go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cipd/client/cli/friendly.go (about)

     1  // Copyright 2015 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package cli
    16  
    17  import (
    18  	"context"
    19  	"encoding/json"
    20  	"flag"
    21  	"fmt"
    22  	"os"
    23  	"path/filepath"
    24  
    25  	"github.com/maruel/subcommands"
    26  
    27  	"go.chromium.org/luci/auth/client/authcli"
    28  	"go.chromium.org/luci/common/cli"
    29  	"go.chromium.org/luci/common/errors"
    30  
    31  	"go.chromium.org/luci/cipd/client/cipd"
    32  	"go.chromium.org/luci/cipd/client/cipd/deployer"
    33  	"go.chromium.org/luci/cipd/client/cipd/fs"
    34  	"go.chromium.org/luci/cipd/common"
    35  	"go.chromium.org/luci/cipd/common/cipderr"
    36  )
    37  
    38  ////////////////////////////////////////////////////////////////////////////////
    39  // Site root path resolution.
    40  
    41  // findSiteRoot returns a directory R such as R/.cipd exists and p is inside
    42  // R or p is R. Returns empty string if no such directory.
    43  func findSiteRoot(p string) string {
    44  	for {
    45  		if isSiteRoot(p) {
    46  			return p
    47  		}
    48  		// Dir returns "/" (or C:\\) when it encounters the root directory. This is
    49  		// the only case when the return value of Dir(...) ends with separator.
    50  		parent := filepath.Dir(p)
    51  		if parent[len(parent)-1] == filepath.Separator {
    52  			// It is possible disk root has .cipd directory, check it.
    53  			if isSiteRoot(parent) {
    54  				return parent
    55  			}
    56  			return ""
    57  		}
    58  		p = parent
    59  	}
    60  }
    61  
    62  // optionalSiteRoot takes a path to a site root or an empty string. If some
    63  // path is given, it normalizes it and ensures that it is indeed a site root
    64  // directory. If empty string is given, it discovers a site root for current
    65  // directory.
    66  func optionalSiteRoot(siteRoot string) (string, error) {
    67  	if siteRoot == "" {
    68  		cwd, err := os.Getwd()
    69  		if err != nil {
    70  			return "", errors.Annotate(err, "resolving current working directory").Tag(cipderr.IO).Err()
    71  		}
    72  		siteRoot = findSiteRoot(cwd)
    73  		if siteRoot == "" {
    74  			return "", errors.Reason("directory %s is not in a site root, use 'init' to create one", cwd).Tag(cipderr.BadArgument).Err()
    75  		}
    76  		return siteRoot, nil
    77  	}
    78  	siteRoot, err := filepath.Abs(siteRoot)
    79  	if err != nil {
    80  		return "", errors.Annotate(err, "bad site root path").Tag(cipderr.BadArgument).Err()
    81  	}
    82  	if !isSiteRoot(siteRoot) {
    83  		return "", errors.Reason("directory %s doesn't look like a site root, use 'init' to create one", siteRoot).Tag(cipderr.BadArgument).Err()
    84  	}
    85  	return siteRoot, nil
    86  }
    87  
    88  // isSiteRoot returns true if <p>/.cipd exists.
    89  func isSiteRoot(p string) bool {
    90  	fi, err := os.Stat(filepath.Join(p, fs.SiteServiceDir))
    91  	return err == nil && fi.IsDir()
    92  }
    93  
    94  ////////////////////////////////////////////////////////////////////////////////
    95  // Config file parsing.
    96  
    97  // installationSiteConfig is stored in .cipd/config.json.
    98  type installationSiteConfig struct {
    99  	// ServiceURL is https://<hostname> of a backend to use by default.
   100  	ServiceURL string `json:",omitempty"`
   101  	// DefaultVersion is what version to install if not specified.
   102  	DefaultVersion string `json:",omitempty"`
   103  	// TrackedVersions is mapping package name -> version to use in 'update'.
   104  	TrackedVersions map[string]string `json:",omitempty"`
   105  	// CacheDir contains shared cache.
   106  	CacheDir string `json:",omitempty"`
   107  }
   108  
   109  // read loads JSON from given path.
   110  func (c *installationSiteConfig) read(path string) error {
   111  	*c = installationSiteConfig{}
   112  	r, err := os.Open(path)
   113  	if err != nil {
   114  		return err
   115  	}
   116  	defer r.Close()
   117  	return json.NewDecoder(r).Decode(c)
   118  }
   119  
   120  // write dumps JSON to given path.
   121  func (c *installationSiteConfig) write(path string) error {
   122  	blob, err := json.MarshalIndent(c, "", "\t")
   123  	if err != nil {
   124  		return err
   125  	}
   126  	return os.WriteFile(path, blob, 0666)
   127  }
   128  
   129  // readConfig reads config, returning default one if missing.
   130  //
   131  // The returned config may have ServiceURL set to "" due to previous buggy
   132  // version of CIPD not setting it up correctly.
   133  func readConfig(siteRoot string) (installationSiteConfig, error) {
   134  	path := filepath.Join(siteRoot, fs.SiteServiceDir, "config.json")
   135  	c := installationSiteConfig{}
   136  	if err := c.read(path); err != nil && !os.IsNotExist(err) {
   137  		return c, errors.Annotate(err, "failed to read site root config").Tag(cipderr.IO).Err()
   138  	}
   139  	return c, nil
   140  }
   141  
   142  ////////////////////////////////////////////////////////////////////////////////
   143  // High level wrapper around site root.
   144  
   145  // installationSite represents a site root directory with config and optional
   146  // cipd.Client instance configured to install packages into that root.
   147  type installationSite struct {
   148  	siteRoot          string                  // path to a site root directory
   149  	defaultServiceURL string                  // set during construction
   150  	cfg               *installationSiteConfig // parsed .cipd/config.json file
   151  	client            cipd.Client             // initialized by initClient()
   152  }
   153  
   154  // getInstallationSite finds site root directory, reads config and constructs
   155  // installationSite object.
   156  //
   157  // If siteRoot is "", will find a site root based on the current directory,
   158  // otherwise will use siteRoot. Doesn't create any new files or directories,
   159  // just reads what's on disk.
   160  func getInstallationSite(siteRoot, defaultServiceURL string) (*installationSite, error) {
   161  	siteRoot, err := optionalSiteRoot(siteRoot)
   162  	if err != nil {
   163  		return nil, err
   164  	}
   165  	cfg, err := readConfig(siteRoot)
   166  	if err != nil {
   167  		return nil, err
   168  	}
   169  	if cfg.ServiceURL == "" {
   170  		cfg.ServiceURL = defaultServiceURL
   171  	}
   172  	return &installationSite{siteRoot, defaultServiceURL, &cfg, nil}, nil
   173  }
   174  
   175  // initInstallationSite creates new site root directory on disk.
   176  //
   177  // It does a bunch of sanity checks (like whether rootDir is empty) that are
   178  // skipped if 'force' is set to true.
   179  func initInstallationSite(rootDir, defaultServiceURL string, force bool) (*installationSite, error) {
   180  	rootDir, err := filepath.Abs(rootDir)
   181  	if err != nil {
   182  		return nil, errors.Annotate(err, "bad root path").Tag(cipderr.BadArgument).Err()
   183  	}
   184  
   185  	// rootDir is inside an existing site root?
   186  	existing := findSiteRoot(rootDir)
   187  	if existing != "" {
   188  		msg := fmt.Sprintf("directory %s is already inside a site root (%s)", rootDir, existing)
   189  		if !force {
   190  			return nil, errors.New(msg, cipderr.BadArgument)
   191  		}
   192  		fmt.Fprintf(os.Stderr, "Warning: %s.\n", msg)
   193  	}
   194  
   195  	// Attempting to use in a non empty directory?
   196  	entries, err := os.ReadDir(rootDir)
   197  	if err != nil && !os.IsNotExist(err) {
   198  		return nil, errors.Annotate(err, "bad site root dir").Tag(cipderr.IO).Err()
   199  	}
   200  	if len(entries) != 0 {
   201  		msg := fmt.Sprintf("directory %s is not empty", rootDir)
   202  		if !force {
   203  			return nil, errors.New(msg, cipderr.BadArgument)
   204  		}
   205  		fmt.Fprintf(os.Stderr, "Warning: %s.\n", msg)
   206  	}
   207  
   208  	// Good to go.
   209  	if err = os.MkdirAll(filepath.Join(rootDir, fs.SiteServiceDir), 0777); err != nil {
   210  		return nil, errors.Annotate(err, "creating site root dir").Tag(cipderr.IO).Err()
   211  	}
   212  	site, err := getInstallationSite(rootDir, defaultServiceURL)
   213  	if err != nil {
   214  		return nil, err
   215  	}
   216  	fmt.Printf("Site root initialized at %s.\n", rootDir)
   217  	return site, nil
   218  }
   219  
   220  // initClient initializes cipd.Client to use to talk to backend.
   221  //
   222  // Can be called only once. Use it directly via site.client.
   223  func (site *installationSite) initClient(ctx context.Context, authFlags authcli.Flags) (err error) {
   224  	if site.client != nil {
   225  		return errors.New("client is already initialized", cipderr.BadArgument)
   226  	}
   227  	clientOpts := clientOptions{
   228  		authFlags:  authFlags,
   229  		serviceURL: site.cfg.ServiceURL,
   230  		cacheDir:   site.cfg.CacheDir,
   231  		rootDir:    site.siteRoot,
   232  	}
   233  	site.client, err = clientOpts.makeCIPDClient(ctx)
   234  	return err
   235  }
   236  
   237  // modifyConfig reads config file, calls callback to mutate it, then writes
   238  // it back.
   239  func (site *installationSite) modifyConfig(cb func(cfg *installationSiteConfig) error) error {
   240  	path := filepath.Join(site.siteRoot, fs.SiteServiceDir, "config.json")
   241  	c := installationSiteConfig{}
   242  	if err := c.read(path); err != nil && !os.IsNotExist(err) {
   243  		return errors.Annotate(err, "reading site root config").Tag(cipderr.IO).Err()
   244  	}
   245  	if err := cb(&c); err != nil {
   246  		return err
   247  	}
   248  	// Fix broken config that doesn't have ServiceURL set. It is required now.
   249  	if c.ServiceURL == "" {
   250  		c.ServiceURL = site.defaultServiceURL
   251  	}
   252  	if err := c.write(path); err != nil {
   253  		return errors.Annotate(err, "writing site root config").Tag(cipderr.IO).Err()
   254  	}
   255  	return nil
   256  }
   257  
   258  // installedPackages discovers versions of packages installed in the site.
   259  //
   260  // If pkgs is empty array, it returns list of all installed packages.
   261  func (site *installationSite) installedPackages(ctx context.Context) (map[string][]pinInfo, error) {
   262  	d := deployer.New(site.siteRoot)
   263  
   264  	allPins, err := d.FindDeployed(ctx)
   265  	if err != nil {
   266  		return nil, err
   267  	}
   268  	output := make(map[string][]pinInfo, len(allPins))
   269  	for subdir, pins := range allPins {
   270  		output[subdir] = make([]pinInfo, len(pins))
   271  		for i, pin := range pins {
   272  			cpy := pin
   273  			output[subdir][i] = pinInfo{
   274  				Pkg:      pin.PackageName,
   275  				Pin:      &cpy,
   276  				Tracking: site.cfg.TrackedVersions[pin.PackageName],
   277  			}
   278  		}
   279  	}
   280  	return output, nil
   281  }
   282  
   283  // installPackage installs (or updates) a package.
   284  func (site *installationSite) installPackage(ctx context.Context, pkgName, version string, paranoid cipd.ParanoidMode) (*pinInfo, error) {
   285  	if site.client == nil {
   286  		return nil, errors.New("client is not initialized", cipderr.BadArgument)
   287  	}
   288  
   289  	// Figure out what exactly (what instance ID) to install.
   290  	if version == "" {
   291  		version = site.cfg.DefaultVersion
   292  	}
   293  	if version == "" {
   294  		version = "latest"
   295  	}
   296  	resolved, err := site.client.ResolveVersion(ctx, pkgName, version)
   297  	if err != nil {
   298  		return nil, err
   299  	}
   300  
   301  	// Install it by constructing an ensure file with all already installed
   302  	// packages plus the one we are installing (into the root "" subdir).
   303  	deployed, err := site.client.FindDeployed(ctx)
   304  	if err != nil {
   305  		return nil, err
   306  	}
   307  
   308  	found := false
   309  	root := deployed[""]
   310  	for idx := range root {
   311  		if root[idx].PackageName == resolved.PackageName {
   312  			root[idx] = resolved // upgrading the existing package
   313  			found = true
   314  		}
   315  	}
   316  	if !found {
   317  		if deployed == nil {
   318  			deployed = common.PinSliceBySubdir{}
   319  		}
   320  		deployed[""] = append(deployed[""], resolved) // install a new one
   321  	}
   322  
   323  	actions, err := site.client.EnsurePackages(ctx, deployed, &cipd.EnsureOptions{
   324  		Paranoia: paranoid,
   325  	})
   326  	if err != nil {
   327  		return nil, err
   328  	}
   329  
   330  	if actions.Empty() {
   331  		fmt.Printf("Package %s is up-to-date.\n", pkgName)
   332  	}
   333  
   334  	// Update config saying what version to track. Remove tracking if an exact
   335  	// instance ID was requested.
   336  	trackedVersion := ""
   337  	if version != resolved.InstanceID {
   338  		trackedVersion = version
   339  	}
   340  	err = site.modifyConfig(func(cfg *installationSiteConfig) error {
   341  		if cfg.TrackedVersions == nil {
   342  			cfg.TrackedVersions = map[string]string{}
   343  		}
   344  		if cfg.TrackedVersions[pkgName] != trackedVersion {
   345  			if trackedVersion == "" {
   346  				fmt.Printf("Package %s is now pinned to %q.\n", pkgName, resolved.InstanceID)
   347  			} else {
   348  				fmt.Printf("Package %s is now tracking %q.\n", pkgName, trackedVersion)
   349  			}
   350  		}
   351  		if trackedVersion == "" {
   352  			delete(cfg.TrackedVersions, pkgName)
   353  		} else {
   354  			cfg.TrackedVersions[pkgName] = trackedVersion
   355  		}
   356  		return nil
   357  	})
   358  	if err != nil {
   359  		return nil, err
   360  	}
   361  
   362  	// Success.
   363  	return &pinInfo{
   364  		Pkg:      pkgName,
   365  		Pin:      &resolved,
   366  		Tracking: trackedVersion,
   367  	}, nil
   368  }
   369  
   370  ////////////////////////////////////////////////////////////////////////////////
   371  // Common command line flags.
   372  
   373  // siteRootOptions defines command line flag for specifying existing site root
   374  // directory. 'init' subcommand is NOT using it, since it creates a new site
   375  // root, not reusing an existing one.
   376  type siteRootOptions struct {
   377  	rootDir string
   378  }
   379  
   380  func (opts *siteRootOptions) registerFlags(f *flag.FlagSet) {
   381  	f.StringVar(
   382  		&opts.rootDir, "root", "", "Path to an installation site root directory. "+
   383  			"If omitted will try to discover it by examining parent directories.")
   384  }
   385  
   386  ////////////////////////////////////////////////////////////////////////////////
   387  // 'init' subcommand.
   388  
   389  func cmdInit(params Parameters) *subcommands.Command {
   390  	return &subcommands.Command{
   391  		Advanced:  true,
   392  		UsageLine: "init [root dir] [options]",
   393  		ShortDesc: "sets up a new site root directory to install packages into",
   394  		LongDesc: "Sets up a new site root directory to install packages into.\n\n" +
   395  			"Uses current working directory by default.\n" +
   396  			"Unless -force is given, the new site root directory should be empty (or " +
   397  			"do not exist at all) and not be under some other existing site root. " +
   398  			"The command will create <root>/.cipd subdirectory with some " +
   399  			"configuration files. This directory is used by CIPD client to keep " +
   400  			"track of what is installed in the site root.",
   401  		CommandRun: func() subcommands.CommandRun {
   402  			c := &initRun{}
   403  			c.registerBaseFlags()
   404  			c.Flags.BoolVar(&c.force, "force", false, "Create the site root even if the directory is not empty or already under another site root directory.")
   405  			c.Flags.StringVar(&c.serviceURL, "service-url", params.ServiceURL, "Backend URL. Will be put into the site config and used for subsequent 'install' commands.")
   406  			c.Flags.StringVar(&c.cacheDir, "cache-dir", "", "Directory for shared cache")
   407  			return c
   408  		},
   409  	}
   410  }
   411  
   412  type initRun struct {
   413  	cipdSubcommand
   414  
   415  	force      bool
   416  	serviceURL string
   417  	cacheDir   string
   418  }
   419  
   420  func (c *initRun) Run(a subcommands.Application, args []string, _ subcommands.Env) int {
   421  	if !c.checkArgs(args, 0, 1) {
   422  		return 1
   423  	}
   424  	rootDir := "."
   425  	if len(args) == 1 {
   426  		rootDir = args[0]
   427  	}
   428  	site, err := initInstallationSite(rootDir, c.serviceURL, c.force)
   429  	if err != nil {
   430  		return c.done(nil, err)
   431  	}
   432  	err = site.modifyConfig(func(cfg *installationSiteConfig) error {
   433  		cfg.ServiceURL = c.serviceURL
   434  		cfg.CacheDir = c.cacheDir
   435  		return nil
   436  	})
   437  	return c.done(site.siteRoot, err)
   438  }
   439  
   440  ////////////////////////////////////////////////////////////////////////////////
   441  // 'install' subcommand.
   442  
   443  func cmdInstall(params Parameters) *subcommands.Command {
   444  	return &subcommands.Command{
   445  		Advanced:  true,
   446  		UsageLine: "install <package> [<version>] [options]",
   447  		ShortDesc: "installs or updates a package",
   448  		LongDesc:  "Installs or updates a package.",
   449  		CommandRun: func() subcommands.CommandRun {
   450  			c := &installRun{defaultServiceURL: params.ServiceURL}
   451  			c.registerBaseFlags()
   452  			c.authFlags.Register(&c.Flags, params.DefaultAuthOptions)
   453  			c.siteRootOptions.registerFlags(&c.Flags)
   454  			c.Flags.BoolVar(&c.force, "force", false, "Check all package files and present and reinstall them if missing.")
   455  			return c
   456  		},
   457  	}
   458  }
   459  
   460  type installRun struct {
   461  	cipdSubcommand
   462  	authFlags authcli.Flags
   463  	siteRootOptions
   464  
   465  	defaultServiceURL string // used only if the site config has ServiceURL == ""
   466  	force             bool   // if true use CheckPresence paranoid mode
   467  }
   468  
   469  func (c *installRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
   470  	if !c.checkArgs(args, 1, 2) {
   471  		return 1
   472  	}
   473  
   474  	// Pkg and version to install.
   475  	pkgName, err := expandTemplate(args[0])
   476  	if err != nil {
   477  		return c.done(nil, err)
   478  	}
   479  
   480  	version := ""
   481  	if len(args) == 2 {
   482  		version = args[1]
   483  	}
   484  
   485  	paranoid := cipd.NotParanoid
   486  	if c.force {
   487  		paranoid = cipd.CheckPresence
   488  	}
   489  
   490  	// Auto initialize site root directory if necessary. Don't be too aggressive
   491  	// about it though (do not use force=true). Will do anything only if
   492  	// c.rootDir points to an empty directory.
   493  	var site *installationSite
   494  	rootDir, err := optionalSiteRoot(c.rootDir)
   495  	if err == nil {
   496  		site, err = getInstallationSite(rootDir, c.defaultServiceURL)
   497  	} else {
   498  		site, err = initInstallationSite(c.rootDir, c.defaultServiceURL, false)
   499  		if err != nil {
   500  			err = errors.Annotate(err, "can't auto initialize cipd site root, use 'init'").Err()
   501  		}
   502  	}
   503  	if err != nil {
   504  		return c.done(nil, err)
   505  	}
   506  
   507  	ctx := cli.GetContext(a, c, env)
   508  	if err = site.initClient(ctx, c.authFlags); err != nil {
   509  		return c.done(nil, err)
   510  	}
   511  	defer site.client.Close(ctx)
   512  	site.client.BeginBatch(ctx)
   513  	defer site.client.EndBatch(ctx)
   514  	return c.done(site.installPackage(ctx, pkgName, version, paranoid))
   515  }
   516  
   517  ////////////////////////////////////////////////////////////////////////////////
   518  // 'installed' subcommand.
   519  
   520  func cmdInstalled(params Parameters) *subcommands.Command {
   521  	return &subcommands.Command{
   522  		Advanced:  true,
   523  		UsageLine: "installed [options]",
   524  		ShortDesc: "lists packages installed in the site root",
   525  		LongDesc:  "Lists packages installed in the site root.",
   526  		CommandRun: func() subcommands.CommandRun {
   527  			c := &installedRun{defaultServiceURL: params.ServiceURL}
   528  			c.registerBaseFlags()
   529  			c.siteRootOptions.registerFlags(&c.Flags)
   530  			return c
   531  		},
   532  	}
   533  }
   534  
   535  type installedRun struct {
   536  	cipdSubcommand
   537  	siteRootOptions
   538  
   539  	defaultServiceURL string // used only if the site config has ServiceURL == ""
   540  }
   541  
   542  func (c *installedRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
   543  	if !c.checkArgs(args, 0, 0) {
   544  		return 1
   545  	}
   546  	site, err := getInstallationSite(c.rootDir, c.defaultServiceURL)
   547  	if err != nil {
   548  		return c.done(nil, err)
   549  	}
   550  	ctx := cli.GetContext(a, c, env)
   551  	return c.doneWithPinMap(site.installedPackages(ctx))
   552  }