github.com/yourbase/yb@v0.7.1/cmd/yb/helpers.go (about) 1 package main 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/rand" 7 "encoding/hex" 8 "errors" 9 "fmt" 10 "net" 11 "os" 12 "os/user" 13 "path/filepath" 14 "runtime" 15 "sort" 16 "strconv" 17 "strings" 18 19 docker "github.com/fsouza/go-dockerclient" 20 "github.com/spf13/cobra" 21 "github.com/spf13/pflag" 22 "github.com/yourbase/yb" 23 "github.com/yourbase/yb/internal/biome" 24 "github.com/yourbase/yb/internal/build" 25 "github.com/yourbase/yb/internal/config" 26 "github.com/yourbase/yb/internal/ybdata" 27 "zombiezen.com/go/log" 28 ) 29 30 type executionMode int 31 32 const ( 33 noContainer executionMode = -1 34 preferHost executionMode = 0 35 useContainer executionMode = 1 36 ) 37 38 func (mode executionMode) String() string { 39 switch mode { 40 case noContainer: 41 return "no-container" 42 case preferHost: 43 return "host" 44 case useContainer: 45 return "container" 46 default: 47 return fmt.Sprint(int(mode)) 48 } 49 } 50 51 func (mode *executionMode) Set(s string) error { 52 switch strings.ToLower(s) { 53 case "no-container": 54 *mode = noContainer 55 case "host": 56 *mode = preferHost 57 case "container": 58 *mode = useContainer 59 default: 60 return fmt.Errorf("invalid execution mode %q", s) 61 } 62 return nil 63 } 64 65 func (mode executionMode) Type() string { 66 return "host|container|no-container" 67 } 68 69 // executionModeVar registers the --mode flag. 70 func executionModeVar(flags *pflag.FlagSet, mode *executionMode) { 71 flags.Var(mode, "mode", "how to execute commands in target") 72 flags.AddFlag(&pflag.Flag{ 73 Name: "no-container", 74 Value: noContainerFlag{mode}, 75 Usage: "Avoid using Docker if possible", 76 DefValue: "false", 77 NoOptDefVal: "true", 78 Hidden: true, 79 }) 80 } 81 82 func connectDockerClient(mode executionMode) (*docker.Client, error) { 83 if mode <= noContainer { 84 return nil, nil 85 } 86 dockerClient, err := docker.NewVersionedClientFromEnv("1.39") 87 if err != nil { 88 return nil, err 89 } 90 return dockerClient, nil 91 } 92 93 type newBiomeOptions struct { 94 packageDir string 95 downloader *ybdata.Downloader 96 dataDirs *ybdata.Dirs 97 baseEnv biome.Environment 98 netrcFiles []string 99 100 executionMode executionMode 101 dockerClient *docker.Client 102 dockerNetworkID string 103 } 104 105 func newBiome(ctx context.Context, target *yb.Target, opts newBiomeOptions) (biome.BiomeCloser, error) { 106 useDocker := willUseDockerForCommands(opts.executionMode, []*yb.Target{target}) 107 if useDocker && opts.dockerClient == nil { 108 return nil, fmt.Errorf("set up environment for target %s: docker required but unavailable", target.Name) 109 } 110 log.Debugf(ctx, "Checking for netrc data in %s", 111 append(append([]string(nil), config.DefaultNetrcFiles()...), opts.netrcFiles...)) 112 netrc, err := config.CatFiles(config.DefaultNetrcFiles(), opts.netrcFiles) 113 if err != nil { 114 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 115 } 116 if !useDocker { 117 l := biome.Local{ 118 PackageDir: opts.packageDir, 119 } 120 var err error 121 l.HomeDir, err = opts.dataDirs.BuildHome(opts.packageDir, target.Name, l.Describe()) 122 if err != nil { 123 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 124 } 125 log.Debugf(ctx, "Home located at %s", l.HomeDir) 126 if err := ensureKeychain(ctx, l); err != nil { 127 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 128 } 129 bio, err := injectNetrc(ctx, l, netrc) 130 if err != nil { 131 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 132 } 133 return biome.EnvBiome{ 134 Biome: bio, 135 Env: opts.baseEnv, 136 }, nil 137 } 138 139 dockerDesc, err := biome.DockerDescriptor(ctx, opts.dockerClient) 140 if err != nil { 141 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 142 } 143 home, err := opts.dataDirs.BuildHome(opts.packageDir, target.Name, dockerDesc) 144 if err != nil { 145 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 146 } 147 log.Debugf(ctx, "Home located at %s", home) 148 tiniFile, err := opts.downloader.Download(ctx, biome.TiniURL) 149 if err != nil { 150 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 151 } 152 defer tiniFile.Close() 153 c, err := biome.CreateContainer(ctx, opts.dockerClient, &biome.ContainerOptions{ 154 PackageDir: opts.packageDir, 155 HomeDir: home, 156 TiniExe: tiniFile, 157 Definition: target.Container, 158 NetworkID: opts.dockerNetworkID, 159 PullOutput: os.Stderr, 160 }) 161 if err != nil { 162 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 163 } 164 bio, err := injectNetrc(ctx, c, netrc) 165 if err != nil { 166 return nil, fmt.Errorf("set up environment for target %s: %w", target.Name, err) 167 } 168 return biome.EnvBiome{ 169 Biome: bio, 170 Env: opts.baseEnv, 171 }, nil 172 } 173 174 // netrcFlagVar registers the --netrc flag. 175 func netrcFlagVar(flags *pflag.FlagSet, netrc *[]string) { 176 // StringArray makes every --netrc flag add to the list. 177 // StringSlice does this too, but also permits comma-separated. 178 // (Not great names. It isn't obvious until you look at the source.) 179 flags.StringArrayVar(netrc, "netrc-file", nil, "Inject a netrc `file` (can be passed multiple times to concatenate)") 180 } 181 182 func injectNetrc(ctx context.Context, bio biome.BiomeCloser, netrc []byte) (biome.BiomeCloser, error) { 183 if len(netrc) == 0 { 184 log.Debugf(ctx, "No .netrc data, skipping") 185 return bio, nil 186 } 187 const netrcFilename = ".netrc" 188 log.Infof(ctx, "Writing .netrc") 189 netrcPath := bio.JoinPath(bio.Dirs().Home, netrcFilename) 190 err := biome.WriteFile(ctx, bio, netrcPath, bytes.NewReader(netrc)) 191 if err != nil { 192 return nil, fmt.Errorf("write netrc: %w", err) 193 } 194 err = runCommand(ctx, bio, "chmod", "600", netrcPath) 195 if err != nil { 196 // Not fatal. File will be removed later. 197 log.Warnf(ctx, "Making temporary .netrc private: %v", err) 198 } 199 return biome.WithClose(bio, func() error { 200 ctx := context.Background() 201 err := runCommand(ctx, bio, 202 "rm", bio.JoinPath(bio.Dirs().Home, netrcFilename)) 203 if err != nil { 204 log.Warnf(ctx, "Could not clean up .netrc: %v", err) 205 } 206 return nil 207 }), nil 208 } 209 210 func runCommand(ctx context.Context, bio biome.Biome, argv ...string) error { 211 output := new(strings.Builder) 212 err := bio.Run(ctx, &biome.Invocation{ 213 Argv: argv, 214 Stdout: output, 215 Stderr: output, 216 }) 217 if err != nil { 218 if output.Len() > 0 { 219 return fmt.Errorf("%w\n%s", err, output) 220 } 221 return err 222 } 223 return nil 224 } 225 226 func newDockerNetwork(ctx context.Context, client *docker.Client, mode executionMode, targets []*yb.Target) (string, func(), error) { 227 if client == nil || !willUseDocker(mode, targets) { 228 return "", func() {}, nil 229 } 230 var bits [8]byte 231 if _, err := rand.Read(bits[:]); err != nil { 232 return "", nil, fmt.Errorf("create docker network: generate name: %w", err) 233 } 234 name := hex.EncodeToString(bits[:]) 235 log.Infof(ctx, "Creating Docker network %s...", name) 236 network, err := client.CreateNetwork(docker.CreateNetworkOptions{ 237 Context: ctx, 238 Name: name, 239 Driver: "bridge", 240 }) 241 if err != nil { 242 return "", nil, fmt.Errorf("create docker network: %w", err) 243 } 244 id := network.ID 245 return id, func() { 246 log.Infof(ctx, "Removing Docker network %s...", name) 247 if err := client.RemoveNetwork(id); err != nil { 248 log.Warnf(ctx, "Unable to remove Docker network %s (%s): %v", name, id, err) 249 } 250 }, nil 251 } 252 253 func showDockerWarningsIfNeeded(ctx context.Context, mode executionMode, targets []*yb.Target) { 254 if !willUseDocker(mode, targets) || runtime.GOOS != biome.Linux { 255 return 256 } 257 dockerGroup, err := user.LookupGroup("docker") 258 if errors.As(err, new(user.UnknownGroupError)) { 259 log.Warnf(ctx, "yb will use Docker, but 'docker' group does not exist. If something goes wrong, check the Docker installation instructions at https://docs.docker.com/engine/install/") 260 return 261 } 262 if err != nil { 263 log.Debugf(ctx, "Could not determine 'docker' group information to display Docker warning: %v", err) 264 return 265 } 266 u, err := user.Current() 267 if err != nil { 268 log.Debugf(ctx, "Could not determine user information to display Docker warning: %v", err) 269 return 270 } 271 gids, err := u.GroupIds() 272 if err != nil { 273 log.Debugf(ctx, "Could not determine groups to display Docker warning: %v", err) 274 return 275 } 276 for _, gid := range gids { 277 if gid == dockerGroup.Gid { 278 return 279 } 280 } 281 log.Warnf(ctx, "yb will use Docker, but %s is not part of the 'docker' group. If something goes wrong, check the Docker installation instructions at https://docs.docker.com/engine/install/", u.Username) 282 } 283 284 func willUseDocker(mode executionMode, targets []*yb.Target) bool { 285 for _, target := range targets { 286 for name := range target.Resources { 287 if os.Getenv(build.ContainerIPEnvVar(name)) == "" { 288 return true 289 } 290 } 291 } 292 return willUseDockerForCommands(mode, targets) 293 } 294 295 func willUseDockerForCommands(mode executionMode, targets []*yb.Target) bool { 296 networkAvailable, _ := hostHasDockerNetwork() 297 for _, target := range targets { 298 if target.UseContainer { 299 return true 300 } 301 for name := range target.Resources { 302 if os.Getenv(build.ContainerIPEnvVar(name)) == "" && !networkAvailable { 303 return true 304 } 305 } 306 } 307 return mode >= useContainer 308 } 309 310 // findPackage searches for the package configuration file in the current 311 // working directory or any parent directory. If the current working directory 312 // is a subdirectory of the package, subdir is the path of the working directory 313 // relative to pkg.Path. 314 func findPackage() (pkg *yb.Package, subdir string, err error) { 315 dir, err := os.Getwd() 316 if err != nil { 317 return nil, "", fmt.Errorf("find package configuration: %w", err) 318 } 319 for { 320 pkg, err = yb.LoadPackage(filepath.Join(dir, yb.PackageConfigFilename)) 321 if err == nil { 322 return pkg, subdir, nil 323 } 324 if !errors.Is(err, os.ErrNotExist) { 325 return nil, "", fmt.Errorf("find package configuration: %w", err) 326 } 327 328 // Not found. Move up a directory. 329 parent, name := filepath.Split(dir) 330 if parent == dir { 331 // Hit root. 332 return nil, "", fmt.Errorf("find package configuration: %s not found in this or any parent directories", yb.PackageConfigFilename) 333 } 334 subdir = filepath.Join(name, subdir) 335 dir = filepath.Clean(parent) // strip trailing separators 336 } 337 } 338 339 func listTargetNames(targets map[string]*yb.Target) []string { 340 names := make([]string, 0, len(targets)) 341 for name := range targets { 342 names = append(names, name) 343 } 344 sort.Strings(names) 345 return names 346 } 347 348 // autocompleteTargetName provides tab completion suggestions for target names. 349 func autocompleteTargetName(toComplete string) ([]string, cobra.ShellCompDirective) { 350 pkg, _, err := findPackage() 351 if err != nil { 352 return nil, cobra.ShellCompDirectiveError 353 } 354 names := make([]string, 0, len(pkg.Targets)) 355 for k := range pkg.Targets { 356 if strings.HasPrefix(k, toComplete) { 357 names = append(names, k) 358 } 359 } 360 sort.Strings(names) 361 return names, cobra.ShellCompDirectiveNoFileComp 362 } 363 364 type noContainerFlag struct { 365 mode *executionMode 366 } 367 368 func (f noContainerFlag) String() string { 369 return strconv.FormatBool(*f.mode == noContainer) 370 } 371 372 func (f noContainerFlag) Set(s string) error { 373 v, err := strconv.ParseBool(s) 374 if err != nil { 375 return err 376 } 377 if v { 378 *f.mode = noContainer 379 } else { 380 *f.mode = preferHost 381 } 382 return nil 383 } 384 385 func (f noContainerFlag) Type() string { 386 return "bool" 387 } 388 389 // hostHasDockerNetwork returns true if the Docker network bridge ("docker0" as 390 // reported by ifconfig and brctl) exists, or false otherwise. This interface 391 // serves as a network bridge between Docker containers. 392 // 393 // Common reasons for the interface not existing are that Docker is not 394 // installed, or that the host is running macOS or WSL2 (operating systems in 395 // which Docker doesn't establish the bridge on the host). 396 func hostHasDockerNetwork() (bool, error) { 397 interfaces, err := net.Interfaces() 398 if err != nil { 399 return false, fmt.Errorf("cannot check for docker bridge: %w", err) 400 } 401 402 for _, i := range interfaces { 403 if i.Name == "docker0" { 404 return true, nil 405 } 406 } 407 return false, nil 408 }