From 99426b7a88e3822e577cdf9f0d5f0d07f01d6227 Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Thu, 1 Jun 2017 11:00:17 -0700 Subject: [PATCH 1/2] Application maintenance mode --- apps.go | 3 ++ certs.go | 2 +- cmd/emp/info.go | 3 +- cmd/emp/main.go | 3 ++ cmd/emp/maintenance.go | 94 +++++++++++++++++++++++++++++++++++ cmd/emp/rename.go | 3 +- empire.go | 63 +++++++++++++++++++++++ events.go | 26 ++++++++++ events_test.go | 5 ++ migrations.go | 17 +++++++ migrations_test.go | 2 +- pkg/heroku/app.go | 5 +- pkg/heroku/app_test.go | 2 +- releases.go | 24 ++++++++- server/heroku/apps.go | 27 ++++++++-- tests/api/apps_test.go | 2 +- tests/cli/apps_test.go | 2 +- tests/cli/maintenance_test.go | 53 ++++++++++++++++++++ 18 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 cmd/emp/maintenance.go create mode 100644 tests/cli/maintenance_test.go diff --git a/apps.go b/apps.go index 455e3a001..a4630992c 100644 --- a/apps.go +++ b/apps.go @@ -85,6 +85,9 @@ type App struct { // The time that this application was created. CreatedAt *time.Time + + // Maintenance defines whether the app is in maintenance mode or not. + Maintenance bool } // IsValid returns an error if the app isn't valid. diff --git a/certs.go b/certs.go index 032306007..347954637 100644 --- a/certs.go +++ b/certs.go @@ -26,7 +26,7 @@ func (s *certsService) CertsAttach(ctx context.Context, db *gorm.DB, opts CertsA return err } - if err := s.releases.Restart(ctx, db, app); err != nil { + if err := s.releases.ReleaseApp(ctx, db, app, nil); err != nil { if err == ErrNoReleases { return nil } diff --git a/cmd/emp/info.go b/cmd/emp/info.go index 1f506fdde..7868aaf1f 100644 --- a/cmd/emp/info.go +++ b/cmd/emp/info.go @@ -18,6 +18,7 @@ func runInfo(cmd *Command, args []string) { app, err := client.AppInfo(mustApp()) must(err) fmt.Printf("Name: %s\n", app.Name) - fmt.Printf("ID: %s\n", app.Id) + fmt.Printf("ID: %s\n", app.Id) + fmt.Printf("Maintenance: %s\n", fmtMaintenance(app.Maintenance)) fmt.Printf("Cert: %s\n", app.Cert) } diff --git a/cmd/emp/main.go b/cmd/emp/main.go index 98b84c33d..5ef141129 100644 --- a/cmd/emp/main.go +++ b/cmd/emp/main.go @@ -150,6 +150,9 @@ var commands = []*Command{ cmdGet, cmdLogin, cmdLogout, + cmdMaintenance, + cmdMaintenanceEnable, + cmdMaintenanceDisable, cmdSSL, cmdSSLCertAdd, cmdSSLCertRollback, diff --git a/cmd/emp/maintenance.go b/cmd/emp/maintenance.go new file mode 100644 index 000000000..1dea2f0d5 --- /dev/null +++ b/cmd/emp/maintenance.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/remind101/empire/pkg/heroku" +) + +var cmdMaintenance = &Command{ + Run: runMaintenance, + Usage: "maintenance", + NeedsApp: true, + Category: "app", + Short: "show app maintenance mode" + extra, + Long: ` +Maintenance shows the current maintenance mode state of an app. +Example: + $ emp maintenance -a + enabled +`, +} + +func runMaintenance(cmd *Command, args []string) { + if len(args) != 0 { + cmd.PrintUsage() + os.Exit(2) + } + app, err := client.AppInfo(mustApp()) + must(err) + fmt.Println(fmtMaintenance(app.Maintenance)) +} + +var cmdMaintenanceEnable = &Command{ + Run: maybeMessage(runMaintenanceEnable), + Usage: "maintenance-enable", + NeedsApp: true, + Category: "app", + Short: "enable maintenance mode" + extra, + Long: ` +Enables maintenance mode on an app. +Example: + $ emp maintenance-enable -a + Enabled maintenance mode on myapp. +`, +} + +func runMaintenanceEnable(cmd *Command, args []string) { + message := getMessage() + if len(args) != 0 { + cmd.PrintUsage() + os.Exit(2) + } + newmode := true + app, err := client.AppUpdate(mustApp(), &heroku.AppUpdateOpts{Maintenance: &newmode}, message) + must(err) + log.Printf("Enabled maintenance mode on %s.", app.Name) +} + +var cmdMaintenanceDisable = &Command{ + Run: maybeMessage(runMaintenanceDisable), + Usage: "maintenance-disable", + NeedsApp: true, + Category: "app", + Short: "disable maintenance mode" + extra, + Long: ` +Disables maintenance mode on an app. +Example: + $ emp maintenance-disable -a + Disabled maintenance mode on myapp. +`, +} + +func runMaintenanceDisable(cmd *Command, args []string) { + message := getMessage() + if len(args) != 0 { + cmd.PrintUsage() + os.Exit(2) + } + newmode := false + app, err := client.AppUpdate(mustApp(), &heroku.AppUpdateOpts{Maintenance: &newmode}, message) + must(err) + log.Printf("Disabled maintenance mode on %s.", app.Name) +} + +type fmtMaintenance bool + +func (f fmtMaintenance) String() string { + if f { + return "enabled" + } + return "disabled" +} diff --git a/cmd/emp/rename.go b/cmd/emp/rename.go index 6f83cbbdd..571d3d1a0 100644 --- a/cmd/emp/rename.go +++ b/cmd/emp/rename.go @@ -22,10 +22,11 @@ Example: } func runRename(cmd *Command, args []string) { + message := getMessage() cmd.AssertNumArgsCorrect(args) oldname, newname := args[0], args[1] - app, err := client.AppUpdate(oldname, &heroku.AppUpdateOpts{Name: &newname}) + app, err := client.AppUpdate(oldname, &heroku.AppUpdateOpts{Name: &newname}, message) must(err) log.Printf("Renamed %s to %s.", oldname, app.Name) log.Println("Ensure you update your git remote URL.") diff --git a/empire.go b/empire.go index d55723a29..e0da69765 100644 --- a/empire.go +++ b/empire.go @@ -250,6 +250,69 @@ func (e *Empire) Config(app *App) (*Config, error) { return c, nil } +type SetMaintenanceModeOpts struct { + // User performing the action. + User *User + + // The associated app. + App *App + + // Wheather maintenance mode should be enabled or not. + Maintenance bool + + // Commit message + Message string +} + +func (opts SetMaintenanceModeOpts) Event() MaintenanceEvent { + return MaintenanceEvent{ + User: opts.User.Name, + App: opts.App.Name, + Maintenance: opts.Maintenance, + Message: opts.Message, + } +} + +func (opts SetMaintenanceModeOpts) Validate(e *Empire) error { + return e.requireMessages(opts.Message) +} + +// SetMaintenanceMode enables or disables "maintenance mode" on the app. When an +// app is in maintenance mode, all processes will be scaled down to 0. When +// taken out of maintenance mode, all processes will be scaled up back to their +// existing values. +func (e *Empire) SetMaintenanceMode(ctx context.Context, opts SetMaintenanceModeOpts) error { + if err := opts.Validate(e); err != nil { + return err + } + + tx := e.db.Begin() + + app := opts.App + + app.Maintenance = opts.Maintenance + + if err := appsUpdate(tx, app); err != nil { + tx.Rollback() + return err + } + + if err := e.releases.ReleaseApp(ctx, tx, app, nil); err != nil { + tx.Rollback() + if err == ErrNoReleases { + return nil + } + + return err + } + + if err := tx.Commit().Error; err != nil { + return err + } + + return e.PublishEvent(opts.Event()) +} + // SetOpts are options provided when setting new config vars on an app. type SetOpts struct { // User performing the action. diff --git a/events.go b/events.go index 00828cf34..511f5b31b 100644 --- a/events.go +++ b/events.go @@ -86,6 +86,32 @@ func (e RestartEvent) GetApp() *App { return e.app } +type MaintenanceEvent struct { + User string + App string + Maintenance bool + Message string + + app *App +} + +func (e MaintenanceEvent) Event() string { + return "maintenance" +} + +func (e MaintenanceEvent) String() string { + state := "disabled" + if e.Maintenance { + state = "enabled" + } + msg := fmt.Sprintf("%s %s maintenance mode on %s", e.User, state, e.App) + return appendCommitMessage(msg, e.Message) +} + +func (e MaintenanceEvent) GetApp() *App { + return e.app +} + type ScaleEventUpdate struct { Process string Quantity int diff --git a/events_test.go b/events_test.go index b10a9dcec..409ef5e2a 100644 --- a/events_test.go +++ b/events_test.go @@ -26,6 +26,11 @@ func TestEvents_String(t *testing.T) { {RestartEvent{User: "ejholmes", App: "acme-inc", Message: "commit message"}, "ejholmes restarted acme-inc: 'commit message'"}, {RestartEvent{User: "ejholmes", App: "acme-inc", PID: "abcd", Message: "commit message"}, "ejholmes restarted `abcd` on acme-inc: 'commit message'"}, + // MaintenanceEvent + {MaintenanceEvent{User: "ejholmes", App: "acme-inc", Maintenance: false}, "ejholmes disabled maintenance mode on acme-inc"}, + {MaintenanceEvent{User: "ejholmes", App: "acme-inc", Maintenance: true}, "ejholmes enabled maintenance mode on acme-inc"}, + {MaintenanceEvent{User: "ejholmes", App: "acme-inc", Maintenance: true, Message: "upgrading db"}, "ejholmes enabled maintenance mode on acme-inc: 'upgrading db'"}, + // ScaleEvent {ScaleEvent{ User: "ejholmes", diff --git a/migrations.go b/migrations.go index 686b2728a..6ee153f2b 100644 --- a/migrations.go +++ b/migrations.go @@ -612,6 +612,23 @@ ALTER TABLE apps ADD COLUMN exposure TEXT NOT NULL default 'private'`, `ALTER TABLE apps ADD COLUMN cert text`, }), }, + + // This migration migrates the cert storage from a single string column + // to a mapping of process name to cert name. + { + ID: 20, + Up: func(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE apps ADD COLUMN maintenance bool NOT NULL DEFAULT false`) + if err != nil { + return fmt.Errorf("error adding maintenance column: %v", err) + } + + return nil + }, + Down: migrate.Queries([]string{ + `ALTER TABLE apps DROP COLUMN maintenance`, + }), + }, } // latestSchema returns the schema version that this version of Empire should be diff --git a/migrations_test.go b/migrations_test.go index 1214f40c0..8fcd12ad0 100644 --- a/migrations_test.go +++ b/migrations_test.go @@ -30,7 +30,7 @@ func TestMigrations(t *testing.T) { } func TestLatestSchema(t *testing.T) { - assert.Equal(t, 19, latestSchema()) + assert.Equal(t, 20, latestSchema()) } func TestNoDuplicateMigrations(t *testing.T) { diff --git a/pkg/heroku/app.go b/pkg/heroku/app.go index 153825847..707974a18 100644 --- a/pkg/heroku/app.go +++ b/pkg/heroku/app.go @@ -128,9 +128,10 @@ func (c *Client) AppList(lr *ListRange) ([]App, error) { // // appIdentity is the unique identifier of the App. options is the struct of // optional parameters for this action. -func (c *Client) AppUpdate(appIdentity string, options *AppUpdateOpts) (*App, error) { +func (c *Client) AppUpdate(appIdentity string, options *AppUpdateOpts, message string) (*App, error) { + rh := RequestHeaders{CommitMessage: message} var appRes App - return &appRes, c.Patch(&appRes, "/apps/"+appIdentity, options) + return &appRes, c.PatchWithHeaders(&appRes, "/apps/"+appIdentity, options, rh.Headers()) } // AppUpdateOpts holds the optional parameters for AppUpdate diff --git a/pkg/heroku/app_test.go b/pkg/heroku/app_test.go index 0df124833..b27a0a31b 100644 --- a/pkg/heroku/app_test.go +++ b/pkg/heroku/app_test.go @@ -280,7 +280,7 @@ func TestAppUpdateSuccess(t *testing.T) { ts, handler, c := newTestServerAndClient(t, appUpdateRequest) defer ts.Close() - app, err := c.AppUpdate("example", &appUpdateRequestOptions) + app, err := c.AppUpdate("example", &appUpdateRequestOptions, "message") if err != nil { t.Fatal(err) } diff --git a/releases.go b/releases.go index bc3228642..845044f0b 100644 --- a/releases.go +++ b/releases.go @@ -168,6 +168,23 @@ func (s *releasesService) Release(ctx context.Context, release *Release, ss twel return s.Scheduler.Submit(ctx, a, ss) } +func (s *releasesService) ReleaseApp(ctx context.Context, db *gorm.DB, app *App, ss twelvefactor.StatusStream) error { + release, err := releasesFind(db, ReleasesQuery{App: app}) + if err != nil { + if err == gorm.RecordNotFound { + return ErrNoReleases + } + + return err + } + + if release == nil { + return nil + } + + return s.Release(ctx, release, ss) +} + // Restart will find the last release for an app and submit it to the scheduler // to restart the app. func (s *releasesService) Restart(ctx context.Context, db *gorm.DB, app *App) error { @@ -361,13 +378,18 @@ func newSchedulerProcess(release *Release, name string, p Process) (*twelvefacto } } + quantity := p.Quantity + if release.App.Maintenance { + quantity = 0 + } + return &twelvefactor.Process{ Type: name, Env: env, Labels: labels, Command: []string(p.Command), Image: release.Slug.Image, - Quantity: p.Quantity, + Quantity: quantity, Memory: uint(p.Memory), CPUShares: uint(p.CPUShare), Nproc: uint(p.Nproc), diff --git a/server/heroku/apps.go b/server/heroku/apps.go index 14e02f0c9..6a2abd0ed 100644 --- a/server/heroku/apps.go +++ b/server/heroku/apps.go @@ -15,11 +15,12 @@ type App heroku.App func newApp(a *empire.App) *App { return &App{ - Id: a.ID, - Name: a.Name, - CreatedAt: *a.CreatedAt, - Cert: a.Certs["web"], // For backwards compatibility. - Certs: a.Certs, + Id: a.ID, + Name: a.Name, + Maintenance: a.Maintenance, + CreatedAt: *a.CreatedAt, + Cert: a.Certs["web"], // For backwards compatibility. + Certs: a.Certs, } } @@ -131,6 +132,11 @@ func (h *Server) PatchApp(ctx context.Context, w http.ResponseWriter, r *http.Re return err } + m, err := findMessage(r) + if err != nil { + return err + } + // DEPRECATED: For backwards compatibility with older emp clients. if form.Cert != nil { if err := h.CertsAttach(ctx, empire.CertsAttachOpts{ @@ -141,6 +147,17 @@ func (h *Server) PatchApp(ctx context.Context, w http.ResponseWriter, r *http.Re } } + if form.Maintenance != nil { + if err := h.SetMaintenanceMode(ctx, empire.SetMaintenanceModeOpts{ + User: auth.UserFromContext(ctx), + App: a, + Maintenance: *form.Maintenance, + Message: m, + }); err != nil { + return err + } + } + return Encode(w, newApp(a)) } diff --git a/tests/api/apps_test.go b/tests/api/apps_test.go index 3dc9f4d19..024f6e26c 100644 --- a/tests/api/apps_test.go +++ b/tests/api/apps_test.go @@ -35,7 +35,7 @@ func TestAttachCert(t *testing.T) { cert := "serverCertificate" app, err := c.AppUpdate(appName, &heroku.AppUpdateOpts{ Cert: &cert, - }) + }, "Attaching cert") if err != nil { t.Fatal(err) } diff --git a/tests/cli/apps_test.go b/tests/cli/apps_test.go index 8c134fc50..31141e417 100644 --- a/tests/cli/apps_test.go +++ b/tests/cli/apps_test.go @@ -39,7 +39,7 @@ func TestAppInfo(t *testing.T) { }, { "info -a acme-inc", - regexp.MustCompile("Name: acme-inc\nID: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\n"), + regexp.MustCompile("Name: acme-inc\nID: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\n"), }, }) } diff --git a/tests/cli/maintenance_test.go b/tests/cli/maintenance_test.go new file mode 100644 index 000000000..948e692c9 --- /dev/null +++ b/tests/cli/maintenance_test.go @@ -0,0 +1,53 @@ +package cli_test + +import ( + "testing" + "time" +) + +func TestMaintenance(t *testing.T) { + now(time.Now().AddDate(0, 0, -5)) + defer resetNow() + + run(t, []Command{ + DeployCommand("latest", "v1"), + { + "scale web=2 -a acme-inc", + "Scaled acme-inc to web=2:1X.", + }, + { + "scale -l -a acme-inc", + "rake=0:1X scheduled=0:1X web=2:1X worker=0:1X", + }, + { + "ps -a acme-inc", + `v1.web.1 i-aa111aa1 1X running 5d "./bin/web" +v1.web.2 i-aa111aa1 1X running 5d "./bin/web"`, + }, + { + "maintenance-enable -a acme-inc", + "Enabled maintenance mode on acme-inc.", + }, + { + "ps -a acme-inc", + ``, + }, + { + "scale -l -a acme-inc", + "rake=0:1X scheduled=0:1X web=2:1X worker=0:1X", + }, + { + "maintenance -a acme-inc", + "enabled", + }, + { + "maintenance-disable -a acme-inc", + "Disabled maintenance mode on acme-inc.", + }, + { + "ps -a acme-inc", + `v1.web.1 i-aa111aa1 1X running 5d "./bin/web" +v1.web.2 i-aa111aa1 1X running 5d "./bin/web"`, + }, + }) +} From 979174e5c2f7cc5795bea2e11a9ff5f7dabcdc0c Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 9 Jun 2017 10:06:45 -0700 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d900c828c..b79c45d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ **Features** * Empire now supports a new (experimental) feature to enable attached processes to be ran with ECS. [#1043](https://github.com/remind101/empire/pull/1043) +* Empire now supports "maintenance mode" for applications. [#1086](https://github.com/remind101/empire/pull/1086) **Bugs**