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 }