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  }