github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/environs/tools/build.go (about)

     1  // Copyright 2013 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package tools
     5  
     6  import (
     7  	"archive/tar"
     8  	"bytes"
     9  	"compress/gzip"
    10  	"crypto/sha256"
    11  	"encoding/json"
    12  	"fmt"
    13  	"io"
    14  	"os"
    15  	"os/exec"
    16  	"path/filepath"
    17  	"strings"
    18  
    19  	"github.com/juju/errors"
    20  	"github.com/juju/version/v2"
    21  
    22  	"github.com/juju/juju/core/arch"
    23  	coreos "github.com/juju/juju/core/os"
    24  	"github.com/juju/juju/juju/names"
    25  	jujuversion "github.com/juju/juju/version"
    26  )
    27  
    28  // Archive writes the executable files found in the given directory in
    29  // gzipped tar format to w.
    30  func Archive(w io.Writer, dir string) error {
    31  	entries, err := os.ReadDir(dir)
    32  	if err != nil {
    33  		return err
    34  	}
    35  
    36  	gzw := gzip.NewWriter(w)
    37  	defer closeErrorCheck(&err, gzw)
    38  
    39  	tarw := tar.NewWriter(gzw)
    40  	defer closeErrorCheck(&err, tarw)
    41  
    42  	for _, ent := range entries {
    43  		fi, err := ent.Info()
    44  		if err != nil {
    45  			logger.Errorf("failed to read file info: %s", ent.Name())
    46  			continue
    47  		}
    48  
    49  		h := tarHeader(fi)
    50  		logger.Debugf("adding entry: %#v", h)
    51  		// ignore local umask
    52  		if isExecutable(fi) {
    53  			h.Mode = 0755
    54  		} else {
    55  			h.Mode = 0644
    56  		}
    57  		err = tarw.WriteHeader(h)
    58  		if err != nil {
    59  			return err
    60  		}
    61  		fileName := filepath.Join(dir, ent.Name())
    62  		if err := copyFile(tarw, fileName); err != nil {
    63  			return err
    64  		}
    65  	}
    66  	return nil
    67  }
    68  
    69  // archiveAndSHA256 calls Archive with the provided arguments,
    70  // and returns a hex-encoded SHA256 hash of the resulting
    71  // archive.
    72  func archiveAndSHA256(w io.Writer, dir string) (sha256hash string, err error) {
    73  	h := sha256.New()
    74  	if err := Archive(io.MultiWriter(h, w), dir); err != nil {
    75  		return "", err
    76  	}
    77  	return fmt.Sprintf("%x", h.Sum(nil)), err
    78  }
    79  
    80  // copyFile writes the contents of the given file to w.
    81  func copyFile(w io.Writer, file string) error {
    82  	f, err := os.Open(file)
    83  	if err != nil {
    84  		return err
    85  	}
    86  	defer f.Close()
    87  	_, err = io.Copy(w, f)
    88  	return err
    89  }
    90  
    91  // tarHeader returns a tar file header given the file's stat
    92  // information.
    93  func tarHeader(i os.FileInfo) *tar.Header {
    94  	return &tar.Header{
    95  		Typeflag:   tar.TypeReg,
    96  		Name:       i.Name(),
    97  		Size:       i.Size(),
    98  		Mode:       int64(i.Mode() & 0777),
    99  		ModTime:    i.ModTime(),
   100  		AccessTime: i.ModTime(),
   101  		ChangeTime: i.ModTime(),
   102  		Uname:      "ubuntu",
   103  		Gname:      "ubuntu",
   104  	}
   105  }
   106  
   107  // isExecutable returns whether the given info
   108  // represents a regular file executable by (at least) the user.
   109  func isExecutable(i os.FileInfo) bool {
   110  	return i.Mode()&(0100|os.ModeType) == 0100
   111  }
   112  
   113  // closeErrorCheck means that we can ensure that
   114  // Close errors do not get lost even when we defer them,
   115  func closeErrorCheck(errp *error, c io.Closer) {
   116  	err := c.Close()
   117  	if *errp == nil {
   118  		*errp = err
   119  	}
   120  }
   121  
   122  func findExecutable(execFile string) (string, error) {
   123  	logger.Debugf("looking for: %s", execFile)
   124  	if filepath.IsAbs(execFile) {
   125  		return execFile, nil
   126  	}
   127  
   128  	dir, file := filepath.Split(execFile)
   129  
   130  	// Now we have two possibilities:
   131  	//   file == path indicating that the PATH was searched
   132  	//   dir != "" indicating that it is a relative path
   133  
   134  	if dir == "" {
   135  		path := os.Getenv("PATH")
   136  		for _, name := range filepath.SplitList(path) {
   137  			result := filepath.Join(name, file)
   138  			// Use exec.LookPath() to check if the file exists and is executable`
   139  			f, err := exec.LookPath(result)
   140  			if err == nil {
   141  				return f, nil
   142  			}
   143  		}
   144  
   145  		return "", fmt.Errorf("could not find %q in the path", file)
   146  	}
   147  	cwd, err := os.Getwd()
   148  	if err != nil {
   149  		return "", err
   150  	}
   151  	return filepath.Clean(filepath.Join(cwd, execFile)), nil
   152  }
   153  
   154  func copyFileWithMode(from, to string, mode os.FileMode) error {
   155  	source, err := os.Open(from)
   156  	if err != nil {
   157  		logger.Infof("open source failed: %v", err)
   158  		return err
   159  	}
   160  	defer source.Close()
   161  	destination, err := os.OpenFile(to, os.O_RDWR|os.O_TRUNC|os.O_CREATE, mode)
   162  	if err != nil {
   163  		logger.Infof("open destination failed: %v", err)
   164  		return err
   165  	}
   166  	defer destination.Close()
   167  	_, err = io.Copy(destination, source)
   168  	if err != nil {
   169  		return err
   170  	}
   171  	return nil
   172  }
   173  
   174  // Override for testing.
   175  var ExistingJujuLocation = existingJujuLocation
   176  
   177  // ExistingJujuLocation returns the directory where 'juju' is running, and where
   178  // we expect to find 'jujuc' and 'jujud'.
   179  func existingJujuLocation() (string, error) {
   180  	jujuLocation, err := findExecutable(os.Args[0])
   181  	if err != nil {
   182  		logger.Infof("%v", err)
   183  		return "", err
   184  	}
   185  	jujuDir := filepath.Dir(jujuLocation)
   186  	return jujuDir, nil
   187  }
   188  
   189  // VersionFileFallbackDir is the other location we'll check for a
   190  // juju-versions file if it's not alongside the binary (for example if
   191  // Juju was installed from a .deb). (Exposed so we can override it in
   192  // tests.)
   193  var VersionFileFallbackDir = "/usr/lib/juju"
   194  
   195  func copyExistingJujus(dir string, skipCopyVersionFile bool) error {
   196  	// Assume that the user is running juju.
   197  	jujuDir, err := ExistingJujuLocation()
   198  	if err != nil {
   199  		logger.Infof("couldn't find existing jujud: %v", err)
   200  		return errors.Trace(err)
   201  	}
   202  	jujudLocation := filepath.Join(jujuDir, names.Jujud)
   203  	logger.Debugf("checking: %s", jujudLocation)
   204  	info, err := os.Stat(jujudLocation)
   205  	if err != nil {
   206  		logger.Infof("couldn't find existing jujud: %v", err)
   207  		return errors.Trace(err)
   208  	}
   209  	logger.Infof("Found agent binary to upload (%s)", jujudLocation)
   210  	target := filepath.Join(dir, names.Jujud)
   211  	logger.Infof("target: %v", target)
   212  	err = copyFileWithMode(jujudLocation, target, info.Mode())
   213  	if err != nil {
   214  		return errors.Trace(err)
   215  	}
   216  	jujucLocation := filepath.Join(jujuDir, names.Jujuc)
   217  	jujucTarget := filepath.Join(dir, names.Jujuc)
   218  	if _, err = os.Stat(jujucLocation); os.IsNotExist(err) {
   219  		logger.Infof("jujuc not found at %s, not including", jujucLocation)
   220  	} else if err != nil {
   221  		return errors.Trace(err)
   222  	} else {
   223  		logger.Infof("target jujuc: %v", jujucTarget)
   224  		err = copyFileWithMode(jujucLocation, jujucTarget, info.Mode())
   225  		if err != nil {
   226  			return errors.Trace(err)
   227  		}
   228  	}
   229  	if skipCopyVersionFile {
   230  		return nil
   231  	}
   232  	// If there's a version file beside the jujud binary or in the
   233  	// fallback location, include that.
   234  	versionTarget := filepath.Join(dir, names.JujudVersions)
   235  
   236  	versionPaths := []string{
   237  		filepath.Join(jujuDir, names.JujudVersions),
   238  		filepath.Join(VersionFileFallbackDir, names.JujudVersions),
   239  	}
   240  	for _, versionPath := range versionPaths {
   241  		info, err = os.Stat(versionPath)
   242  		if os.IsNotExist(err) {
   243  			continue
   244  		} else if err != nil {
   245  			return errors.Trace(err)
   246  		}
   247  		logger.Infof("including versions file %q", versionPath)
   248  		return errors.Trace(copyFileWithMode(versionPath, versionTarget, info.Mode()))
   249  	}
   250  	return nil
   251  }
   252  
   253  func buildJujus(dir string) error {
   254  	logger.Infof("building jujud")
   255  
   256  	// Determine if we are in tree of juju and if to prefer
   257  	// vendor or readonly mod deps.
   258  	var lastErr error
   259  	var cmdDir string
   260  	for _, m := range []string{"-mod=vendor", "-mod=readonly"} {
   261  		var stdout, stderr bytes.Buffer
   262  		cmd := exec.Command("go", "list", "-json", m, "github.com/juju/juju")
   263  		cmd.Env = append(os.Environ(), "GO111MODULE=on")
   264  		cmd.Stderr = &stderr
   265  		cmd.Stdout = &stdout
   266  		err := cmd.Run()
   267  		if err != nil {
   268  			lastErr = fmt.Errorf(`cannot build juju agent outside of github.com/juju/juju tree
   269  			cd into the directory containing juju version=%s commit=%s: %w:
   270  			%s`, jujuversion.Current.String(), jujuversion.GitCommit, err, stderr.String())
   271  			continue
   272  		}
   273  		pkg := struct {
   274  			Root string `json:"Root"`
   275  		}{}
   276  		err = json.Unmarshal(stdout.Bytes(), &pkg)
   277  		if err != nil {
   278  			lastErr = fmt.Errorf("cannot parse go list output for github.com/juju/juju version=%s commit=%s: %w",
   279  				jujuversion.Current.String(), jujuversion.GitCommit, err)
   280  			continue
   281  		}
   282  		lastErr = nil
   283  		cmdDir = pkg.Root
   284  		break
   285  	}
   286  	if lastErr != nil {
   287  		return lastErr
   288  	}
   289  
   290  	// Build binaries.
   291  	cmds := [][]string{
   292  		{"make", "jujud-controller"},
   293  	}
   294  	for _, args := range cmds {
   295  		cmd := exec.Command(args[0], args[1:]...)
   296  		cmd.Env = append(os.Environ(), "GOBIN="+dir)
   297  		cmd.Dir = cmdDir
   298  		out, err := cmd.CombinedOutput()
   299  		if err != nil {
   300  			return fmt.Errorf("build command %q failed: %v; %s", args[0], err, out)
   301  		}
   302  		if logger.IsTraceEnabled() {
   303  			logger.Tracef("Built jujud:\n%s", out)
   304  		}
   305  	}
   306  	return nil
   307  }
   308  
   309  func packageLocalTools(toolsDir string, buildAgent bool) error {
   310  	if !buildAgent {
   311  		if err := copyExistingJujus(toolsDir, true); err != nil {
   312  			return errors.New("no prepackaged agent available and no jujud binary can be found")
   313  		}
   314  		return nil
   315  	}
   316  	logger.Infof("Building agent binary to upload (%s)", jujuversion.Current.String())
   317  	if err := buildJujus(toolsDir); err != nil {
   318  		return errors.Annotate(err, "cannot build jujud agent binary from source")
   319  	}
   320  	return nil
   321  }
   322  
   323  // BundleToolsFunc is a function which can bundle all the current juju tools
   324  // in gzipped tar format to the given writer.
   325  type BundleToolsFunc func(
   326  	build bool, w io.Writer,
   327  	getForceVersion func(version.Number) version.Number,
   328  ) (builtVersion version.Binary, forceVersion version.Number, _ bool, _ string, _ error)
   329  
   330  // Override for testing.
   331  var BundleTools BundleToolsFunc = func(
   332  	build bool, w io.Writer,
   333  	getForceVersion func(version.Number) version.Number,
   334  ) (version.Binary, version.Number, bool, string, error) {
   335  	return bundleTools(build, w, getForceVersion, JujudVersion)
   336  }
   337  
   338  // bundleTools bundles all the current juju tools in gzipped tar
   339  // format to the given writer. A FORCE-VERSION file is included in
   340  // the tools bundle so it will lie about its current version number.
   341  func bundleTools(
   342  	build bool, w io.Writer,
   343  	getForceVersion func(version.Number) version.Number,
   344  	jujudVersion func(dir string) (version.Binary, bool, error),
   345  ) (_ version.Binary, _ version.Number, official bool, sha256hash string, _ error) {
   346  	dir, err := os.MkdirTemp("", "juju-tools")
   347  	if err != nil {
   348  		return version.Binary{}, version.Number{}, false, "", err
   349  	}
   350  	defer os.RemoveAll(dir)
   351  
   352  	existingJujuLocation, err := ExistingJujuLocation()
   353  	if err != nil {
   354  		return version.Binary{}, version.Number{}, false, "", errors.Annotate(err, "couldn't find existing jujud")
   355  	}
   356  	_, official, err = jujudVersion(existingJujuLocation)
   357  	if err != nil && !errors.IsNotFound(err) {
   358  		return version.Binary{}, version.Number{}, official, "", errors.Trace(err)
   359  	}
   360  	if official && build {
   361  		return version.Binary{}, version.Number{}, official, "", errors.Errorf("cannot build agent for official build")
   362  	}
   363  
   364  	if err := packageLocalTools(dir, build); err != nil {
   365  		return version.Binary{}, version.Number{}, false, "", err
   366  	}
   367  
   368  	// We need to get the version again because the juju binaries at dir might be built from source code.
   369  	tvers, official, err := jujudVersion(dir)
   370  	if err != nil {
   371  		return version.Binary{}, version.Number{}, false, "", errors.Trace(err)
   372  	}
   373  	if official {
   374  		logger.Debugf("using official version %s", tvers)
   375  	}
   376  	forceVersion := getForceVersion(tvers.Number)
   377  	logger.Debugf("forcing version to %s", forceVersion)
   378  	if err := os.WriteFile(filepath.Join(dir, "FORCE-VERSION"), []byte(forceVersion.String()), 0666); err != nil {
   379  		return version.Binary{}, version.Number{}, false, "", err
   380  	}
   381  
   382  	sha256hash, err = archiveAndSHA256(w, dir)
   383  	if err != nil {
   384  		return version.Binary{}, version.Number{}, false, "", err
   385  	}
   386  	return tvers, forceVersion, official, sha256hash, err
   387  }
   388  
   389  // Override for testing.
   390  var ExecCommand = exec.Command
   391  
   392  func getVersionFromJujud(dir string) (version.Binary, error) {
   393  	// If there's no jujud, return a NotFound error.
   394  	path := filepath.Join(dir, names.Jujud)
   395  	if _, err := os.Stat(path); err != nil {
   396  		if os.IsNotExist(err) {
   397  			return version.Binary{}, errors.NotFoundf(path)
   398  		}
   399  		return version.Binary{}, errors.Trace(err)
   400  	}
   401  	cmd := ExecCommand(path, "version")
   402  	var stdout, stderr bytes.Buffer
   403  	cmd.Stdout = &stdout
   404  	cmd.Stderr = &stderr
   405  
   406  	if err := cmd.Run(); err != nil {
   407  		return version.Binary{}, errors.Errorf("cannot get version from %q: %v; %s", path, err, stderr.String()+stdout.String())
   408  	}
   409  	tvs := strings.TrimSpace(stdout.String())
   410  	tvers, err := version.ParseBinary(tvs)
   411  	if err != nil {
   412  		return version.Binary{}, errors.Errorf("invalid version %q printed by jujud", tvs)
   413  	}
   414  	return tvers, nil
   415  }
   416  
   417  // JujudVersion returns the Jujud version at the specified location,
   418  // and whether it is an official binary.
   419  func JujudVersion(dir string) (version.Binary, bool, error) {
   420  	tvers, err := getVersionFromFile(dir)
   421  	official := err == nil
   422  	if err != nil && !errors.IsNotFound(err) && !isNoMatchingToolsChecksum(err) {
   423  		return version.Binary{}, false, errors.Trace(err)
   424  	}
   425  	if errors.IsNotFound(err) || isNoMatchingToolsChecksum(err) {
   426  		// No signature file found.
   427  		// Extract the version number that the jujud binary was built with.
   428  		// This is used to check compatibility with the version of the client
   429  		// being used to bootstrap.
   430  		tvers, err = getVersionFromJujud(dir)
   431  		if err != nil {
   432  			return version.Binary{}, false, errors.Trace(err)
   433  		}
   434  	}
   435  	return tvers, official, nil
   436  }
   437  
   438  type noMatchingToolsChecksum struct {
   439  	versionPath string
   440  	jujudPath   string
   441  }
   442  
   443  func (e *noMatchingToolsChecksum) Error() string {
   444  	return fmt.Sprintf("no SHA256 in version file %q matches binary %q", e.versionPath, e.jujudPath)
   445  }
   446  
   447  func isNoMatchingToolsChecksum(err error) bool {
   448  	_, ok := err.(*noMatchingToolsChecksum)
   449  	return ok
   450  }
   451  
   452  func getVersionFromFile(dir string) (version.Binary, error) {
   453  	versionPath := filepath.Join(dir, names.JujudVersions)
   454  	sigFile, err := os.Open(versionPath)
   455  	if os.IsNotExist(err) {
   456  		return version.Binary{}, errors.NotFoundf("version file %q", versionPath)
   457  	} else if err != nil {
   458  		return version.Binary{}, errors.Trace(err)
   459  	}
   460  	defer sigFile.Close()
   461  
   462  	versions, err := ParseVersions(sigFile)
   463  	if err != nil {
   464  		return version.Binary{}, errors.Trace(err)
   465  	}
   466  
   467  	// Find the binary by hash.
   468  	jujudPath := filepath.Join(dir, names.Jujud)
   469  	jujudFile, err := os.Open(jujudPath)
   470  	if err != nil {
   471  		return version.Binary{}, errors.Trace(err)
   472  	}
   473  	defer jujudFile.Close()
   474  	matching, err := versions.VersionsMatching(jujudFile)
   475  	if err != nil {
   476  		return version.Binary{}, errors.Trace(err)
   477  	}
   478  	if len(matching) == 0 {
   479  		return version.Binary{}, &noMatchingToolsChecksum{versionPath, jujudPath}
   480  	}
   481  	return selectBinary(matching)
   482  }
   483  
   484  func selectBinary(versions []string) (version.Binary, error) {
   485  	thisArch := arch.HostArch()
   486  	thisHost := coreos.HostOSTypeName()
   487  	var current version.Binary
   488  	for _, ver := range versions {
   489  		var err error
   490  		current, err = version.ParseBinary(ver)
   491  		if err != nil {
   492  			return version.Binary{}, errors.Trace(err)
   493  		}
   494  		if current.Release == thisHost && current.Arch == thisArch {
   495  			return current, nil
   496  		}
   497  	}
   498  	// There's no version matching our osType/arch, but the signature
   499  	// still matches the binary for all versions passed in, so just
   500  	// punt.
   501  	return current, nil
   502  }