...

Source file src/edge-infra.dev/pkg/sds/patching/patchmanager/patchextract.go

Documentation: edge-infra.dev/pkg/sds/patching/patchmanager

     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  	// Clean up temp scripts dir
    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  	//if err := ValidateFiles(targetPath, log); err != nil {
   102  	//	log.Error(err, "Failed to validate checksum")
   103  	//	return err
   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  // Prepare patching directories for install
   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  // Extract tar file to temp destination files dependent on root dir
   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  // Generate output path based on file
   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  	// #nosec G305
   187  	// Validation for G305
   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  // Write file from tar IOStream to disk
   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) /* #nosec G115 */
   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  // During the move to IEN v1.10.0. The scripts directory location has changed.
   250  // Rename existing and symlink
   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  		// The new dir shouldn't exist outside of testing, clean it up either way
   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  		// create folder for completed scripts
   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  // Delete versions files not included in retentionVers
   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  // Filters if script should be included by node role
   555  // true if role mismatches filename's .tp or .cp
   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