github.com/section/sectionctl@v1.12.3/commands/deploy.go (about)

     1  package commands
     2  
     3  import (
     4  	"archive/tar"
     5  	"bytes"
     6  	"compress/gzip"
     7  	"encoding/json"
     8  	"fmt"
     9  	"io"
    10  	"io/ioutil"
    11  	"mime/multipart"
    12  	"net/http"
    13  	"net/url"
    14  	"os"
    15  	"path/filepath"
    16  	"runtime"
    17  	"strconv"
    18  	"strings"
    19  	"time"
    20  
    21  	"github.com/rs/zerolog/log"
    22  
    23  	"github.com/alecthomas/kong"
    24  	"github.com/section/sectionctl/api"
    25  )
    26  
    27  // MaxFileSize is the tarball file size allowed to be uploaded in bytes.
    28  const MaxFileSize = 1073741824 // 1GB
    29  
    30  // DeployCmd handles deploying an app to Section.
    31  type DeployCmd struct {
    32  	AccountID      int           `short:"a" help:"AccountID to deploy application to."`
    33  	AppID          int           `short:"i" help:"AppID to deploy application to."`
    34  	Environment    string        `short:"e" default:"Production" help:"Environment to deploy application to. (name of git branch ie: Production, staging, development)"`
    35  	Directory      string        `short:"C" default:"." help:"Directory which contains the application to deploy."`
    36  	ServerURL      *url.URL      `default:"https://aperture.section.io/new/code_upload/v1/upload" help:"URL to upload application to"`
    37  	Timeout        time.Duration `default:"600s" help:"Timeout of individual HTTP requests."`
    38  	SkipDelete     bool          `help:"Skip delete of temporary tarball created to upload app."`
    39  	SkipValidation bool          `help:"Skip validation of the workload before pushing into Section. Use with caution."`
    40  	AppPath        string        `default:"nodejs" help:"Path of NodeJS application in environment repository."`
    41  }
    42  
    43  // UploadResponse represents the response from a request to the upload service.
    44  type UploadResponse struct {
    45  	PayloadID string `json:"payloadID"`
    46  }
    47  
    48  // PayloadValue represents the value of a trigger update payload.
    49  type PayloadValue struct {
    50  	ID string `json:"section_payload_id"`
    51  }
    52  
    53  // Run deploys an app to Section's edge
    54  func (c *DeployCmd) Run(ctx *kong.Context, logWriters *LogWriters) (err error) {
    55  
    56  	dir := c.Directory
    57  	if dir == "." {
    58  		abs, err := filepath.Abs(dir)
    59  		if err == nil {
    60  			dir = abs
    61  		}
    62  	}
    63  
    64  	log.Info().Msg(Green("Deploying your node.js package to Account ID: %d, App ID: %d, Environment %s", c.AccountID, c.AppID, c.Environment))
    65  	if !c.SkipValidation {
    66  		errs := IsValidNodeApp(dir)
    67  		if len(errs) > 0 {
    68  			var se []string
    69  			for _, err := range errs {
    70  				se = append(se, fmt.Sprintf("- %s", err))
    71  			}
    72  			errstr := strings.Join(se, "\n")
    73  			return fmt.Errorf("not a valid Node.js app: \n\n%s", errstr)
    74  		}
    75  	}
    76  
    77  	s := NewSpinner(fmt.Sprintf("Packaging app in: %s", dir), logWriters)
    78  	s.Start()
    79  
    80  	ignores := []string{".lint", ".git"}
    81  	files, err := BuildFilelist(dir, ignores)
    82  	if err != nil {
    83  		s.Stop()
    84  		return fmt.Errorf("unable to build file list: %s", err)
    85  	}
    86  	s.Stop()
    87  	log.Debug().Msg("Archiving files:")
    88  	for _, file := range files {
    89  		log.Debug().Str("file", file)
    90  	}
    91  
    92  	tempFile, err := ioutil.TempFile("", "sectionctl-deploy.*.tar.gz")
    93  	if err != nil {
    94  		s.Stop()
    95  		return fmt.Errorf("couldn't create a temp file: %v", err)
    96  	}
    97  	if c.SkipDelete {
    98  		s.Stop()
    99  		log.Debug().Str("Temporar upload tarball location", tempFile.Name())
   100  		s.Start()
   101  	} else {
   102  		defer os.Remove(tempFile.Name())
   103  	}
   104  
   105  	err = CreateTarball(tempFile, files)
   106  	if err != nil {
   107  		s.Stop()
   108  		return fmt.Errorf("failed to pack files: %v", err)
   109  	}
   110  	s.Stop()
   111  
   112  	log.Debug().Str("Temporar file location", tempFile.Name())
   113  	stat, err := tempFile.Stat()
   114  	if err != nil {
   115  		return fmt.Errorf("%s: could not stat, got error: %s", tempFile.Name(), err)
   116  	}
   117  	if stat.Size() > MaxFileSize {
   118  		return fmt.Errorf("failed to upload tarball: file size (%d) is greater than (%d)", stat.Size(), MaxFileSize)
   119  	}
   120  
   121  	_, err = tempFile.Seek(0, 0)
   122  	if err != nil {
   123  		return fmt.Errorf("unable to seek to beginning of tarball: %s", err)
   124  	}
   125  
   126  	req, err := newFileUploadRequest(c, tempFile)
   127  	if err != nil {
   128  		return fmt.Errorf("unable to build file upload: %s", err)
   129  	}
   130  
   131  	req.Header.Add("section-token", api.Token)
   132  
   133  	log.Debug().Str("URL", req.URL.String())
   134  
   135  	artifactSizeMB := stat.Size() / 1024 / 1024
   136  	log.Debug().Msg(fmt.Sprintf("Upload artifact is %dMB (%d bytes) large", artifactSizeMB, stat.Size()))
   137  	s = NewSpinner(fmt.Sprintf("Uploading app (%dMB)...", artifactSizeMB), logWriters)
   138  	s.Start()
   139  	client := &http.Client{
   140  		Timeout: c.Timeout,
   141  	}
   142  	resp, err := client.Do(req)
   143  	if err != nil {
   144  		return fmt.Errorf("upload request failed: %v", err)
   145  	}
   146  	defer resp.Body.Close()
   147  	s.Stop()
   148  	if resp.StatusCode != 200 && resp.StatusCode != 204 {
   149  		return fmt.Errorf("upload failed with status: %s and transaction ID %s", resp.Status, resp.Header["Aperture-Tx-Id"][0])
   150  	}
   151  
   152  	var response UploadResponse
   153  	err = json.NewDecoder(resp.Body).Decode(&response)
   154  	if err != nil {
   155  		return fmt.Errorf("failed to decode response %v", err)
   156  	}
   157  
   158  	err = globalGitService.UpdateGitViaGit(ctx, c, response, logWriters)
   159  	if err != nil {
   160  		if err.Error() == "file not found" {
   161  			return fmt.Errorf("this application is not configured to host a node.js app on Section, or, possibly, you didn't specify the proper --AppPath")
   162  		}
   163  		return fmt.Errorf("failed to trigger app update: %v", err)
   164  	}
   165  
   166  	log.Info().Msg("Done!")
   167  
   168  	return nil
   169  }
   170  
   171  // IsValidNodeApp detects if a Node.js app is present in a given directory
   172  func IsValidNodeApp(dir string) (errs []error) {
   173  	packageJSONPath := filepath.Join(dir, "package.json")
   174  	if packageJSONContents, err := ioutil.ReadFile(packageJSONPath); err != nil {
   175  		if os.IsNotExist(err) {
   176  			log.Debug().Msg(fmt.Sprintf("[WARN] %s is not a file", packageJSONPath))
   177  		} else {
   178  			log.Info().Err(err).Msg("Error reading your package.json")
   179  		}		
   180  	} else {
   181  		packageJSON, err := ParsePackageJSON(string(packageJSONContents))
   182  		if err != nil {
   183  			log.Info().Err(err).Msg("Error parsing your package.json")
   184  		}
   185  		if len(packageJSON.Section.StartScript) == 0 && packageJSON.Scripts["start"] == "" {
   186  			errs = append(errs, fmt.Errorf("package.json does not include a start script. please add one"))
   187  		} else if len(packageJSON.Section.StartScript) > 0 && len(packageJSON.Scripts[packageJSON.Section.StartScript]) == 0 {
   188  			errs = append(errs, fmt.Errorf("package.json does not include the script: %s", packageJSON.Section.StartScript))
   189  		}
   190  	}
   191  
   192  	nodeModulesPath := filepath.Join(dir, "node_modules")
   193  	fi, err := os.Stat(nodeModulesPath)
   194  	if os.IsNotExist(err) {
   195  		errs = append(errs, fmt.Errorf("%s is not a directory", nodeModulesPath))
   196  	} else {
   197  		if !fi.IsDir() {
   198  			errs = append(errs, fmt.Errorf("%s is not a directory", nodeModulesPath))
   199  		}
   200  	}
   201  
   202  	return errs
   203  }
   204  
   205  // Split helps differentiate between different directory delimiters. / or \
   206  func Split(r rune) bool {
   207  	return r == '\\' || r == '/'
   208  }
   209  
   210  // BuildFilelist builds a list of files to be tarballed, with optional ignores.
   211  func BuildFilelist(dir string, ignores []string) (files []string, err error) {
   212  	var fi os.FileInfo
   213  	if fi, err = os.Stat(dir); os.IsNotExist(err) {
   214  		return files, err
   215  	}
   216  	if !fi.IsDir() {
   217  		return files, fmt.Errorf("specified path is not a directory: %s", dir)
   218  	}
   219  
   220  	err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
   221  		for _, i := range ignores {
   222  			location := strings.FieldsFunc(path, Split) // split by subdirectory or filename
   223  			for _, loc := range location {
   224  				if strings.Contains(loc, i) {
   225  					return nil
   226  				}
   227  			}
   228  		}
   229  		files = append(files, path)
   230  		return nil
   231  	})
   232  	if err != nil {
   233  		return files, fmt.Errorf("failed to walk path: %v", err)
   234  	}
   235  	return files, err
   236  }
   237  
   238  // CreateTarball creates a tarball containing all the files in filePaths and writes it to w.
   239  func CreateTarball(w io.Writer, filePaths []string) error {
   240  	gzipWriter := gzip.NewWriter(w)
   241  	defer gzipWriter.Close()
   242  
   243  	tarWriter := tar.NewWriter(gzipWriter)
   244  	defer tarWriter.Close()
   245  
   246  	prefix := filePaths[0]
   247  	for _, filePath := range filePaths {
   248  		err := addFileToTarWriter(filePath, tarWriter, prefix)
   249  		if err != nil {
   250  			return fmt.Errorf(fmt.Sprintf("Could not add file '%s', to tarball, got error '%s'", filePath, err.Error()))
   251  		}
   252  	}
   253  
   254  	return nil
   255  }
   256  
   257  func addFileToTarWriter(filePath string, tarWriter *tar.Writer, prefix string) error {
   258  	file, err := os.Open(filePath)
   259  	if err != nil {
   260  		return fmt.Errorf(fmt.Sprintf("Could not open file '%s', got error '%s'", filePath, err.Error()))
   261  	}
   262  	defer file.Close()
   263  
   264  	stat, err := os.Lstat(filePath)
   265  	if err != nil {
   266  		return fmt.Errorf(fmt.Sprintf("Could not get stat for file '%s', got error '%s'", filePath, err.Error()))
   267  	}
   268  
   269  	baseFilePath := strings.TrimPrefix(filePath, prefix)
   270  	header, err := tar.FileInfoHeader(stat, baseFilePath)
   271  	if err != nil {
   272  		return err
   273  	}
   274  	if stat.Mode()&os.ModeSymlink == os.ModeSymlink {
   275  		link, err := os.Readlink(filePath)
   276  		if err != nil {
   277  			return err
   278  		}
   279  		header.Linkname = link
   280  	}
   281  
   282  	// must provide real name
   283  	// (see https://golang.org/src/archive/tar/common.go?#L626)
   284  	header.Name = filepath.ToSlash(baseFilePath)
   285  	// ensure windows provides filemodes for binaries in node_modules/.bin
   286  	if runtime.GOOS == "windows" {
   287  		match := strings.Contains(baseFilePath, "node_modules\\.bin")
   288  		if match {
   289  			header.Mode = 0o755
   290  		}
   291  	}
   292  	err = tarWriter.WriteHeader(header)
   293  	if err != nil {
   294  		return fmt.Errorf(fmt.Sprintf("Could not write header for file '%s', got error '%s'", baseFilePath, err.Error()))
   295  	}
   296  
   297  	if !stat.IsDir() && stat.Mode()&os.ModeSymlink != os.ModeSymlink {
   298  		_, err = io.Copy(tarWriter, file)
   299  		if err != nil {
   300  			return fmt.Errorf(fmt.Sprintf("Could not copy the file '%s' data to the tarball, got error '%s'", baseFilePath, err.Error()))
   301  		}
   302  	}
   303  
   304  	return nil
   305  }
   306  
   307  // newFileUploadRequest builds a HTTP request for uploading an app and the account + app it belongs to
   308  func newFileUploadRequest(c *DeployCmd, f *os.File) (r *http.Request, err error) {
   309  	contents, err := ioutil.ReadAll(f)
   310  	if err != nil {
   311  		return nil, err
   312  	}
   313  	defer f.Close()
   314  
   315  	var body bytes.Buffer
   316  	writer := multipart.NewWriter(&body)
   317  	part, err := writer.CreateFormFile("file", filepath.Base(f.Name()))
   318  	if err != nil {
   319  		return nil, err
   320  	}
   321  	_, err = part.Write(contents)
   322  	if err != nil {
   323  		return nil, err
   324  	}
   325  
   326  	err = writer.WriteField("account_id", strconv.Itoa(c.AccountID))
   327  	if err != nil {
   328  		return nil, err
   329  	}
   330  	err = writer.WriteField("app_id", strconv.Itoa(c.AppID))
   331  	if err != nil {
   332  		return nil, err
   333  	}
   334  
   335  	err = writer.Close()
   336  	if err != nil {
   337  		return nil, err
   338  	}
   339  
   340  	req, err := http.NewRequest(http.MethodPost, c.ServerURL.String(), &body)
   341  	if err != nil {
   342  		return nil, fmt.Errorf("failed to create upload URL: %v", err)
   343  	}
   344  	req.Header.Add("Content-Type", writer.FormDataContentType())
   345  
   346  	return req, err
   347  }