github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/commands/compute/hashfiles.go (about)

     1  package compute
     2  
     3  import (
     4  	"archive/tar"
     5  	"bytes"
     6  	"crypto/sha512"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"path/filepath"
    12  	"sort"
    13  
    14  	"github.com/kennygrant/sanitize"
    15  	"github.com/mholt/archiver/v3"
    16  
    17  	"github.com/fastly/cli/pkg/argparser"
    18  	fsterr "github.com/fastly/cli/pkg/errors"
    19  	"github.com/fastly/cli/pkg/global"
    20  	"github.com/fastly/cli/pkg/manifest"
    21  	"github.com/fastly/cli/pkg/text"
    22  )
    23  
    24  // MaxPackageSize represents the max package size that can be uploaded to the
    25  // Fastly Package API endpoint.
    26  //
    27  // NOTE: This is variable not a constant for the sake of test manipulations.
    28  // https://developer.fastly.com/learning/compute/#limitations-and-constraints
    29  var MaxPackageSize int64 = 100000000 // 100MB in bytes
    30  
    31  // HashFilesCommand produces a deployable artifact from files on the local disk.
    32  type HashFilesCommand struct {
    33  	argparser.Base
    34  
    35  	// Build fields
    36  	dir                   argparser.OptionalString
    37  	env                   argparser.OptionalString
    38  	includeSrc            argparser.OptionalBool
    39  	lang                  argparser.OptionalString
    40  	metadataDisable       argparser.OptionalBool
    41  	metadataFilterEnvVars argparser.OptionalString
    42  	metadataShow          argparser.OptionalBool
    43  	packageName           argparser.OptionalString
    44  	timeout               argparser.OptionalInt
    45  
    46  	buildCmd  *BuildCommand
    47  	Package   string
    48  	SkipBuild bool
    49  }
    50  
    51  // NewHashFilesCommand returns a usable command registered under the parent.
    52  func NewHashFilesCommand(parent argparser.Registerer, g *global.Data, build *BuildCommand) *HashFilesCommand {
    53  	var c HashFilesCommand
    54  	c.buildCmd = build
    55  	c.Globals = g
    56  	c.CmdClause = parent.Command("hash-files", "Generate a SHA512 digest from the contents of the Compute package")
    57  	c.CmdClause.Flag("dir", "Project directory to build (default: current directory)").Short('C').Action(c.dir.Set).StringVar(&c.dir.Value)
    58  	c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").Action(c.env.Set).StringVar(&c.env.Value)
    59  	c.CmdClause.Flag("include-source", "Include source code in built package").Action(c.includeSrc.Set).BoolVar(&c.includeSrc.Value)
    60  	c.CmdClause.Flag("language", "Language type").Action(c.lang.Set).StringVar(&c.lang.Value)
    61  	c.CmdClause.Flag("metadata-disable", "Disable Wasm binary metadata annotations").Action(c.metadataDisable.Set).BoolVar(&c.metadataDisable.Value)
    62  	c.CmdClause.Flag("metadata-filter-envvars", "Redact specified environment variables from [scripts.env_vars] using comma-separated list").Action(c.metadataFilterEnvVars.Set).StringVar(&c.metadataFilterEnvVars.Value)
    63  	c.CmdClause.Flag("metadata-show", "Inspect the Wasm binary metadata").Action(c.metadataShow.Set).BoolVar(&c.metadataShow.Value)
    64  	c.CmdClause.Flag("package", "Path to a package tar.gz").Short('p').StringVar(&c.Package)
    65  	c.CmdClause.Flag("package-name", "Package name").Action(c.packageName.Set).StringVar(&c.packageName.Value)
    66  	c.CmdClause.Flag("skip-build", "Skip the build step").BoolVar(&c.SkipBuild)
    67  	c.CmdClause.Flag("timeout", "Timeout, in seconds, for the build compilation step").Action(c.timeout.Set).IntVar(&c.timeout.Value)
    68  
    69  	return &c
    70  }
    71  
    72  // Exec implements the command interface.
    73  func (c *HashFilesCommand) Exec(in io.Reader, out io.Writer) (err error) {
    74  	if !c.SkipBuild && c.Package == "" {
    75  		err = c.Build(in, out)
    76  		if err != nil {
    77  			return err
    78  		}
    79  		if c.Globals.Verbose() {
    80  			text.Break(out)
    81  		}
    82  	}
    83  
    84  	var pkgPath string
    85  
    86  	if c.Package == "" {
    87  		manifestFilename := EnvironmentManifest(c.env.Value)
    88  		wd, err := os.Getwd()
    89  		if err != nil {
    90  			return fmt.Errorf("failed to get current working directory: %w", err)
    91  		}
    92  		defer func() {
    93  			_ = os.Chdir(wd)
    94  		}()
    95  		manifestPath := filepath.Join(wd, manifestFilename)
    96  
    97  		projectDir, err := ChangeProjectDirectory(c.dir.Value)
    98  		if err != nil {
    99  			return err
   100  		}
   101  		if projectDir != "" {
   102  			if c.Globals.Verbose() {
   103  				text.Info(out, ProjectDirMsg, projectDir)
   104  			}
   105  			manifestPath = filepath.Join(projectDir, manifestFilename)
   106  		}
   107  
   108  		if projectDir != "" || c.env.WasSet {
   109  			err = c.Globals.Manifest.File.Read(manifestPath)
   110  		} else {
   111  			err = c.Globals.Manifest.File.ReadError()
   112  		}
   113  		if err != nil {
   114  			if errors.Is(err, os.ErrNotExist) {
   115  				err = fsterr.ErrReadingManifest
   116  			}
   117  			c.Globals.ErrLog.Add(err)
   118  			return err
   119  		}
   120  
   121  		projectName, source := c.Globals.Manifest.Name()
   122  		if source == manifest.SourceUndefined {
   123  			return fsterr.ErrReadingManifest
   124  		}
   125  		pkgPath = filepath.Join(projectDir, "pkg", fmt.Sprintf("%s.tar.gz", sanitize.BaseName(projectName)))
   126  	} else {
   127  		pkgPath, err = filepath.Abs(c.Package)
   128  		if err != nil {
   129  			return fmt.Errorf("failed to locate package path '%s': %w", c.Package, err)
   130  		}
   131  	}
   132  
   133  	hash, err := getFilesHash(pkgPath)
   134  	if err != nil {
   135  		return err
   136  	}
   137  
   138  	text.Output(out, hash)
   139  	return nil
   140  }
   141  
   142  // Build constructs and executes the build logic.
   143  func (c *HashFilesCommand) Build(in io.Reader, out io.Writer) error {
   144  	output := out
   145  	if !c.Globals.Verbose() {
   146  		output = io.Discard
   147  	}
   148  	if c.dir.WasSet {
   149  		c.buildCmd.Flags.Dir = c.dir.Value
   150  	}
   151  	if c.env.WasSet {
   152  		c.buildCmd.Flags.Env = c.env.Value
   153  	}
   154  	if c.includeSrc.WasSet {
   155  		c.buildCmd.Flags.IncludeSrc = c.includeSrc.Value
   156  	}
   157  	if c.lang.WasSet {
   158  		c.buildCmd.Flags.Lang = c.lang.Value
   159  	}
   160  	if c.packageName.WasSet {
   161  		c.buildCmd.Flags.PackageName = c.packageName.Value
   162  	}
   163  	if c.timeout.WasSet {
   164  		c.buildCmd.Flags.Timeout = c.timeout.Value
   165  	}
   166  	if c.metadataDisable.WasSet {
   167  		c.buildCmd.MetadataDisable = c.metadataDisable.Value
   168  	}
   169  	if c.metadataFilterEnvVars.WasSet {
   170  		c.buildCmd.MetadataFilterEnvVars = c.metadataFilterEnvVars.Value
   171  	}
   172  	if c.metadataShow.WasSet {
   173  		c.buildCmd.MetadataShow = c.metadataShow.Value
   174  	}
   175  	return c.buildCmd.Exec(in, output)
   176  }
   177  
   178  // getFilesHash returns a hash of all the files in the package in sorted filename order.
   179  func getFilesHash(pkgPath string) (string, error) {
   180  	contents := make(map[string]*bytes.Buffer)
   181  
   182  	if err := packageFiles(pkgPath, func(f archiver.File) error {
   183  		// We want the full path here and not f.Name(), which is only the
   184  		// filename.
   185  		//
   186  		// This is safe to do - we already verified it in packageFiles().
   187  		header, ok := f.Header.(*tar.Header)
   188  		if !ok {
   189  			return errors.New("failed to convert file type into *tar.Header")
   190  		}
   191  		entry := header.Name
   192  		contents[entry] = &bytes.Buffer{}
   193  		if _, err := io.Copy(contents[entry], f); err != nil {
   194  			return fmt.Errorf("error reading %s: %w", entry, err)
   195  		}
   196  		return nil
   197  	}); err != nil {
   198  		return "", err
   199  	}
   200  
   201  	keys := make([]string, 0, len(contents))
   202  	for k := range contents {
   203  		keys = append(keys, k)
   204  	}
   205  	sort.Strings(keys)
   206  
   207  	h := sha512.New()
   208  	for _, entry := range keys {
   209  		if _, err := io.Copy(h, contents[entry]); err != nil {
   210  			return "", fmt.Errorf("failed to generate hash from package files: %w", err)
   211  		}
   212  	}
   213  	return fmt.Sprintf("%x", h.Sum(nil)), nil
   214  }