-
Notifications
You must be signed in to change notification settings - Fork 26
/
main.go
629 lines (532 loc) · 18.1 KB
/
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"text/template"
"github.com/drone/drone-plugin-go/plugin"
)
var (
// Version is set at compile time.
version string
// Build revision is set at compile time.
rev string
)
type GAE struct {
// Action is required and can be any action accepted by the `appcfg.py` or
// `gcloud app` commands:
//
// appcfg.py (update, update_cron, update_indexes, set_default_version, etc.)
// gcloud app (deploy, services, versions, etc.)
// The appcfg.py commands are deprecated and will no longer work come Oct 2019.
Action string `json:"action"`
// AddlArgs is a set of key-value pairs to allow users to pass along any
// additional parameters to the `appcfg.py` command.
AddlArgs map[string]string `json:"addl_args"`
// AddlFlags is an array of flag parameters that do not have a value.
AddlFlags []string `json:"addl_flags"`
// Version is used to set the version of new deployments
// or to alter existing deployments.
// value will be sanitized (lowercase, replace non-alphanumeric with `-`, max 63 chars)
Version string `json:"version"`
// Service is used to set the service to be deployed
Service string `json:"service"`
// AEEnv allows users to set additional environment variables with `appcfg.py -E`
// in their App Engine environment. This can be useful for injecting
// secrets from your Drone secret store. No effect with `gcloud` commands.
AEEnv map[string]string `json:"ae_environment"`
// SubCommands are optionally used with `gcloud app` Actions to produce
// complex commands like `gcloud app instances delete ...`.
SubCommands []string `json:"sub_commands"`
// FlexImage tells the plugin where to pull the image from when deploying a Flexible
// VM instance. Example value: 'gcr.io/nyt-games-dev/puzzles-sub:$COMMIT'
FlexImage string `json:"flex_image"`
// TemplateVars allows users to pass a set of key/values to be injected into the
// various yaml configuration files. To use, the keys in this map must be referenced
// in the yaml files with Go's templating syntax. For example, the key "ABC" would be
// referenced with {{ .ABC }}.
TemplateVars map[string]interface{} `json:"vars"`
// AppFile is the name of the app.yaml file to use for this deployment. This field
// is only required if your app.yaml file is not named 'app.yaml'. Sometimes it is
// helpful to have a different `app.yaml` file per project for different environment
// and autoscaling configurations.
AppFile string `json:"app_file"`
// MaxVersions is an optional value that can be used along with the "deploy" or
// "update" actions. If set to a non-zero value, the plugin will look up the versions
// of the deployed service and delete any older versions beyond the "max" value
// provided. If any of the "older" versions that should be deleted are actually
// serving traffic, they will not be deleted. This may result in the actual version
// count being higher than the max listed here.
MaxVersions int `json:"max_versions"`
// CronFile is the name of the cron.yaml file to use for this deployment. This field
// is only required if your cron.yaml file is not named 'cron.yaml' or if you
// want to use the `action: deploy` configuration to deploy a cron.yaml change.
CronFile string `json:"cron_file"`
// DispatchFile is the name of the dispatch.yaml file to use for this deployment. This field
// is only required if your dispatch.yaml file is not named 'dispatch.yaml' or if you
// want to use the `action: deploy` configuration to deploy a dispatch.yaml change.
DispatchFile string `json:"dispatch_file"`
// QueueFile is the name of the queue.yaml file to use for this deployment. This field
// is only required if your queue.yaml file is not named 'queue.yaml'.
// This field is deprecated and will no longer work come Oct 2019.
QueueFile string `json:"queue_file"`
// Dir points to the directory the application exists in. This is only required if
// you application is not in the base directory.
Dir string `json:"dir"`
// Project is required. It should be the Google Cloud Project to deploy to.
Project string `json:"project"`
// Token is required and should contain the JSON key of a service account associated
// with the Google Cloud project the user wishes to interact with.
Token string `json:"token"`
// GCloudCmd is an optional override for the location of the gcloud CLI tool. This
// may be useful if using a custom image.
GCloudCmd string `json:"gcloud_cmd"`
// AppCfgCmd is an optional override for the location of the App Engine appcfg.py
// tool. This may be useful if using a custom image.
// This field is deprecated and will no longer work come Oct 2019.
AppCfgCmd string `json:"appcfg_cmd"`
// Beta is used by the gcloud command suite. If set, `gcloud beta app` will be used.
Beta bool `json:"beta"`
}
func main() {
err := wrapMain()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func wrapMain() error {
if version == "" {
version = "x.x.x"
}
if rev == "" {
rev = "[unknown]"
}
fmt.Printf("Drone GAE Plugin %s built from %s\n", version, rev)
vargs := GAE{}
workspace := ""
// Check what drone version we're running on
if os.Getenv("DRONE_WORKSPACE") == "" { // 0.4
err := configFromStdin(&vargs, &workspace)
if err != nil {
return err
}
} else { // 0.5+
err := configFromEnv(&vargs, &workspace)
if err != nil {
return err
}
}
err := validateVargs(&vargs)
if err != nil {
return err
}
keyPath := "/tmp/gcloud.json"
// Trim whitespace, to forgive the vagaries of YAML parsing.
vargs.Token = strings.TrimSpace(vargs.Token)
// Write credentials to tmp file to be picked up by the 'gcloud' command.
// This is inside the ephemeral plugin container, not on the host.
err = ioutil.WriteFile(keyPath, []byte(vargs.Token), 0600)
if err != nil {
return fmt.Errorf("error writing token file: %s\n", err)
}
// Warn if the keyfile can't be deleted, but don't abort. We're almost
// certainly running inside an ephemeral container, so the file will be
// discarded when we're finished anyway.
defer func() {
err := os.Remove(keyPath)
if err != nil {
fmt.Printf("warning: error removing token file: %s\n", err)
}
}()
runner := NewEnviron(filepath.Join(workspace, vargs.Dir), os.Environ(),
os.Stdout, os.Stderr)
// setup gcloud with our service account so we can use it for an access token
err = runner.Run(vargs.GCloudCmd, "auth", "activate-service-account", "--key-file", keyPath)
if err != nil {
return fmt.Errorf("error: %s\n", err)
}
// if gcloud app cmd or group, run it
if gcloudCmds[vargs.Action] || gcloudGroups[vargs.Action] {
err = runGcloud(runner, workspace, vargs)
} else {
// otherwise, do appcfg.py command
err = runAppCfg(runner, workspace, vargs)
}
if err != nil {
return err
}
// check if MaxVersions is supplied + deploy action
if vargs.MaxVersions > 0 && (vargs.Action == "deploy" || vargs.Action == "update") {
return removeOldVersions(runner, workspace, vargs)
}
return nil
}
func configFromStdin(vargs *GAE, workspace *string) error {
// https://godoc.org/github.com/drone/drone-plugin-go/plugin
workspaceInfo := plugin.Workspace{}
plugin.Param("workspace", &workspaceInfo)
plugin.Param("vargs", vargs)
// Note this hangs if no cli args or input on STDIN
plugin.MustParse()
*workspace = workspaceInfo.Path
return nil
}
// GAE struct has different json for these, so use an intermediate for the new drone format
type dummyGAE struct {
AddlArgs map[string]string `json:"-"`
AEEnv map[string]string `json:"-"`
TemplateVars map[string]interface{} `json:"-"`
}
func configFromEnv(vargs *GAE, workspace *string) error {
// drone plugin input format du jour:
// https://0-8-0.docs.drone.io/plugin-overview/
// Strings
*workspace = os.Getenv("DRONE_WORKSPACE")
vargs.Action = os.Getenv("PLUGIN_ACTION")
vargs.Version = os.Getenv("PLUGIN_VERSION")
vargs.Service = os.Getenv("PLUGIN_SERVICE")
vargs.FlexImage = os.Getenv("PLUGIN_FLEX_IMAGE")
vargs.AppFile = os.Getenv("PLUGIN_APP_FILE")
vargs.MaxVersions, _ = strconv.Atoi(os.Getenv("PLUGIN_MAX_VERSIONS"))
vargs.CronFile = os.Getenv("PLUGIN_CRON_FILE")
vargs.DispatchFile = os.Getenv("PLUGIN_DISPATCH_FILE")
vargs.QueueFile = os.Getenv("PLUGIN_QUEUE_FILE")
vargs.Dir = os.Getenv("PLUGIN_DIR")
vargs.Project = os.Getenv("PLUGIN_PROJECT")
vargs.GCloudCmd = os.Getenv("PLUGIN_GCLOUD_CMD")
vargs.AppCfgCmd = os.Getenv("PLUGIN_APPCFG_CMD")
vargs.Beta = os.Getenv("PLUGIN_BETA") == "true"
vargs.Token = os.Getenv("PLUGIN_GAE_CREDENTIALS")
if vargs.Token == "" {
// falling back to old env variable for drone 0.x compatibility
// secrets are not prefixed
vargs.Token = os.Getenv("GAE_CREDENTIALS")
}
if decodedToken, err := base64.StdEncoding.DecodeString(vargs.Token); err == nil {
// if no error then the token is base64 encoded (or empty)
vargs.Token = string(decodedToken)
} else {
fmt.Println("gae_credentials is not base64 encoded: skipping decode")
}
// Maps
dummyVargs := dummyGAE{}
addlArgs := os.Getenv("PLUGIN_ADDL_ARGS")
if addlArgs != "" {
if err := json.Unmarshal([]byte(addlArgs), &dummyVargs.AddlArgs); err != nil {
return fmt.Errorf("could not parse param addl_args into a map[string]string")
}
vargs.AddlArgs = dummyVargs.AddlArgs
}
AEEnv := os.Getenv("PLUGIN_AE_ENVIRONMENT")
if AEEnv != "" {
if err := json.Unmarshal([]byte(AEEnv), &dummyVargs.AEEnv); err != nil {
return fmt.Errorf("could not parse param ae_environment into a map[string]string")
}
// expand any env vars in template variable values
for k, v := range dummyVargs.AEEnv {
if s := os.ExpandEnv(v); s != "" {
dummyVargs.AEEnv[k] = os.ExpandEnv(v)
}
}
vargs.AEEnv = dummyVargs.AEEnv
}
templateVars := os.Getenv("PLUGIN_VARS")
if templateVars != "" {
if err := json.Unmarshal([]byte(templateVars), &dummyVargs.TemplateVars); err != nil {
return fmt.Errorf("could not parse param vars into a map[string]interface{}")
}
// expand any env vars in template variable values
for k, v := range dummyVargs.TemplateVars {
if v, ok := v.(string); ok {
if s := os.ExpandEnv(v); s != "" {
dummyVargs.TemplateVars[k] = os.ExpandEnv(v)
}
}
}
vargs.TemplateVars = dummyVargs.TemplateVars
}
// Lists: pity the fool whose values include commas
vargs.AddlFlags = strings.Split(os.Getenv("PLUGIN_ADDL_FLAGS"), ",")
vargs.SubCommands = strings.Split(os.Getenv("PLUGIN_SUB_COMMANDS"), ",")
return nil
}
func validateVargs(vargs *GAE) error {
if vargs.Token == "" {
return fmt.Errorf("missing required credentials: GAE_CREDENTIALS or param token")
}
if vargs.Project == "" {
vargs.Project = getProjectFromToken(vargs.Token)
if vargs.Project == "" {
return fmt.Errorf("missing required project id: not found in credentials or param project")
}
}
if vargs.Action == "" {
return fmt.Errorf("missing required param: action")
}
if vargs.AppCfgCmd == "" {
vargs.AppCfgCmd = "/go_appengine/appcfg.py"
}
if vargs.GCloudCmd == "" {
vargs.GCloudCmd = "gcloud"
}
if vargs.Version != "" {
v := strings.ToLower(vargs.Version)
if len(v) > 63 {
v = v[:63]
}
re := regexp.MustCompile(`[^a-z\d-]`)
vargs.Version = re.ReplaceAllString(v, "-")
}
return nil
}
// Gcloud Groups
var gcloudGroups = map[string]bool{
"services": true,
"versions": true,
"instances": true,
}
// Gcloud Commands
var gcloudCmds = map[string]bool{
"deploy": true,
}
func runGcloud(runner *Environ, workspace string, vargs GAE) error {
var args []string
// if beta, add that command first so we get `gcloud beta ...`
if vargs.Beta {
args = append(args, "beta")
}
// add the app action (gcloud app X)
args = append(args, []string{
"app",
vargs.Action,
}...)
// Add subcommands to we can make complex calls like
// 'gcloud app services X Y Z ...'
for _, cmd := range vargs.SubCommands {
if len(cmd) > 0 {
args = append(args, cmd)
}
}
// hook in the appropriate yaml file unless the action is a group
// 'gcloud app services X Y Z' fails with the addition of a yaml file
if !gcloudGroups[vargs.Action] {
switch {
case vargs.DispatchFile != "":
args = append(args, "./dispatch.yaml")
case vargs.CronFile != "":
args = append(args, "./cron.yaml")
default:
args = append(args, "./app.yaml")
}
}
// add a version if we've got one
// (requires passing args differently based on whether it's a group or plain command)
if vargs.Version != "" {
if gcloudCmds[vargs.Action] {
args = append(args, "--version", vargs.Version)
} else {
// it's a gcloud command for a group, treat it differently
args = append(args, vargs.Version)
}
}
// add a service if we've got one
// (requires passing args differently based on whether it's a group or plain command)
if vargs.Service != "" {
if gcloudCmds[vargs.Action] {
args = append(args, "--service", vargs.Service)
} else {
// it's a gcloud command for a group, treat it differently
args = append(args, vargs.Service)
}
}
if vargs.FlexImage != "" {
args = append(args, "--image-url", vargs.FlexImage)
}
if len(vargs.Project) > 0 {
args = append(args, "--project", vargs.Project)
}
// add flag to prevent interactive
args = append(args, "--quiet")
// add the remaining arguments
if len(vargs.AddlArgs) > 0 {
for k, v := range vargs.AddlArgs {
args = append(args, k, v)
}
}
// add any additional singleton flags
if len(vargs.AddlFlags) > 0 {
for _, v := range vargs.AddlFlags {
if len(v) > 0 {
args = append(args, v)
}
}
}
if err := setupAppFile(workspace, vargs); err != nil {
return err
}
if err := setupCronFile(workspace, vargs); err != nil {
return err
}
if err := setupDispatchFile(workspace, vargs); err != nil {
return err
}
if err := setupQueueFile(workspace, vargs); err != nil {
return err
}
err := runner.Run(vargs.GCloudCmd, args...)
if err != nil {
return fmt.Errorf("error: %s\n", err)
}
return nil
}
func runAppCfg(runner *Environ, workspace string, vargs GAE) error {
// get access token string to pass along to `appcfg.py`
tokenCmd := exec.Command(vargs.GCloudCmd, "auth", "print-access-token")
var accessToken bytes.Buffer
tokenCmd.Stdout = &accessToken
err := tokenCmd.Run()
if err != nil {
return fmt.Errorf("error creating access token: %s\n", err)
}
// build initial args for appcfg command
args := []string{
"--oauth2_access_token", accessToken.String(),
"-A", vargs.Project,
}
// add a version if we've got one
if vargs.Version != "" {
args = append(args, "-V", vargs.Version)
}
// add any env variables
if len(vargs.AEEnv) > 0 {
for k, v := range vargs.AEEnv {
args = append(args, "-E", k+":"+v)
}
}
// add any additional variables
if len(vargs.AddlArgs) > 0 {
for k, v := range vargs.AddlArgs {
args = append(args, k, v)
}
}
// add action and current dir
args = append(args, vargs.Action, ".")
if err = setupAppFile(workspace, vargs); err != nil {
return err
}
if err = setupCronFile(workspace, vargs); err != nil {
return err
}
err = runner.Run(vargs.AppCfgCmd, args...)
if err != nil {
return fmt.Errorf("error: %s\n", err)
}
return nil
}
type token struct {
ProjectID string `json:"project_id"`
}
func getProjectFromToken(j string) string {
t := token{}
err := json.Unmarshal([]byte(j), &t)
if err != nil {
return ""
}
return t.ProjectID
}
// some app engine commands are weird and require the app file to be named
// 'app.yaml'. If an app file is given and it does not equal that, we need
// to copy it there
func setupAppFile(workspace string, vargs GAE) error {
return setupFile(workspace, vargs, "app.yaml", vargs.AppFile)
}
// Useful for differentiating between prd and dev cron versions for GCP appengine
func setupCronFile(workspace string, vargs GAE) error {
return setupFile(workspace, vargs, "cron.yaml", vargs.CronFile)
}
// Useful for differentiating between prd and dev dispatch versions for GCP appengine
func setupDispatchFile(workspace string, vargs GAE) error {
return setupFile(workspace, vargs, "dispatch.yaml", vargs.DispatchFile)
}
// Useful for differentiating between prd and dev queue versions for GCP appengine
func setupQueueFile(workspace string, vargs GAE) error {
return setupFile(workspace, vargs, "queue.yaml", vargs.QueueFile)
}
// setupFile is used to copy a user-supplied file to a GAE-expected file.
// gaeName is the file name that GAE uses (ex: app.yaml, cron.yaml, default.yaml)
// suppliedName is the name of the file that should be renamed (ex: stg-app.yaml)
// If any template variables are provided, the file will be parsed and executed as
// a text/template with the variables injected.
func setupFile(workspace string, vargs GAE, gaeName string, suppliedName string) error {
// if no file given, give up
if suppliedName == "" {
return nil
}
dest := filepath.Join(workspace, vargs.Dir, gaeName)
if suppliedName != gaeName {
orig := filepath.Join(workspace, vargs.Dir, suppliedName)
err := copyFile(dest, orig)
if err != nil {
return fmt.Errorf("error moving %q to %q: %s\n", suppliedName, gaeName, err)
}
}
// now that the file is in the right spot, we can inject any available TemplateVars.
blob, err := ioutil.ReadFile(dest)
if err != nil {
return fmt.Errorf("error reading template: %s\n", err)
}
tmpl, err := template.New(gaeName).Option("missingkey=error").Parse(string(blob))
if err != nil {
return fmt.Errorf("error parsing template: %s\n", err)
}
out, err := os.OpenFile(dest, os.O_TRUNC|os.O_RDWR, 0755)
if err != nil {
return fmt.Errorf("error opening template: %s\n", err)
}
defer out.Close()
err = tmpl.Execute(out, vargs.TemplateVars)
if err != nil {
return fmt.Errorf("error executing template: %s\n", err)
}
return nil
}
func copyFile(dst, src string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
tmp, err := ioutil.TempFile(filepath.Dir(dst), "")
if err != nil {
return err
}
_, err = io.Copy(tmp, in)
if err != nil {
tmp.Close()
os.Remove(tmp.Name())
return err
}
if err = tmp.Close(); err != nil {
os.Remove(tmp.Name())
return err
}
if err = os.Chmod(tmp.Name(), info.Mode()); err != nil {
os.Remove(tmp.Name())
return err
}
return os.Rename(tmp.Name(), dst)
}