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  }