diff --git a/autopilot.go b/autopilot.go index a53c98d..ad20f37 100644 --- a/autopilot.go +++ b/autopilot.go @@ -1,15 +1,20 @@ package main import ( + "context" "encoding/json" "errors" "flag" "fmt" + "log" "net/url" "os" "strings" + "time" + "code.cloudfoundry.org/cli/cf/api/logs" "code.cloudfoundry.org/cli/plugin" + "github.com/cloudfoundry/noaa/consumer" "github.com/contraband/autopilot/rewind" ) @@ -30,74 +35,119 @@ func venerableAppName(appName string) string { return fmt.Sprintf("%s-venerable", appName) } -func getActionsForExistingApp(appRepo *ApplicationRepo, appName, manifestPath, appPath string) []rewind.Action { +func getActionsForApp(appRepo *ApplicationRepo, appName, manifestPath, appPath string, showLogs bool) []rewind.Action { + venName := venerableAppName(appName) + var err error + var curApp, venApp *AppEntity + var haveVenToCleanup bool + return []rewind.Action{ - // rename + // get info about current app + { + Forward: func() error { + curApp, err = appRepo.GetAppMetadata(appName) + if err != ErrAppNotFound { + return err + } + curApp = nil + return nil + }, + }, + // get info about ven app { Forward: func() error { - return appRepo.RenameApplication(appName, venerableAppName(appName)) + venApp, err = appRepo.GetAppMetadata(venName) + if err != ErrAppNotFound { + return err + } + venApp = nil + return nil + }, + }, + // rename any existing app such so that next step can push to a clear space + { + Forward: func() error { + // Unless otherwise specified, go with our start state + haveVenToCleanup = (venApp != nil) + + // If there is no current app running, that's great, we're done here + if curApp == nil { + return nil + } + + // If current app isn't started, then we'll just delete it, and we're done + if curApp.State != "STARTED" { + return appRepo.DeleteApplication(appName) + } + + // Do we have a ven app that will stop a rename? + if venApp != nil { + // Finally, since the current app claims to be healthy, we'll delete the venerable app, and rename the current over the top + err = appRepo.DeleteApplication(venName) + if err != nil { + return err + } + } + + // Finally, rename + haveVenToCleanup = true + return appRepo.RenameApplication(appName, venName) }, }, // push { Forward: func() error { - return appRepo.PushApplication(appName, manifestPath, appPath) + return appRepo.PushApplication(appName, manifestPath, appPath, showLogs) }, ReversePrevious: func() error { + if !haveVenToCleanup { + return nil + } + // If the app cannot start we'll have a lingering application // We delete this application so that the rename can succeed appRepo.DeleteApplication(appName) - return appRepo.RenameApplication(venerableAppName(appName), appName) + return appRepo.RenameApplication(venName, appName) }, }, // delete { Forward: func() error { - return appRepo.DeleteApplication(venerableAppName(appName)) + if !haveVenToCleanup { + return nil + } + return appRepo.DeleteApplication(venName) }, }, } } -func getActionsForNewApp(appRepo *ApplicationRepo, appName, manifestPath, appPath string) []rewind.Action { +func getActionsForNewApp(appRepo *ApplicationRepo, appName, manifestPath, appPath string, showLogs bool) []rewind.Action { return []rewind.Action{ // push { Forward: func() error { - return appRepo.PushApplication(appName, manifestPath, appPath) + return appRepo.PushApplication(appName, manifestPath, appPath, showLogs) }, }, } } func (plugin AutopilotPlugin) Run(cliConnection plugin.CliConnection, args []string) { - if len(args) > 0 && args[0] == "CLI-MESSAGE-UNINSTALL" { + // only handle if actually invoked, else it can't be uninstalled cleanly + if args[0] != "zero-downtime-push" { return } appRepo := NewApplicationRepo(cliConnection) - appName, manifestPath, appPath, err := ParseArgs(args) + appName, manifestPath, appPath, showLogs, err := ParseArgs(args) fatalIf(err) - appExists, err := appRepo.DoesAppExist(appName) - fatalIf(err) - - var actionList []rewind.Action - - if appExists { - actionList = getActionsForExistingApp(appRepo, appName, manifestPath, appPath) - } else { - actionList = getActionsForNewApp(appRepo, appName, manifestPath, appPath) - } - - actions := rewind.Actions{ - Actions: actionList, + fatalIf((&rewind.Actions{ + Actions: getActionsForApp(appRepo, appName, manifestPath, appPath, showLogs), RewindFailureMessage: "Oh no. Something's gone wrong. I've tried to roll back but you should check to see if everything is OK.", - } - - err = actions.Execute() - fatalIf(err) + }).Execute()) fmt.Println() fmt.Println("A new version of your application has successfully been pushed!") @@ -126,26 +176,33 @@ func (AutopilotPlugin) GetMetadata() plugin.PluginMetadata { } } -func ParseArgs(args []string) (string, string, string, error) { +func ParseArgs(args []string) (string, string, string, bool, error) { flags := flag.NewFlagSet("zero-downtime-push", flag.ContinueOnError) manifestPath := flags.String("f", "", "path to an application manifest") appPath := flags.String("p", "", "path to application files") + showLogs := flags.Bool("show-app-log", false, "tail and show application log during application start") + if len(args) < 2 || strings.HasPrefix(args[1], "-") { + return "", "", "", false, ErrNoArgs + } err := flags.Parse(args[2:]) if err != nil { - return "", "", "", err + return "", "", "", false, err } appName := args[1] if *manifestPath == "" { - return "", "", "", ErrNoManifest + return "", "", "", false, ErrNoManifest } - return appName, *manifestPath, *appPath, nil + return appName, *manifestPath, *appPath, *showLogs, nil } -var ErrNoManifest = errors.New("a manifest is required to push this application") +var ( + ErrNoArgs = errors.New("app name must be specified") + ErrNoManifest = errors.New("a manifest is required to push this application") +) type ApplicationRepo struct { conn plugin.CliConnection @@ -162,15 +219,61 @@ func (repo *ApplicationRepo) RenameApplication(oldName, newName string) error { return err } -func (repo *ApplicationRepo) PushApplication(appName, manifestPath, appPath string) error { - args := []string{"push", appName, "-f", manifestPath} +func (repo *ApplicationRepo) PushApplication(appName, manifestPath, appPath string, showLogs bool) error { + args := []string{"push", appName, "-f", manifestPath, "--no-start"} if appPath != "" { args = append(args, "-p", appPath) } _, err := repo.conn.CliCommand(args...) - return err + if err != nil { + return err + } + + if showLogs { + app, err := repo.conn.GetApp(appName) + if err != nil { + return err + } + dopplerEndpoint, err := repo.conn.DopplerEndpoint() + if err != nil { + return err + } + token, err := repo.conn.AccessToken() + if err != nil { + return err + } + + cons := consumer.New(dopplerEndpoint, nil, nil) + defer cons.Close() + + messages, errors := cons.TailingLogs(app.Guid, token) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + for { + select { + case m := <-messages: + if m.GetSourceType() != "STG" { // skip STG messages as the cf tool already prints them + os.Stderr.WriteString(logs.NewNoaaLogMessage(m).ToLog(time.Local) + "\n") + } + case e := <-errors: + log.Println("error reading logs:", e) + case <-ctx.Done(): + return + } + } + }() + } + + _, err = repo.conn.CliCommand("start", appName) + if err != nil { + return err + } + + return nil } func (repo *ApplicationRepo) DeleteApplication(appName string) error { @@ -183,39 +286,44 @@ func (repo *ApplicationRepo) ListApplications() error { return err } -func (repo *ApplicationRepo) DoesAppExist(appName string) (bool, error) { +type AppEntity struct { + State string `json:"state"` +} + +var ( + ErrAppNotFound = errors.New("application not found") +) + +// GetAppMetadata returns metadata about an app with appName +func (repo *ApplicationRepo) GetAppMetadata(appName string) (*AppEntity, error) { space, err := repo.conn.GetCurrentSpace() if err != nil { - return false, err + return nil, err } path := fmt.Sprintf(`v2/apps?q=name:%s&q=space_guid:%s`, url.QueryEscape(appName), space.Guid) result, err := repo.conn.CliCommandWithoutTerminalOutput("curl", path) if err != nil { - return false, err + return nil, err } jsonResp := strings.Join(result, "") - output := make(map[string]interface{}) + output := struct { + Resources []struct { + Entity AppEntity `json:"entity"` + } `json:"resources"` + }{} err = json.Unmarshal([]byte(jsonResp), &output) if err != nil { - return false, err + return nil, err } - totalResults, ok := output["total_results"] - - if !ok { - return false, errors.New("Missing total_results from api response") - } - - count, ok := totalResults.(float64) - - if !ok { - return false, fmt.Errorf("total_results didn't have a number %v", totalResults) + if len(output.Resources) == 0 { + return nil, ErrAppNotFound } - return count == 1, nil + return &output.Resources[0].Entity, nil } diff --git a/autopilot_test.go b/autopilot_test.go index d1c9f76..4c94007 100644 --- a/autopilot_test.go +++ b/autopilot_test.go @@ -21,7 +21,7 @@ func TestAutopilot(t *testing.T) { var _ = Describe("Flag Parsing", func() { It("parses a complete set of args", func() { - appName, manifestPath, appPath, err := ParseArgs( + appName, manifestPath, appPath, showLogs, err := ParseArgs( []string{ "zero-downtime-push", "appname", @@ -34,10 +34,11 @@ var _ = Describe("Flag Parsing", func() { Expect(appName).To(Equal("appname")) Expect(manifestPath).To(Equal("manifest-path")) Expect(appPath).To(Equal("app-path")) + Expect(showLogs).To(Equal(false)) }) It("requires a manifest", func() { - _, _, _, err := ParseArgs( + _, _, _, _, err := ParseArgs( []string{ "zero-downtime-push", "appname", @@ -77,11 +78,11 @@ var _ = Describe("ApplicationRepo", func() { }) }) - Describe("DoesAppExist", func() { + Describe("GetAppMetadata", func() { It("returns an error if the cli returns an error", func() { cliConn.CliCommandWithoutTerminalOutputReturns([]string{}, errors.New("you shall not curl")) - _, err := repo.DoesAppExist("app-name") + _, err := repo.GetAppMetadata("app-name") Expect(err).To(MatchError("you shall not curl")) }) @@ -92,36 +93,14 @@ var _ = Describe("ApplicationRepo", func() { } cliConn.CliCommandWithoutTerminalOutputReturns(response, nil) - _, err := repo.DoesAppExist("app-name") + _, err := repo.GetAppMetadata("app-name") Expect(err).To(HaveOccurred()) }) - It("returns an error if the cli response doesn't contain total_results", func() { + It("returns app data if the app exists", func() { response := []string{ - `{"brutal_results":2}`, - } - - cliConn.CliCommandWithoutTerminalOutputReturns(response, nil) - _, err := repo.DoesAppExist("app-name") - - Expect(err).To(MatchError("Missing total_results from api response")) - }) - - It("returns an error if the cli response contains a non-number total_results", func() { - response := []string{ - `{"total_results":"sandwich"}`, - } - - cliConn.CliCommandWithoutTerminalOutputReturns(response, nil) - _, err := repo.DoesAppExist("app-name") - - Expect(err).To(MatchError("total_results didn't have a number sandwich")) - }) - - It("returns true if the app exists", func() { - response := []string{ - `{"total_results":1}`, + `{"resources":[{"entity":{"state":"STARTED"}}]}`, } spaceGUID := "4" @@ -135,19 +114,19 @@ var _ = Describe("ApplicationRepo", func() { nil, ) - result, err := repo.DoesAppExist("app-name") + result, err := repo.GetAppMetadata("app-name") Expect(cliConn.CliCommandWithoutTerminalOutputCallCount()).To(Equal(1)) args := cliConn.CliCommandWithoutTerminalOutputArgsForCall(0) Expect(args).To(Equal([]string{"curl", "v2/apps?q=name:app-name&q=space_guid:4"})) Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeTrue()) + Expect(result).ToNot(BeNil()) }) It("URL encodes the application name", func() { response := []string{ - `{"total_results":1}`, + `{"resources":[{"entity":{"state":"STARTED"}}]}`, } spaceGUID := "4" @@ -161,62 +140,64 @@ var _ = Describe("ApplicationRepo", func() { nil, ) - result, err := repo.DoesAppExist("app name") + result, err := repo.GetAppMetadata("app name") Expect(cliConn.CliCommandWithoutTerminalOutputCallCount()).To(Equal(1)) args := cliConn.CliCommandWithoutTerminalOutputArgsForCall(0) Expect(args).To(Equal([]string{"curl", "v2/apps?q=name:app+name&q=space_guid:4"})) Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeTrue()) + Expect(result).ToNot(BeNil()) }) - It("returns false if the app does not exist", func() { + It("returns nil if the app does not exist", func() { response := []string{ - `{"total_results":0}`, + `{"resources":[]}`, } cliConn.CliCommandWithoutTerminalOutputReturns(response, nil) - result, err := repo.DoesAppExist("app-name") + result, err := repo.GetAppMetadata("app-name") - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeFalse()) + Expect(err).To(Equal(ErrAppNotFound)) + Expect(result).To(BeNil()) }) }) Describe("PushApplication", func() { It("pushes an application with both a manifest and a path", func() { - err := repo.PushApplication("appName", "/path/to/a/manifest.yml", "/path/to/the/app") + err := repo.PushApplication("appName", "/path/to/a/manifest.yml", "/path/to/the/app", false) Expect(err).ToNot(HaveOccurred()) - Expect(cliConn.CliCommandCallCount()).To(Equal(1)) + Expect(cliConn.CliCommandCallCount()).To(Equal(2)) args := cliConn.CliCommandArgsForCall(0) Expect(args).To(Equal([]string{ "push", "appName", "-f", "/path/to/a/manifest.yml", + "--no-start", "-p", "/path/to/the/app", })) }) It("pushes an application with only a manifest", func() { - err := repo.PushApplication("appName", "/path/to/a/manifest.yml", "") + err := repo.PushApplication("appName", "/path/to/a/manifest.yml", "", false) Expect(err).ToNot(HaveOccurred()) - Expect(cliConn.CliCommandCallCount()).To(Equal(1)) + Expect(cliConn.CliCommandCallCount()).To(Equal(2)) args := cliConn.CliCommandArgsForCall(0) Expect(args).To(Equal([]string{ "push", "appName", "-f", "/path/to/a/manifest.yml", + "--no-start", })) }) It("returns errors from the push", func() { cliConn.CliCommandReturns([]string{}, errors.New("bad app")) - err := repo.PushApplication("appName", "/path/to/a/manifest.yml", "/path/to/the/app") + err := repo.PushApplication("appName", "/path/to/a/manifest.yml", "/path/to/the/app", false) Expect(err).To(MatchError("bad app")) }) })