1 package patchmanager
2
3 import (
4 "archive/tar"
5 "bufio"
6 "compress/gzip"
7 "crypto/sha256"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "io"
12 "io/fs"
13 "os"
14 "path"
15 "path/filepath"
16 "regexp"
17 "sort"
18 "strings"
19
20 "slices"
21
22 "github.com/spf13/afero"
23 "gopkg.in/yaml.v2"
24
25 "github.com/go-logr/logr"
26 "github.com/hashicorp/go-version"
27
28 "edge-infra.dev/pkg/sds/patching/common"
29 )
30
31 var (
32 upgradePhases = []string{
33 "pre-reboot",
34 "upgrade-mode",
35 "post-reboot",
36 }
37 )
38
39 const completeSuffix = "-complete"
40
41 type DirectivesConfig struct {
42 Directives []struct {
43 Regex string `yaml:"regex"`
44 ScriptOptions []struct {
45 Name string
46 Skip bool
47 Weight int
48 } `yaml:"script_options"`
49 }
50 }
51
52 func (p *PatchManager) installPatchset() error {
53 dlComplete, err := p.DownloadComplete()
54 if err != nil {
55 return err
56 }
57 if dlComplete {
58 p.Log.Info("Download already complete")
59 return nil
60 }
61
62 p.Log.Info("House keeping old version files")
63 versionRetention := []string{p.CurrentVer, p.TargetVer}
64 if err := housekeepVersions(versionRetention, p.Log, p.Cfg); err != nil {
65 return err
66 }
67
68 if err := prepareLegacy(p.CurrentVer, p.Log, p.Cfg); err != nil {
69 return err
70 }
71
72 p.Log.Info("Preparing directories")
73 targetPath := GetArtefactsPath(p.TargetVer, p.Cfg)
74 if err := prepareDirectories(targetPath, p.Cfg); err != nil {
75 return err
76 }
77
78 p.Log.Info("Extracting files")
79 if err := extractPatchset(p.TargetVer, p.Log, p.Cfg); err != nil {
80 return fmt.Errorf("Failed to unpack artefacts from blob: %w", err)
81 }
82
83 p.Log.Info("Preparing upgrade scripts")
84 if err := p.installPatchScripts(); err != nil {
85 return fmt.Errorf("Failed to install upgrade scripts: %w", err)
86 }
87
88
89 if err := p.Fs.RemoveAll(p.Cfg.ScriptsTempPath); err != nil {
90 return err
91 }
92
93 if err := addCasperSymlink(p.TargetVer, p.Cfg); err != nil {
94 return err
95 }
96
97 if _, err := os.OpenFile(filepath.Join(targetPath, ".complete"), os.O_RDONLY|os.O_CREATE, 0655); err != nil {
98 return err
99 }
100
101
102
103
104
105 return nil
106 }
107
108 func addCasperSymlink(version string, cfg common.Config) error {
109 squashName := "filesystem.squashfs"
110 versionPath := GetArtefactsPath(version, cfg)
111 casper := path.Join(versionPath, "casper")
112 if err := os.Mkdir(casper, 0755); err != nil {
113 return err
114 }
115 return os.Symlink(path.Join(versionPath, squashName), path.Join(casper, squashName))
116 }
117
118
119 func prepareDirectories(targetPath string, cfg common.Config) error {
120 if err := os.RemoveAll(targetPath); err != nil {
121 return err
122 }
123
124 if err := os.RemoveAll(cfg.ArtefactsTempPath); err != nil {
125 return err
126 }
127
128 if err := os.RemoveAll(cfg.ScriptsTempPath); err != nil {
129 return err
130 }
131
132 return createScriptDirectories(cfg)
133 }
134
135
136 func extractTarFile(source io.Reader, log logr.Logger, cfg common.Config) error {
137 uncompressedStream, err := gzip.NewReader(source)
138 if err != nil {
139 return err
140 }
141 defer uncompressedStream.Close()
142
143 tarReader := tar.NewReader(uncompressedStream)
144
145 for {
146 header, err := tarReader.Next()
147 if err == io.EOF {
148 break
149 } else if err != nil {
150 return err
151 }
152
153 filePath, err := getDestPath(header.Name, cfg)
154 if err != nil {
155 return err
156 }
157
158 switch header.Typeflag {
159 case tar.TypeDir:
160 if err := os.MkdirAll(filePath, 0755); err != nil {
161 return err
162 }
163 case tar.TypeReg:
164 if err := writeFile(filePath, tarReader, header, log); err != nil {
165 return err
166 }
167 default:
168 return fmt.Errorf("Unknown header type: %x name: %s", header.Typeflag, header.Name)
169 }
170 }
171 return nil
172 }
173
174
175 func getDestPath(header string, cfg common.Config) (string, error) {
176 var filePath string
177
178 if strings.HasPrefix(header, common.BootFiles) {
179 filePath = filepath.Join(cfg.ArtefactsTempPath, strings.Replace(header, common.BootFiles, "", 1))
180 } else if strings.HasPrefix(header, common.ScriptFiles) {
181 filePath = filepath.Join(cfg.ScriptsTempPath, strings.Replace(header, common.ScriptFiles, "", 1))
182 } else {
183 return "", fmt.Errorf("Unexpected filepath in header %s", header)
184 }
185
186
187
188 if !strings.HasPrefix(filePath, filepath.Clean(filePath)) {
189 return "", fmt.Errorf("%s: %s", "content filepath is tainted", header)
190 }
191 return filePath, nil
192 }
193
194
195 func writeFile(filepath string, tarReader *tar.Reader, header *tar.Header, log logr.Logger) error {
196 const bufferSize int64 = 4 * common.KiB
197 const stepSize int64 = 100 * common.MiB
198
199 fsMode := fs.FileMode(header.Mode)
200 outFile, err := os.OpenFile(filepath, os.O_WRONLY|os.O_CREATE, fsMode)
201 if err != nil {
202 return err
203 }
204 defer outFile.Close()
205
206 for read := int64(0); read < header.Size; read += bufferSize {
207 _, err := io.CopyN(outFile, tarReader, bufferSize)
208 if err != nil {
209 if err == io.EOF {
210 break
211 }
212 return err
213 }
214 if read%(stepSize) == 0 {
215 log.Info(fmt.Sprintf("Extracting %s %d Mib / %d Mib", filepath, read/common.MiB, header.Size/common.MiB))
216 }
217 }
218 log.Info(fmt.Sprintf("Extracting %s done", filepath))
219
220 stat, err := outFile.Stat()
221 if err != nil {
222 return err
223 }
224 if stat.Size() != header.Size {
225 return fmt.Errorf("Extracted file %s size does not match header", header.Name)
226 }
227 return nil
228 }
229
230 func extractPatchset(targetVersion string, log logr.Logger, cfg common.Config) error {
231 file, err := os.Open(cfg.PatchsetMount)
232 if err != nil {
233 return err
234 }
235
236 defer file.Close()
237
238 if err := extractTarFile(file, log, cfg); err != nil {
239 return err
240 }
241
242 return os.Rename(cfg.ArtefactsTempPath, GetArtefactsPath(targetVersion, cfg))
243 }
244
245 func GetArtefactsPath(version string, cfg common.Config) string {
246 return filepath.Join(cfg.ArtefactsPath, version)
247 }
248
249
250
251 func prepareLegacy(currentVer string, log logr.Logger, cfg common.Config) error {
252 maxVer, _ := version.NewVersion("v1.10.0")
253 current, err := version.NewVersion(currentVer)
254 if err != nil {
255 return err
256 }
257 if current.GreaterThanOrEqual(maxVer) {
258 log.Info("Removing BWC symlink")
259 return os.RemoveAll(cfg.LegacyScriptsPath)
260 }
261 stat, err := os.Lstat(cfg.LegacyScriptsPath)
262 if err == nil {
263 log.Info("Legacy script dir/link exists")
264 if stat.Mode()&fs.ModeSymlink != 0 {
265 log.Info("Legacy upgrade scripts is already a symlink")
266 return nil
267 }
268
269 log.Info("Deleting " + cfg.ScriptsPath)
270 if err := os.RemoveAll(cfg.ScriptsPath); err != nil {
271 return nil
272 }
273 log.Info("Legacy upgrade scripts dir exists. Renaming")
274 if err := os.Rename(cfg.LegacyScriptsPath, cfg.ScriptsPath); err != nil {
275 return err
276 }
277 if err := os.Chmod(cfg.ScriptsPath, 0744); err != nil {
278 return err
279 }
280 }
281 log.Info("Legacy script dir/link does not exist")
282 return os.Symlink("patching", cfg.LegacyScriptsPath)
283 }
284
285 func createScriptDirectories(cfg common.Config) error {
286 if err := os.MkdirAll(cfg.ScriptsPath, 0744); err != nil {
287 return err
288 }
289 for _, phase := range upgradePhases {
290 path := filepath.Join(cfg.ScriptsPath, phase)
291 if err := os.RemoveAll(path); err != nil {
292 return err
293 }
294 if err := os.MkdirAll(path, 0744); err != nil {
295 return err
296 }
297 if err := os.MkdirAll(path, 0744); err != nil {
298 return err
299 }
300
301 if err := os.MkdirAll(path+completeSuffix, 0744); err != nil {
302 return err
303 }
304 if err := os.Chmod(path+completeSuffix, 0744); err != nil {
305 return err
306 }
307 }
308 return nil
309 }
310
311
312 func housekeepVersions(retentionVers []string, log logr.Logger, cfg common.Config) error {
313 log.Info("House keeping old versions")
314 versionsDir := cfg.ArtefactsPath
315 artefacts, err := os.ReadDir(versionsDir)
316 if err != nil {
317 return err
318 }
319
320 for _, artefact := range artefacts {
321 if !slices.Contains(retentionVers, artefact.Name()) {
322 log.Info("Removing version " + artefact.Name())
323 if err = os.RemoveAll(filepath.Join(versionsDir, artefact.Name())); err != nil {
324 return err
325 }
326 } else {
327 log.Info("Keeping version " + artefact.Name())
328 }
329 }
330 return nil
331 }
332
333 func ValidateFiles(fs afero.Fs, targetPath string, log logr.Logger) error {
334 file, err := fs.Open(filepath.Join(targetPath, "checksum"))
335 if err != nil {
336 log.Error(err, "Checksum file not found")
337 }
338 scanner := bufio.NewScanner(file)
339 for scanner.Scan() {
340 var filePath string
341 var sha256sum string
342 n, err := fmt.Sscanf(scanner.Text(), "%s %s", &sha256sum, &filePath)
343 if n != 2 || err != nil {
344 return fmt.Errorf("Failed to parse checksum file")
345 }
346 if err := ValidateChecksum(filepath.Join(targetPath, filePath), sha256sum); err != nil {
347 return err
348 }
349 }
350 return nil
351 }
352
353 func ValidateChecksum(filePath, checksum string) error {
354 fileChecksum, err := GetSha256(filePath)
355 if err != nil {
356 return err
357 }
358
359 if fileChecksum != checksum {
360 return fmt.Errorf("File %s checksum is not %s", filePath, checksum)
361 }
362 return nil
363 }
364
365 func GetSha256(filePath string) (string, error) {
366 file, err := os.Open(filePath)
367 if err != nil {
368 return "", err
369 }
370
371 defer file.Close()
372
373 hasher := sha256.New()
374 if _, err := io.Copy(hasher, file); err != nil {
375 return "", err
376 }
377 return hex.EncodeToString(hasher.Sum(nil)), nil
378 }
379
380 func (p *PatchManager) installPatchScripts() error {
381 controlPlane, err := p.getControlPlaneHostname()
382 if err != nil {
383 p.Log.Error(err, "Could not find control plane")
384 return err
385 }
386 isCP := p.HostName == controlPlane
387
388 currentVer, err := version.NewVersion(p.CurrentVer)
389 if err != nil {
390 return err
391 }
392
393 dirs, err := afero.ReadDir(p.Fs, p.Cfg.ScriptsTempPath)
394 if err != nil {
395 return err
396 }
397
398 versions, err := versionSort(dirs)
399 if err != nil {
400 return err
401 }
402
403 configs, err := loadDirectivesConfig(versions, p.Cfg)
404 if err != nil {
405 return err
406 }
407
408 hashMaps := make(map[string]map[string]string)
409 for _, phase := range upgradePhases {
410 hashMaps[phase] = make(map[string]string)
411 }
412 for _, version := range versions {
413 if version.LessThanOrEqual(currentVer) && !strings.HasSuffix(version.Original(), "-dev") {
414 p.Log.Info("Skipping upgrade scripts for version " + version.Original())
415 continue
416 }
417
418 p.Log.Info("Installing upgrade scripts for version " + version.Original())
419 if err := installScriptsVersion(currentVer, version, configs, isCP, p.Log, p.Cfg, hashMaps); err != nil {
420 return err
421 }
422 }
423 return nil
424 }
425
426 func installScriptsVersion(currentVer, version *version.Version, configs []DirectivesConfig, isControlPlane bool, log logr.Logger, cfg common.Config, hashMaps map[string]map[string]string) error {
427 path := filepath.Join(cfg.ScriptsTempPath, version.Original())
428
429 for _, phase := range upgradePhases {
430 phasePath := filepath.Join(path, phase)
431 files, err := os.ReadDir(phasePath)
432 if err != nil {
433 log.Error(err, "Failed to read script dir. Skipping")
434 return nil
435 }
436 for _, file := range files {
437 fileName := file.Name()
438 filePath := filepath.Join(phasePath, fileName)
439
440 fileSha256, err := GetSha256(filePath)
441 if err != nil {
442 return err
443 }
444
445 phaseHashMap := hashMaps[phase]
446 _, exists := phaseHashMap[fileSha256]
447 if exists {
448 log.Info(fmt.Sprintf("Skipping upgrade script (duplicate) %s %s %s", version.Original(), phase, fileName))
449 continue
450 }
451 phaseHashMap[fileSha256] = fileName
452 if stat, _ := os.Lstat(filepath.Join(cfg.ScriptsPath, phase+completeSuffix, fileSha256)); stat != nil {
453 log.Info(fmt.Sprintf("Skipping upgrade script (exists) %s %s %s", version.Original(), phase, fileName))
454 continue
455 }
456 if fileName == ".gitignore" {
457 continue
458 }
459 if roleFilter(fileName, isControlPlane) {
460 log.Info(fmt.Sprintf("Skipping upgrade script (role filter) %s %s %s", version.Original(), phase, fileName))
461 continue
462 }
463 skip, weight := checkDirectives(configs, currentVer.Original(), fileName)
464 if skip {
465 log.Info(fmt.Sprintf("Skipping upgrade script (config.yaml) %s %s %s", version.Original(), phase, fileName))
466 continue
467 }
468 if IsExecutable(filePath) {
469 fileName = fmt.Sprintf("%d_%s_%s", weight, padVersion(version), fileName)
470 }
471 newPath := filepath.Join(cfg.ScriptsPath, phase, fileName)
472 log.Info(fmt.Sprintf("Installing upgrade %s to %s", file.Name(), newPath))
473 if err := os.Rename(filePath, newPath); err != nil {
474 return err
475 }
476 }
477 }
478
479 return nil
480 }
481
482 func loadDirectivesConfig(versions []*version.Version, cfg common.Config) (configs []DirectivesConfig, err error) {
483 for _, version := range versions {
484 configPath := filepath.Join(cfg.ScriptsTempPath, version.Original(), "config.yaml")
485 fh, err := os.OpenFile(configPath, os.O_RDONLY, 0644)
486 if errors.Is(err, fs.ErrNotExist) {
487 continue
488 } else if err != nil {
489 return configs, err
490 }
491 defer fh.Close()
492
493 var fileContents []byte
494 fileContents, err = io.ReadAll(fh)
495 if err != nil {
496 return configs, err
497 }
498
499 var config DirectivesConfig
500 if err = yaml.Unmarshal(fileContents, &config); err != nil {
501 return configs, err
502 }
503
504 configs = append(configs, config)
505 }
506 return configs, err
507 }
508
509 func versionSort(versionsRaw []fs.FileInfo) (versions []*version.Version, err error) {
510 versions = make([]*version.Version, len(versionsRaw))
511 for i, raw := range versionsRaw {
512 var v *version.Version
513 v, err = version.NewVersion(raw.Name())
514 if err != nil {
515 return
516 }
517 versions[i] = v
518 }
519 sort.Sort(version.Collection(versions))
520 return
521 }
522
523 func checkDirectives(directivesConfigs []DirectivesConfig, currentVer, name string) (bool, int) {
524 skip := false
525 weight := 10
526
527 for _, directivesConfig := range directivesConfigs {
528 for _, directive := range directivesConfig.Directives {
529 re, _ := regexp.Compile(directive.Regex)
530 if !re.Match([]byte(currentVer)) {
531 continue
532 }
533 for _, option := range directive.ScriptOptions {
534 if option.Name == name {
535 skip = option.Skip
536 weight = option.Weight
537 }
538 }
539 }
540 }
541
542 return skip, weight
543 }
544
545 func padVersion(version *version.Version) string {
546 arr := version.Segments()
547 out := fmt.Sprintf("v%02d.%02d.%02d", arr[0], arr[1], arr[2])
548 if version.Prerelease() != "" {
549 out += "-" + version.Prerelease()
550 }
551 return out
552 }
553
554
555
556 func roleFilter(fileName string, isControlPlane bool) bool {
557 splits := strings.Split(fileName, ".")
558
559 if (slices.Contains(splits, "cp") && !isControlPlane) ||
560 (slices.Contains(splits, "tp") && isControlPlane) {
561 return true
562 }
563 return false
564 }
565
566 func IsExecutable(filepath string) bool {
567 stat, _ := os.Lstat(filepath)
568 return stat.Mode()&0111 != 0
569 }
570
View as plain text