github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/commands/compute/deploy.go (about) 1 package compute 2 3 import ( 4 "errors" 5 "fmt" 6 "io" 7 "io/fs" 8 "net/http" 9 "os" 10 "os/signal" 11 "path/filepath" 12 "strconv" 13 "strings" 14 "syscall" 15 "time" 16 17 "github.com/fastly/go-fastly/v9/fastly" 18 "github.com/kennygrant/sanitize" 19 "github.com/mholt/archiver/v3" 20 21 "github.com/fastly/cli/pkg/api" 22 "github.com/fastly/cli/pkg/api/undocumented" 23 "github.com/fastly/cli/pkg/argparser" 24 "github.com/fastly/cli/pkg/commands/compute/setup" 25 fsterr "github.com/fastly/cli/pkg/errors" 26 "github.com/fastly/cli/pkg/global" 27 "github.com/fastly/cli/pkg/lookup" 28 "github.com/fastly/cli/pkg/manifest" 29 "github.com/fastly/cli/pkg/text" 30 "github.com/fastly/cli/pkg/undo" 31 ) 32 33 const ( 34 manageServiceBaseURL = "https://manage.fastly.com/configure/services/" 35 trialNotActivated = "Valid values for 'type' are: 'vcl'" 36 ) 37 38 // ErrPackageUnchanged is an error that indicates the package hasn't changed. 39 var ErrPackageUnchanged = errors.New("package is unchanged") 40 41 // DeployCommand deploys an artifact previously produced by build. 42 type DeployCommand struct { 43 argparser.Base 44 manifestPath string 45 46 // NOTE: these are public so that the "publish" composite command can set the 47 // values appropriately before calling the Exec() function. 48 Comment argparser.OptionalString 49 Dir string 50 Domain string 51 Env string 52 PackagePath string 53 ServiceName argparser.OptionalServiceNameID 54 ServiceVersion argparser.OptionalServiceVersion 55 StatusCheckCode int 56 StatusCheckOff bool 57 StatusCheckPath string 58 StatusCheckTimeout int 59 } 60 61 // NewDeployCommand returns a usable command registered under the parent. 62 func NewDeployCommand(parent argparser.Registerer, g *global.Data) *DeployCommand { 63 var c DeployCommand 64 c.Globals = g 65 c.CmdClause = parent.Command("deploy", "Deploy a package to a Fastly Compute service") 66 67 // NOTE: when updating these flags, be sure to update the composite command: 68 // `compute publish`. 69 c.RegisterFlag(argparser.StringFlagOpts{ 70 Name: argparser.FlagServiceIDName, 71 Description: argparser.FlagServiceIDDesc, 72 Dst: &c.Globals.Manifest.Flag.ServiceID, 73 Short: 's', 74 }) 75 c.RegisterFlag(argparser.StringFlagOpts{ 76 Action: c.ServiceName.Set, 77 Name: argparser.FlagServiceName, 78 Description: argparser.FlagServiceDesc, 79 Dst: &c.ServiceName.Value, 80 }) 81 c.RegisterFlag(argparser.StringFlagOpts{ 82 Action: c.ServiceVersion.Set, 83 Description: argparser.FlagVersionDesc, 84 Dst: &c.ServiceVersion.Value, 85 Name: argparser.FlagVersionName, 86 }) 87 c.CmdClause.Flag("comment", "Human-readable comment").Action(c.Comment.Set).StringVar(&c.Comment.Value) 88 c.CmdClause.Flag("dir", "Project directory (default: current directory)").Short('C').StringVar(&c.Dir) 89 c.CmdClause.Flag("domain", "The name of the domain associated to the package").StringVar(&c.Domain) 90 c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").StringVar(&c.Env) 91 c.CmdClause.Flag("package", "Path to a package tar.gz").Short('p').StringVar(&c.PackagePath) 92 c.CmdClause.Flag("status-check-code", "Set the expected status response for the service availability check").IntVar(&c.StatusCheckCode) 93 c.CmdClause.Flag("status-check-off", "Disable the service availability check").BoolVar(&c.StatusCheckOff) 94 c.CmdClause.Flag("status-check-path", "Specify the URL path for the service availability check").Default("/").StringVar(&c.StatusCheckPath) 95 c.CmdClause.Flag("status-check-timeout", "Set a timeout (in seconds) for the service availability check").Default("120").IntVar(&c.StatusCheckTimeout) 96 return &c 97 } 98 99 // Exec implements the command interface. 100 func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) { 101 manifestFilename := EnvironmentManifest(c.Env) 102 if c.Env != "" { 103 if c.Globals.Verbose() { 104 text.Info(out, EnvManifestMsg, manifestFilename, manifest.Filename) 105 } 106 } 107 wd, err := os.Getwd() 108 if err != nil { 109 return fmt.Errorf("failed to get current working directory: %w", err) 110 } 111 defer func() { 112 _ = os.Chdir(wd) 113 }() 114 c.manifestPath = filepath.Join(wd, manifestFilename) 115 116 projectDir, err := ChangeProjectDirectory(c.Dir) 117 if err != nil { 118 return err 119 } 120 if projectDir != "" { 121 if c.Globals.Verbose() { 122 text.Info(out, ProjectDirMsg, projectDir) 123 } 124 c.manifestPath = filepath.Join(projectDir, manifestFilename) 125 } 126 127 spinner, err := text.NewSpinner(out) 128 if err != nil { 129 return err 130 } 131 132 err = spinner.Process(fmt.Sprintf("Verifying %s", manifestFilename), func(_ *text.SpinnerWrapper) error { 133 if projectDir != "" || c.Env != "" { 134 err = c.Globals.Manifest.File.Read(c.manifestPath) 135 } else { 136 err = c.Globals.Manifest.File.ReadError() 137 } 138 if err != nil { 139 // If the user hasn't specified a package to deploy, then we'll just check 140 // the read error and return it. 141 if c.PackagePath == "" { 142 if errors.Is(err, os.ErrNotExist) { 143 err = fsterr.ErrReadingManifest 144 } 145 c.Globals.ErrLog.Add(err) 146 return err 147 } 148 // Otherwise, we'll attempt to read the manifest from within the given 149 // package archive. 150 if err := readManifestFromPackageArchive(c.Globals.Manifest, c.PackagePath, manifestFilename); err != nil { 151 return err 152 } 153 if c.Globals.Verbose() { 154 text.Info(out, "Using %s within --package archive: %s\n\n", manifestFilename, c.PackagePath) 155 } 156 } 157 return nil 158 }) 159 if err != nil { 160 return err 161 } 162 if !c.Globals.Flags.NonInteractive { 163 text.Break(out) 164 } 165 166 fnActivateTrial, serviceID, err := c.Setup(out) 167 if err != nil { 168 return err 169 } 170 noExistingService := serviceID == "" 171 172 undoStack := undo.NewStack() 173 undoStack.Push(func() error { 174 if noExistingService && serviceID != "" { 175 return c.CleanupNewService(serviceID, manifestFilename, out) 176 } 177 return nil 178 }) 179 180 defer func(errLog fsterr.LogInterface) { 181 if err != nil { 182 errLog.Add(err) 183 } 184 undoStack.RunIfError(out, err) 185 }(c.Globals.ErrLog) 186 187 signalCh := make(chan os.Signal, 1) 188 signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) 189 go monitorSignals(signalCh, noExistingService, out, undoStack, spinner) 190 191 var serviceVersion *fastly.Version 192 if noExistingService { 193 serviceID, serviceVersion, err = c.NewService(manifestFilename, fnActivateTrial, spinner, in, out) 194 if err != nil { 195 return err 196 } 197 if serviceID == "" { 198 return nil // user declined service creation prompt 199 } 200 } else { 201 // ErrPackageUnchanged is returned AFTER identifying the service version. 202 // nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable 203 serviceVersion, err = c.ExistingServiceVersion(serviceID, out) 204 if err != nil { 205 if errors.Is(err, ErrPackageUnchanged) { 206 text.Info(out, "Skipping package deployment, local and service version are identical. (service %s, version %d) ", serviceID, serviceVersion.Number) 207 return nil 208 } 209 return err 210 } 211 if c.Globals.Manifest.File.Setup.Defined() && !c.Globals.Flags.Quiet { 212 text.Info(out, "\nProcessing of the %s [setup] configuration happens only for a new service. Once a service is created, any further changes to the service or its resources must be made manually.\n\n", manifestFilename) 213 } 214 } 215 216 var sr ServiceResources 217 218 // NOTE: A 'domain' resource isn't strictly part of the [setup] config. 219 // It's part of the implementation so that we can utilise the same interface. 220 // A domain is required regardless of whether it's a new service or existing. 221 sr.domains = &setup.Domains{ 222 APIClient: c.Globals.APIClient, 223 AcceptDefaults: c.Globals.Flags.AcceptDefaults, 224 NonInteractive: c.Globals.Flags.NonInteractive, 225 PackageDomain: c.Domain, 226 RetryLimit: 5, 227 ServiceID: serviceID, 228 ServiceVersion: fastly.ToValue(serviceVersion.Number), 229 Stdin: in, 230 Stdout: out, 231 Verbose: c.Globals.Verbose(), 232 } 233 serviceVersionNumber := fastly.ToValue(serviceVersion.Number) 234 if err = sr.domains.Validate(); err != nil { 235 errLogService(c.Globals.ErrLog, err, serviceID, serviceVersionNumber) 236 return fmt.Errorf("error configuring service domains: %w", err) 237 } 238 if noExistingService { 239 c.ConstructNewServiceResources( 240 &sr, serviceID, serviceVersionNumber, in, out, 241 ) 242 } 243 244 if sr.domains.Missing() { 245 if err := sr.domains.Configure(); err != nil { 246 errLogService(c.Globals.ErrLog, err, serviceID, serviceVersionNumber) 247 return fmt.Errorf("error configuring service domains: %w", err) 248 } 249 } 250 if noExistingService { 251 if err = c.ConfigureServiceResources(sr, serviceID, serviceVersionNumber); err != nil { 252 return err 253 } 254 } 255 256 if sr.domains.Missing() { 257 sr.domains.Spinner = spinner 258 if err := sr.domains.Create(); err != nil { 259 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 260 "Accept defaults": c.Globals.Flags.AcceptDefaults, 261 "Auto-yes": c.Globals.Flags.AutoYes, 262 "Non-interactive": c.Globals.Flags.NonInteractive, 263 "Service ID": serviceID, 264 "Service Version": serviceVersion, 265 }) 266 return err 267 } 268 } 269 if noExistingService { 270 if err = c.CreateServiceResources(sr, spinner, serviceID, serviceVersionNumber); err != nil { 271 return err 272 } 273 } 274 275 err = c.UploadPackage(spinner, serviceID, serviceVersionNumber) 276 if err != nil { 277 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 278 "Package path": c.PackagePath, 279 "Service ID": serviceID, 280 "Service Version": serviceVersion, 281 }) 282 return err 283 } 284 285 if err = c.ProcessService(serviceID, serviceVersionNumber, spinner); err != nil { 286 return err 287 } 288 289 serviceURL, err := c.GetServiceURL(serviceID, serviceVersionNumber) 290 if err != nil { 291 return err 292 } 293 294 if !c.StatusCheckOff && noExistingService { 295 c.StatusCheck(serviceURL, spinner, out) 296 } 297 298 if !noExistingService { 299 text.Break(out) 300 } 301 displayDeployOutput(out, manageServiceBaseURL, serviceID, serviceURL, serviceVersionNumber) 302 return nil 303 } 304 305 // StatusCheck checks the service URL and identifies when it's ready. 306 func (c *DeployCommand) StatusCheck(serviceURL string, spinner text.Spinner, out io.Writer) { 307 var ( 308 err error 309 status int 310 ) 311 if status, err = checkingServiceAvailability(serviceURL+c.StatusCheckPath, spinner, c); err != nil { 312 if re, ok := err.(fsterr.RemediationError); ok { 313 text.Warning(out, re.Remediation) 314 } 315 } 316 317 // Because the service availability can return an error (which we ignore), 318 // then we need to check for the 'no error' scenarios. 319 if err == nil { 320 switch { 321 case validStatusCodeRange(c.StatusCheckCode) && status != c.StatusCheckCode: 322 // If the user set a specific status code expectation... 323 text.Warning(out, "The service path `%s` responded with a status code (%d) that didn't match what was expected (%d).", c.StatusCheckPath, status, c.StatusCheckCode) 324 case !validStatusCodeRange(c.StatusCheckCode) && status >= http.StatusBadRequest: 325 // If no status code was specified, and the actual status response was an error... 326 text.Info(out, "The service path `%s` responded with a non-successful status code (%d). Please check your application code if this is an unexpected response.", c.StatusCheckPath, status) 327 default: 328 text.Break(out) 329 } 330 } 331 } 332 333 func displayDeployOutput(out io.Writer, manageServiceBaseURL, serviceID, serviceURL string, serviceVersion int) { 334 text.Description(out, "Manage this service at", fmt.Sprintf("%s%s", manageServiceBaseURL, serviceID)) 335 text.Description(out, "View this service at", serviceURL) 336 text.Success(out, "Deployed package (service %s, version %v)", serviceID, serviceVersion) 337 } 338 339 // validStatusCodeRange checks the status is a valid status code. 340 // e.g. >= 100 and <= 999. 341 func validStatusCodeRange(status int) bool { 342 if status >= 100 && status <= 999 { 343 return true 344 } 345 return false 346 } 347 348 // Setup prepares the environment. 349 // 350 // - Check if there is an API token missing. 351 // - Acquire the Service ID/Version. 352 // - Validate there is a package to deploy. 353 // - Determine if a trial needs to be activated on the user's account. 354 func (c *DeployCommand) Setup(out io.Writer) (fnActivateTrial Activator, serviceID string, err error) { 355 defaultActivator := func(_ string) error { return nil } 356 357 token, s := c.Globals.Token() 358 if s == lookup.SourceUndefined { 359 return defaultActivator, "", fsterr.ErrNoToken 360 } 361 362 // IMPORTANT: We don't handle the error when looking up the Service ID. 363 // This is because later in the Exec() flow we might create a 'new' service. 364 serviceID, source, flag, err := argparser.ServiceID(c.ServiceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) 365 if err == nil && c.Globals.Verbose() { 366 argparser.DisplayServiceID(serviceID, flag, source, out) 367 } 368 369 if c.PackagePath == "" { 370 projectName, source := c.Globals.Manifest.Name() 371 if source == manifest.SourceUndefined { 372 return defaultActivator, serviceID, fsterr.ErrReadingManifest 373 } 374 c.PackagePath = filepath.Join("pkg", fmt.Sprintf("%s.tar.gz", sanitize.BaseName(projectName))) 375 } 376 377 err = validatePackage(c.PackagePath) 378 if err != nil { 379 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 380 "Package path": c.PackagePath, 381 }) 382 return defaultActivator, serviceID, err 383 } 384 385 endpoint, _ := c.Globals.APIEndpoint() 386 fnActivateTrial = preconfigureActivateTrial(endpoint, token, c.Globals.HTTPClient, c.Globals.Env.DebugMode) 387 388 return fnActivateTrial, serviceID, err 389 } 390 391 // validatePackage checks the package and returns its path, which can change 392 // depending on the user flow scenario. 393 func validatePackage(pkgPath string) error { 394 pkgSize, err := packageSize(pkgPath) 395 if err != nil { 396 return fsterr.RemediationError{ 397 Inner: fmt.Errorf("error reading package size: %w", err), 398 Remediation: "Run `fastly compute build` to produce a Compute package, alternatively use the --package flag to reference a package outside of the current project.", 399 } 400 } 401 if pkgSize > MaxPackageSize { 402 return fsterr.RemediationError{ 403 Inner: fmt.Errorf("package size is too large (%d bytes)", pkgSize), 404 Remediation: fsterr.PackageSizeRemediation, 405 } 406 } 407 return validatePackageContent(pkgPath) 408 } 409 410 // readManifestFromPackageArchive extracts the manifest file from the given 411 // package archive file and reads it into memory. 412 func readManifestFromPackageArchive(data *manifest.Data, packageFlag, manifestFilename string) error { 413 dst, err := os.MkdirTemp("", fmt.Sprintf("%s-*", manifestFilename)) 414 if err != nil { 415 return err 416 } 417 defer os.RemoveAll(dst) 418 419 if err = archiver.Unarchive(packageFlag, dst); err != nil { 420 return fmt.Errorf("error extracting package '%s': %w", packageFlag, err) 421 } 422 423 files, err := os.ReadDir(dst) 424 if err != nil { 425 return err 426 } 427 extractedDirName := files[0].Name() 428 429 manifestPath, err := locateManifest(filepath.Join(dst, extractedDirName), manifestFilename) 430 if err != nil { 431 return err 432 } 433 434 err = data.File.Read(manifestPath) 435 if err != nil { 436 if errors.Is(err, os.ErrNotExist) { 437 err = fsterr.ErrReadingManifest 438 } 439 return err 440 } 441 442 return nil 443 } 444 445 // locateManifest attempts to find the manifest within the given path's 446 // directory tree. 447 func locateManifest(path, manifestFilename string) (string, error) { 448 root, err := filepath.Abs(path) 449 if err != nil { 450 return "", err 451 } 452 453 var foundManifest string 454 455 err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { 456 if err != nil { 457 return err 458 } 459 if !entry.IsDir() && filepath.Base(path) == manifestFilename { 460 foundManifest = path 461 return fsterr.ErrStopWalk 462 } 463 return nil 464 }) 465 if err != nil { 466 // If the error isn't ErrStopWalk, then the WalkDir() function had an 467 // issue processing the directory tree. 468 if err != fsterr.ErrStopWalk { 469 return "", err 470 } 471 472 return foundManifest, nil 473 } 474 475 return "", fmt.Errorf("error locating manifest within the given path: %s", path) 476 } 477 478 // packageSize returns the size of the .tar.gz package. 479 // 480 // Reference: 481 // https://docs.fastly.com/products/compute-at-edge-billing-and-resource-limits#resource-limits 482 func packageSize(path string) (size int64, err error) { 483 fi, err := os.Stat(path) 484 if err != nil { 485 return size, err 486 } 487 return fi.Size(), nil 488 } 489 490 // Activator represents a function that calls an undocumented API endpoint for 491 // activating a Compute free trial on the given customer account. 492 // 493 // It is preconfigured with the Fastly API endpoint, a user token and a simple 494 // HTTP Client. 495 // 496 // This design allows us to pass an Activator rather than passing multiple 497 // unrelated arguments through several nested functions. 498 type Activator func(customerID string) error 499 500 // preconfigureActivateTrial activates a free trial on the customer account. 501 func preconfigureActivateTrial(endpoint, token string, httpClient api.HTTPClient, debugMode string) Activator { 502 debug, _ := strconv.ParseBool(debugMode) 503 return func(customerID string) error { 504 _, err := undocumented.Call(undocumented.CallOptions{ 505 APIEndpoint: endpoint, 506 HTTPClient: httpClient, 507 Method: http.MethodPost, 508 Path: fmt.Sprintf(undocumented.EdgeComputeTrial, customerID), 509 Token: token, 510 Debug: debug, 511 }) 512 if err != nil { 513 apiErr, ok := err.(undocumented.APIError) 514 if !ok { 515 return err 516 } 517 // 409 Conflict == The Compute trial has already been created. 518 if apiErr.StatusCode != http.StatusConflict { 519 return fmt.Errorf("%w: %d %s", err, apiErr.StatusCode, http.StatusText(apiErr.StatusCode)) 520 } 521 } 522 return nil 523 } 524 } 525 526 // NewService handles creating a new service when no Service ID is found. 527 func (c *DeployCommand) NewService(manifestFilename string, fnActivateTrial Activator, spinner text.Spinner, in io.Reader, out io.Writer) (string, *fastly.Version, error) { 528 var ( 529 err error 530 serviceID string 531 serviceVersion *fastly.Version 532 ) 533 534 if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { 535 text.Output(out, "There is no Fastly service associated with this package. To connect to an existing service add the Service ID to the %s file, otherwise follow the prompts to create a service now.\n\n", manifestFilename) 536 text.Output(out, "Press ^C at any time to quit.") 537 538 if c.Globals.Manifest.File.Setup.Defined() { 539 text.Info(out, "\nProcessing of the %s [setup] configuration happens only when there is no existing service. Once a service is created, any further changes to the service or its resources must be made manually.", manifestFilename) 540 } 541 542 text.Break(out) 543 answer, err := text.AskYesNo(out, "Create new service: [y/N] ", in) 544 if err != nil { 545 return serviceID, serviceVersion, err 546 } 547 if !answer { 548 return serviceID, serviceVersion, nil 549 } 550 text.Break(out) 551 } 552 553 defaultServiceName := c.Globals.Manifest.File.Name 554 var serviceName string 555 556 // The service name will be whatever is set in the --service-name flag. 557 // If the flag isn't set, and we're non-interactive, we'll use the default. 558 // If the flag isn't set, and we're interactive, we'll prompt the user. 559 switch { 560 case c.ServiceName.WasSet: 561 serviceName = c.ServiceName.Value 562 case c.Globals.Flags.AcceptDefaults || c.Globals.Flags.NonInteractive: 563 serviceName = defaultServiceName 564 default: 565 serviceName, err = text.Input(out, text.Prompt(fmt.Sprintf("Service name: [%s] ", defaultServiceName)), in) 566 if err != nil || serviceName == "" { 567 serviceName = defaultServiceName 568 } 569 } 570 571 // There is no service and so we'll do a one time creation of the service 572 // 573 // NOTE: we're shadowing the `serviceID` and `serviceVersion` variables. 574 serviceID, serviceVersion, err = createService(c.Globals, serviceName, fnActivateTrial, spinner, out) 575 if err != nil { 576 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 577 "Service name": serviceName, 578 }) 579 return serviceID, serviceVersion, err 580 } 581 582 err = c.UpdateManifestServiceID(serviceID, c.manifestPath) 583 584 // NOTE: Skip error if --package flag is set. 585 // 586 // This is because the use of the --package flag suggests the user is not 587 // within a project directory. If that is the case, then we don't want the 588 // error to be returned because of course there is no manifest to update. 589 // 590 // If the user does happen to be in a project directory and they use the 591 // --package flag, then the above function call to update the manifest will 592 // have succeeded and so there will be no error. 593 if err != nil && c.PackagePath == "" { 594 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 595 "Service ID": serviceID, 596 }) 597 return serviceID, serviceVersion, err 598 } 599 600 return serviceID, serviceVersion, nil 601 } 602 603 // createService creates a service to associate with the compute package. 604 // 605 // NOTE: If the creation of the service fails because the user has not 606 // activated a free trial, then we'll trigger the trial for their account. 607 func createService( 608 g *global.Data, 609 serviceName string, 610 fnActivateTrial Activator, 611 spinner text.Spinner, 612 out io.Writer, 613 ) (serviceID string, serviceVersion *fastly.Version, err error) { 614 f := g.Flags 615 apiClient := g.APIClient 616 errLog := g.ErrLog 617 618 if !f.AcceptDefaults && !f.NonInteractive { 619 text.Break(out) 620 } 621 622 err = spinner.Start() 623 if err != nil { 624 return "", nil, err 625 } 626 msg := "Creating service" 627 spinner.Message(msg + "...") 628 629 service, err := apiClient.CreateService(&fastly.CreateServiceInput{ 630 Name: &serviceName, 631 Type: fastly.ToPointer("wasm"), 632 }) 633 if err != nil { 634 if strings.Contains(err.Error(), trialNotActivated) { 635 user, err := apiClient.GetCurrentUser() 636 if err != nil { 637 err = fmt.Errorf("unable to identify user associated with the given token: %w", err) 638 spinner.StopFailMessage(msg) 639 spinErr := spinner.StopFail() 640 if spinErr != nil { 641 return "", nil, fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) 642 } 643 return serviceID, serviceVersion, fsterr.RemediationError{ 644 Inner: err, 645 Remediation: "To ensure you have access to the Compute platform we need your Customer ID. " + fsterr.AuthRemediation, 646 } 647 } 648 649 customerID := fastly.ToValue(user.CustomerID) 650 err = fnActivateTrial(customerID) 651 if err != nil { 652 err = fmt.Errorf("error creating service: you do not have the Compute free trial enabled on your Fastly account") 653 spinner.StopFailMessage(msg) 654 spinErr := spinner.StopFail() 655 if spinErr != nil { 656 return "", nil, fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) 657 } 658 return serviceID, serviceVersion, fsterr.RemediationError{ 659 Inner: err, 660 Remediation: fsterr.ComputeTrialRemediation, 661 } 662 } 663 664 errLog.AddWithContext(err, map[string]any{ 665 "Service Name": serviceName, 666 "Customer ID": customerID, 667 }) 668 669 spinner.StopFailMessage(msg) 670 err = spinner.StopFail() 671 if err != nil { 672 return "", nil, err 673 } 674 675 return createService(g, serviceName, fnActivateTrial, spinner, out) 676 } 677 678 spinner.StopFailMessage(msg) 679 spinErr := spinner.StopFail() 680 if spinErr != nil { 681 return "", nil, spinErr 682 } 683 684 errLog.AddWithContext(err, map[string]any{ 685 "Service Name": serviceName, 686 }) 687 return serviceID, serviceVersion, fmt.Errorf("error creating service: %w", err) 688 } 689 690 spinner.StopMessage(msg) 691 err = spinner.Stop() 692 if err != nil { 693 return "", nil, err 694 } 695 return fastly.ToValue(service.ServiceID), &fastly.Version{Number: fastly.ToPointer(1)}, nil 696 } 697 698 // CleanupNewService is executed if a new service flow has errors. 699 // It deletes the service, which will cause any contained resources to be deleted. 700 // It will also strip the Service ID from the fastly.toml manifest file. 701 func (c *DeployCommand) CleanupNewService(serviceID, manifestFilename string, out io.Writer) error { 702 text.Info(out, "\nCleaning up service\n\n") 703 err := c.Globals.APIClient.DeleteService(&fastly.DeleteServiceInput{ 704 ServiceID: serviceID, 705 }) 706 if err != nil { 707 return err 708 } 709 710 text.Info(out, "Removing Service ID from %s\n\n", manifestFilename) 711 err = c.UpdateManifestServiceID("", c.manifestPath) 712 if err != nil { 713 return err 714 } 715 716 text.Output(out, "Cleanup complete") 717 return nil 718 } 719 720 // UpdateManifestServiceID updates the Service ID in the manifest. 721 // 722 // There are two scenarios where this function is called. The first is when we 723 // have a Service ID to insert into the manifest. The other is when there is an 724 // error in the deploy flow, and for which the Service ID will be set to an 725 // empty string (otherwise the service itself will be deleted while the 726 // manifest will continue to hold a reference to it). 727 func (c *DeployCommand) UpdateManifestServiceID(serviceID, manifestPath string) error { 728 if err := c.Globals.Manifest.File.Read(manifestPath); err != nil { 729 return fmt.Errorf("error reading %s: %w", manifestPath, err) 730 } 731 c.Globals.Manifest.File.ServiceID = serviceID 732 if err := c.Globals.Manifest.File.Write(manifestPath); err != nil { 733 return fmt.Errorf("error saving %s: %w", manifestPath, err) 734 } 735 return nil 736 } 737 738 // errLogService records the error, service id and version into the error log. 739 func errLogService(l fsterr.LogInterface, err error, sid string, sv int) { 740 l.AddWithContext(err, map[string]any{ 741 "Service ID": sid, 742 "Service Version": sv, 743 }) 744 } 745 746 // CompareLocalRemotePackage compares the local package files hash against the 747 // existing service package version and exits early with message if identical. 748 // 749 // NOTE: We can't avoid the first 'no-changes' upload after the initial deploy. 750 // This is because the fastly.toml manifest does actual change after first deploy. 751 // When user first deploys, there is no value for service_id. 752 // That version of the manifest is inside the package we're checking against. 753 // So on the second deploy, even if user has made no changes themselves, we will 754 // still upload that package because technically there was a change made by the 755 // CLI to add the Service ID. Any subsequent deploys will be aborted because 756 // there will be no changes made by the CLI nor the user. 757 func (c *DeployCommand) CompareLocalRemotePackage(serviceID string, version int) error { 758 filesHash, err := getFilesHash(c.PackagePath) 759 if err != nil { 760 return err 761 } 762 p, err := c.Globals.APIClient.GetPackage(&fastly.GetPackageInput{ 763 ServiceID: serviceID, 764 ServiceVersion: version, 765 }) 766 // IMPORTANT: Skip error as some services won't have a package to compare. 767 // This happens in situations where a user will create the service outside of 768 // the CLI and then reference the Service ID in their fastly.toml manifest. 769 // In that scenario the service might just be an empty service and so trying 770 // to get the package from the service with 404. 771 if err == nil && p.Metadata != nil && filesHash == fastly.ToValue(p.Metadata.FilesHash) { 772 return ErrPackageUnchanged 773 } 774 return nil 775 } 776 777 // UploadPackage uploads the package to the specified service and version. 778 func (c *DeployCommand) UploadPackage(spinner text.Spinner, serviceID string, version int) error { 779 return spinner.Process("Uploading package", func(_ *text.SpinnerWrapper) error { 780 _, err := c.Globals.APIClient.UpdatePackage(&fastly.UpdatePackageInput{ 781 ServiceID: serviceID, 782 ServiceVersion: version, 783 PackagePath: fastly.ToPointer(c.PackagePath), 784 }) 785 if err != nil { 786 return fmt.Errorf("error uploading package: %w", err) 787 } 788 return nil 789 }) 790 } 791 792 // ServiceResources is a collection of backend objects created during setup. 793 // Objects may be nil. 794 type ServiceResources struct { 795 domains *setup.Domains 796 backends *setup.Backends 797 configStores *setup.ConfigStores 798 loggers *setup.Loggers 799 objectStores *setup.KVStores 800 kvStores *setup.KVStores 801 secretStores *setup.SecretStores 802 } 803 804 // ConstructNewServiceResources instantiates multiple [setup] config resources for a 805 // new Service to process. 806 func (c *DeployCommand) ConstructNewServiceResources( 807 sr *ServiceResources, 808 serviceID string, 809 serviceVersion int, 810 in io.Reader, 811 out io.Writer, 812 ) { 813 sr.backends = &setup.Backends{ 814 APIClient: c.Globals.APIClient, 815 AcceptDefaults: c.Globals.Flags.AcceptDefaults, 816 NonInteractive: c.Globals.Flags.NonInteractive, 817 ServiceID: serviceID, 818 ServiceVersion: serviceVersion, 819 Setup: c.Globals.Manifest.File.Setup.Backends, 820 Stdin: in, 821 Stdout: out, 822 } 823 824 sr.configStores = &setup.ConfigStores{ 825 APIClient: c.Globals.APIClient, 826 AcceptDefaults: c.Globals.Flags.AcceptDefaults, 827 NonInteractive: c.Globals.Flags.NonInteractive, 828 ServiceID: serviceID, 829 ServiceVersion: serviceVersion, 830 Setup: c.Globals.Manifest.File.Setup.ConfigStores, 831 Stdin: in, 832 Stdout: out, 833 } 834 835 sr.loggers = &setup.Loggers{ 836 Setup: c.Globals.Manifest.File.Setup.Loggers, 837 Stdout: out, 838 } 839 840 sr.objectStores = &setup.KVStores{ 841 APIClient: c.Globals.APIClient, 842 AcceptDefaults: c.Globals.Flags.AcceptDefaults, 843 NonInteractive: c.Globals.Flags.NonInteractive, 844 ServiceID: serviceID, 845 ServiceVersion: serviceVersion, 846 Setup: c.Globals.Manifest.File.Setup.ObjectStores, 847 Stdin: in, 848 Stdout: out, 849 } 850 851 sr.kvStores = &setup.KVStores{ 852 APIClient: c.Globals.APIClient, 853 AcceptDefaults: c.Globals.Flags.AcceptDefaults, 854 NonInteractive: c.Globals.Flags.NonInteractive, 855 ServiceID: serviceID, 856 ServiceVersion: serviceVersion, 857 Setup: c.Globals.Manifest.File.Setup.KVStores, 858 Stdin: in, 859 Stdout: out, 860 } 861 862 sr.secretStores = &setup.SecretStores{ 863 APIClient: c.Globals.APIClient, 864 AcceptDefaults: c.Globals.Flags.AcceptDefaults, 865 NonInteractive: c.Globals.Flags.NonInteractive, 866 ServiceID: serviceID, 867 ServiceVersion: serviceVersion, 868 Setup: c.Globals.Manifest.File.Setup.SecretStores, 869 Stdin: in, 870 Stdout: out, 871 } 872 } 873 874 // ConfigureServiceResources calls the .Predefined() and .Configure() methods 875 // for each [setup] resource, which first checks if a [setup] config has been 876 // defined for the resource type, and if so it prompts the user for details. 877 func (c *DeployCommand) ConfigureServiceResources(sr ServiceResources, serviceID string, serviceVersion int) error { 878 // NOTE: A service can't be activated without at least one backend defined. 879 // This explains why the following block of code isn't wrapped in a call to 880 // the .Predefined() method, as the call to .Configure() will ensure the 881 // user is prompted regardless of whether there is a [setup.backends] 882 // defined in the fastly.toml configuration. 883 if err := sr.backends.Configure(); err != nil { 884 errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) 885 return fmt.Errorf("error configuring service backends: %w", err) 886 } 887 888 if sr.configStores.Predefined() { 889 if err := sr.configStores.Configure(); err != nil { 890 errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) 891 return fmt.Errorf("error configuring service config stores: %w", err) 892 } 893 } 894 895 if sr.loggers.Predefined() { 896 // NOTE: We don't handle errors from the Configure() method because we 897 // don't actually do anything other than display a message to the user 898 // informing them that they need to create a log endpoint and which 899 // provider type they should be. The reason we don't implement logic for 900 // creating logging objects is because the API input fields vary 901 // significantly between providers. 902 _ = sr.loggers.Configure() 903 } 904 905 if sr.objectStores.Predefined() { 906 if err := sr.objectStores.Configure(); err != nil { 907 errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) 908 return fmt.Errorf("error configuring service object stores: %w", err) 909 } 910 } 911 912 if sr.kvStores.Predefined() { 913 if err := sr.kvStores.Configure(); err != nil { 914 errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) 915 return fmt.Errorf("error configuring service kv stores: %w", err) 916 } 917 } 918 919 if sr.secretStores.Predefined() { 920 if err := sr.secretStores.Configure(); err != nil { 921 errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) 922 return fmt.Errorf("error configuring service secret stores: %w", err) 923 } 924 } 925 926 return nil 927 } 928 929 // CreateServiceResources makes API calls to create resources that have been 930 // defined in the fastly.toml [setup] configuration. 931 func (c *DeployCommand) CreateServiceResources( 932 sr ServiceResources, 933 spinner text.Spinner, 934 serviceID string, 935 serviceVersion int, 936 ) error { 937 sr.backends.Spinner = spinner 938 sr.configStores.Spinner = spinner 939 sr.objectStores.Spinner = spinner 940 sr.kvStores.Spinner = spinner 941 sr.secretStores.Spinner = spinner 942 943 if err := sr.backends.Create(); err != nil { 944 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 945 "Accept defaults": c.Globals.Flags.AcceptDefaults, 946 "Auto-yes": c.Globals.Flags.AutoYes, 947 "Non-interactive": c.Globals.Flags.NonInteractive, 948 "Service ID": serviceID, 949 "Service Version": serviceVersion, 950 }) 951 return err 952 } 953 954 if err := sr.configStores.Create(); err != nil { 955 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 956 "Accept defaults": c.Globals.Flags.AcceptDefaults, 957 "Auto-yes": c.Globals.Flags.AutoYes, 958 "Non-interactive": c.Globals.Flags.NonInteractive, 959 "Service ID": serviceID, 960 "Service Version": serviceVersion, 961 }) 962 return err 963 } 964 965 if err := sr.objectStores.Create(); err != nil { 966 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 967 "Accept defaults": c.Globals.Flags.AcceptDefaults, 968 "Auto-yes": c.Globals.Flags.AutoYes, 969 "Non-interactive": c.Globals.Flags.NonInteractive, 970 "Service ID": serviceID, 971 "Service Version": serviceVersion, 972 }) 973 return err 974 } 975 976 if err := sr.kvStores.Create(); err != nil { 977 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 978 "Accept defaults": c.Globals.Flags.AcceptDefaults, 979 "Auto-yes": c.Globals.Flags.AutoYes, 980 "Non-interactive": c.Globals.Flags.NonInteractive, 981 "Service ID": serviceID, 982 "Service Version": serviceVersion, 983 }) 984 return err 985 } 986 987 if err := sr.secretStores.Create(); err != nil { 988 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 989 "Accept defaults": c.Globals.Flags.AcceptDefaults, 990 "Auto-yes": c.Globals.Flags.AutoYes, 991 "Non-interactive": c.Globals.Flags.NonInteractive, 992 "Service ID": serviceID, 993 "Service Version": serviceVersion, 994 }) 995 return err 996 } 997 998 return nil 999 } 1000 1001 // ProcessService updates the service version comment and then activates the 1002 // service version. 1003 func (c *DeployCommand) ProcessService(serviceID string, serviceVersion int, spinner text.Spinner) error { 1004 if c.Comment.WasSet { 1005 _, err := c.Globals.APIClient.UpdateVersion(&fastly.UpdateVersionInput{ 1006 ServiceID: serviceID, 1007 ServiceVersion: serviceVersion, 1008 Comment: &c.Comment.Value, 1009 }) 1010 if err != nil { 1011 return fmt.Errorf("error setting comment for service version %d: %w", serviceVersion, err) 1012 } 1013 } 1014 1015 return spinner.Process(fmt.Sprintf("Activating service (version %d)", serviceVersion), func(_ *text.SpinnerWrapper) error { 1016 _, err := c.Globals.APIClient.ActivateVersion(&fastly.ActivateVersionInput{ 1017 ServiceID: serviceID, 1018 ServiceVersion: serviceVersion, 1019 }) 1020 if err != nil { 1021 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 1022 "Service ID": serviceID, 1023 "Service Version": serviceVersion, 1024 }) 1025 return fmt.Errorf("error activating version: %w", err) 1026 } 1027 return nil 1028 }) 1029 } 1030 1031 // GetServiceURL returns the service URL. 1032 func (c *DeployCommand) GetServiceURL(serviceID string, serviceVersion int) (string, error) { 1033 latestDomains, err := c.Globals.APIClient.ListDomains(&fastly.ListDomainsInput{ 1034 ServiceID: serviceID, 1035 ServiceVersion: serviceVersion, 1036 }) 1037 if err != nil { 1038 return "", err 1039 } 1040 name := fastly.ToValue(latestDomains[0].Name) 1041 if segs := strings.Split(name, "*."); len(segs) > 1 { 1042 name = segs[1] 1043 } 1044 return fmt.Sprintf("https://%s", name), nil 1045 } 1046 1047 // checkingServiceAvailability pings the service URL until either there is a 1048 // non-500 (or whatever status code is configured by the user) or if the 1049 // configured timeout is reached. 1050 func checkingServiceAvailability( 1051 serviceURL string, 1052 spinner text.Spinner, 1053 c *DeployCommand, 1054 ) (status int, err error) { 1055 remediation := "The service has been successfully deployed and activated, but the service 'availability' check %s (we were looking for a %s but the last status code response was: %d). If using a custom domain, please be sure to check your DNS settings. Otherwise, your application might be taking longer than usual to deploy across our global network. Please continue to check the service URL and if still unavailable please contact Fastly support." 1056 1057 dur := time.Duration(c.StatusCheckTimeout) * time.Second 1058 end := time.Now().Add(dur) 1059 timeout := time.After(dur) 1060 ticker := time.NewTicker(1 * time.Second) 1061 defer func() { ticker.Stop() }() 1062 1063 err = spinner.Start() 1064 if err != nil { 1065 return 0, err 1066 } 1067 msg := "Checking service availability" 1068 spinner.Message(msg + generateTimeout(time.Until(end))) 1069 1070 expected := "non-500 status code" 1071 if validStatusCodeRange(c.StatusCheckCode) { 1072 expected = fmt.Sprintf("%d status code", c.StatusCheckCode) 1073 } 1074 1075 // Keep trying until we're timed out, got a result or got an error 1076 for { 1077 select { 1078 case <-timeout: 1079 err := errors.New("timeout: service not yet available") 1080 returnedStatus := fmt.Sprintf(" (status: %d)", status) 1081 spinner.StopFailMessage(msg + returnedStatus) 1082 spinErr := spinner.StopFail() 1083 if spinErr != nil { 1084 return status, fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) 1085 } 1086 return status, fsterr.RemediationError{ 1087 Inner: err, 1088 Remediation: fmt.Sprintf(remediation, "timed out", expected, status), 1089 } 1090 case t := <-ticker.C: 1091 var ( 1092 ok bool 1093 err error 1094 ) 1095 // We overwrite the `status` variable in the parent scope (defined in the 1096 // return arguments list) so it can be used as part of both the timeout 1097 // and success scenarios. 1098 ok, status, err = pingServiceURL(serviceURL, c.Globals.HTTPClient, c.StatusCheckCode) 1099 if err != nil { 1100 err := fmt.Errorf("failed to ping service URL: %w", err) 1101 returnedStatus := fmt.Sprintf(" (status: %d)", status) 1102 spinner.StopFailMessage(msg + returnedStatus) 1103 spinErr := spinner.StopFail() 1104 if spinErr != nil { 1105 return status, fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) 1106 } 1107 return status, fsterr.RemediationError{ 1108 Inner: err, 1109 Remediation: fmt.Sprintf(remediation, "failed", expected, status), 1110 } 1111 } 1112 if ok { 1113 returnedStatus := fmt.Sprintf(" (status: %d)", status) 1114 spinner.StopMessage(msg + returnedStatus) 1115 return status, spinner.Stop() 1116 } 1117 // Service not available, and no error, so jump back to top of loop 1118 spinner.Message(msg + generateTimeout(end.Sub(t))) 1119 } 1120 } 1121 } 1122 1123 // generateTimeout inserts a dynamically generated message on each tick. 1124 // It notifies the user what's happening and how long is left on the timer. 1125 func generateTimeout(d time.Duration) string { 1126 remaining := fmt.Sprintf("timeout: %v", d.Round(time.Second)) 1127 return fmt.Sprintf(" (app deploying across Fastly's global network | %s)...", remaining) 1128 } 1129 1130 // pingServiceURL indicates if the service returned a non-5xx response (or 1131 // whatever the user defined with --status-check-code), which should help 1132 // signify if the service is generally available. 1133 func pingServiceURL(serviceURL string, httpClient api.HTTPClient, expectedStatusCode int) (ok bool, status int, err error) { 1134 req, err := http.NewRequest("GET", serviceURL, nil) 1135 if err != nil { 1136 return false, 0, err 1137 } 1138 1139 // gosec flagged this: 1140 // G107 (CWE-88): Potential HTTP request made with variable url 1141 // Disabling as we trust the source of the variable. 1142 // #nosec 1143 resp, err := httpClient.Do(req) 1144 if err != nil { 1145 return false, 0, err 1146 } 1147 defer func() { 1148 _ = resp.Body.Close() 1149 }() 1150 1151 // We check for the user's defined status code expectation. 1152 // Otherwise we'll default to checking for a non-500. 1153 if validStatusCodeRange(expectedStatusCode) && resp.StatusCode == expectedStatusCode { 1154 return true, resp.StatusCode, nil 1155 } else if resp.StatusCode < http.StatusInternalServerError { 1156 return true, resp.StatusCode, nil 1157 } 1158 return false, resp.StatusCode, nil 1159 } 1160 1161 // ExistingServiceVersion returns a Service Version for an existing service. 1162 // If the current service version is active or locked, we clone the version. 1163 func (c *DeployCommand) ExistingServiceVersion(serviceID string, out io.Writer) (*fastly.Version, error) { 1164 var ( 1165 err error 1166 serviceVersion *fastly.Version 1167 ) 1168 1169 // There is a scenario where a user already has a Service ID within the 1170 // fastly.toml manifest but they want to deploy their project to a different 1171 // service (e.g. deploy to a staging service). 1172 // 1173 // In this scenario we end up here because we have found a Service ID in the 1174 // manifest but if the --service-name flag is set, then we need to ignore 1175 // what's set in the manifest and instead identify the ID of the service 1176 // name the user has provided. 1177 if c.ServiceName.WasSet { 1178 serviceID, err = c.ServiceName.Parse(c.Globals.APIClient) 1179 if err != nil { 1180 return nil, err 1181 } 1182 } 1183 1184 serviceVersion, err = c.ServiceVersion.Parse(serviceID, c.Globals.APIClient) 1185 if err != nil { 1186 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 1187 "Package path": c.PackagePath, 1188 "Service ID": serviceID, 1189 }) 1190 return nil, err 1191 } 1192 1193 serviceVersionNumber := fastly.ToValue(serviceVersion.Number) 1194 1195 // Validate that we're dealing with a Compute 'wasm' service and not a 1196 // VCL service, for which we cannot upload a wasm package format to. 1197 serviceDetails, err := c.Globals.APIClient.GetServiceDetails(&fastly.GetServiceInput{ServiceID: serviceID}) 1198 if err != nil { 1199 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 1200 "Service ID": serviceID, 1201 "Service Version": serviceVersionNumber, 1202 }) 1203 return serviceVersion, err 1204 } 1205 serviceType := fastly.ToValue(serviceDetails.Type) 1206 if serviceType != "wasm" { 1207 c.Globals.ErrLog.AddWithContext(fmt.Errorf("error: invalid service type: '%s'", serviceType), map[string]any{ 1208 "Service ID": serviceID, 1209 "Service Version": serviceVersionNumber, 1210 "Service Type": serviceType, 1211 }) 1212 return serviceVersion, fsterr.RemediationError{ 1213 Inner: fmt.Errorf("invalid service type: %s", serviceType), 1214 Remediation: "Ensure the provided Service ID is associated with a 'Wasm' Fastly Service and not a 'VCL' Fastly service. " + fsterr.ComputeTrialRemediation, 1215 } 1216 } 1217 1218 err = c.CompareLocalRemotePackage(serviceID, serviceVersionNumber) 1219 if err != nil { 1220 c.Globals.ErrLog.AddWithContext(err, map[string]any{ 1221 "Package path": c.PackagePath, 1222 "Service ID": serviceID, 1223 "Service Version": serviceVersionNumber, 1224 }) 1225 return serviceVersion, err 1226 } 1227 1228 // Unlike other CLI commands that are a direct mapping to an API endpoint, 1229 // the compute deploy command is a composite of behaviours, and so as we 1230 // already automatically activate a version we should autoclone without 1231 // requiring the user to explicitly provide an --autoclone flag. 1232 if fastly.ToValue(serviceVersion.Active) || fastly.ToValue(serviceVersion.Locked) { 1233 clonedVersion, err := c.Globals.APIClient.CloneVersion(&fastly.CloneVersionInput{ 1234 ServiceID: serviceID, 1235 ServiceVersion: serviceVersionNumber, 1236 }) 1237 if err != nil { 1238 errLogService(c.Globals.ErrLog, err, serviceID, serviceVersionNumber) 1239 return serviceVersion, fmt.Errorf("error cloning service version: %w", err) 1240 } 1241 if c.Globals.Verbose() { 1242 msg := "Service version %d is not editable, so it was automatically cloned. Now operating on version %d.\n\n" 1243 format := fmt.Sprintf(msg, serviceVersionNumber, fastly.ToValue(clonedVersion.Number)) 1244 text.Output(out, format) 1245 } 1246 serviceVersion = clonedVersion 1247 } 1248 1249 return serviceVersion, nil 1250 } 1251 1252 func monitorSignals(signalCh chan os.Signal, noExistingService bool, out io.Writer, undoStack *undo.Stack, spinner text.Spinner) { 1253 <-signalCh 1254 signal.Stop(signalCh) 1255 spinner.StopFailMessage("Signal received to interrupt/terminate the Fastly CLI process") 1256 _ = spinner.StopFail() 1257 text.Important(out, "\n\nThe Fastly CLI process will be terminated after any clean-up tasks have been processed") 1258 if noExistingService { 1259 undoStack.Unwind(out) 1260 } 1261 os.Exit(1) 1262 }