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 }