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 }