From 79180bbaa91f7074fed06633b12f813056a03ee3 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Mon, 19 Oct 2020 19:45:07 -0500 Subject: [PATCH] Add coder envs rebuild and watch-build commands --- coder-sdk/env.go | 66 ++++++++++++++++++ go.mod | 7 +- go.sum | 11 +-- internal/cmd/envs.go | 2 + internal/cmd/rebuild.go | 146 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 internal/cmd/rebuild.go diff --git a/coder-sdk/env.go b/coder-sdk/env.go index 8fd42838..6bc2f2ba 100644 --- a/coder-sdk/env.go +++ b/coder-sdk/env.go @@ -122,6 +122,29 @@ func (c Client) StopEnvironment(ctx context.Context, envID string) error { return c.requestBody(ctx, http.MethodPut, "/api/environments/"+envID+"/stop", nil, nil) } +// UpdateEnvironmentReq defines the update operation, only setting +// nil-fields. +type UpdateEnvironmentReq struct { + ImageID *string `json:"image_id"` + ImageTag *string `json:"image_tag"` + CPUCores *float32 `json:"cpu_cores"` + MemoryGB *float32 `json:"memory_gb"` + DiskGB *int `json:"disk_gb"` + GPUs *int `json:"gpus"` + Services *[]string `json:"services"` + CodeServerReleaseURL *string `json:"code_server_release_url"` +} + +// RebuildEnvironment requests that the given envID is rebuilt with no changes to its specification. +func (c Client) RebuildEnvironment(ctx context.Context, envID string) error { + return c.requestBody(ctx, http.MethodPatch, "/api/environments/"+envID, UpdateEnvironmentReq{}, nil) +} + +// EditEnvironment modifies the environment specification and initiates a rebuild. +func (c Client) EditEnvironment(ctx context.Context, envID string, req UpdateEnvironmentReq) error { + return c.requestBody(ctx, http.MethodPatch, "/api/environments/"+envID, req, nil) +} + // DialWsep dials an environments command execution interface // See https://github.com/cdr/wsep for details. func (c Client) DialWsep(ctx context.Context, env *Environment) (*websocket.Conn, error) { @@ -138,6 +161,49 @@ func (c Client) DialEnvironmentBuildLog(ctx context.Context, envID string) (*web return c.dialWebsocket(ctx, "/api/environments/"+envID+"/watch-update") } +// BuildLog defines a build log record for a Coder environment. +type BuildLog struct { + ID string `db:"id" json:"id"` + EnvironmentID string `db:"environment_id" json:"environment_id"` + // BuildID allows the frontend to separate the logs from the old build with the logs from the new. + BuildID string `db:"build_id" json:"build_id"` + Time time.Time `db:"time" json:"time"` + Type BuildLogType `db:"type" json:"type"` + Msg string `db:"msg" json:"msg"` +} + +// BuildLogFollowMsg wraps the base BuildLog and adds a field for collecting +// errors that may occur when follow or parsing. +type BuildLogFollowMsg struct { + BuildLog + Err error +} + +// FollowEnvironmentBuildLog trails the build log of a Coder environment. +func (c Client) FollowEnvironmentBuildLog(ctx context.Context, envID string) (<-chan BuildLogFollowMsg, error) { + ch := make(chan BuildLogFollowMsg) + ws, err := c.DialEnvironmentBuildLog(ctx, envID) + if err != nil { + return nil, err + } + go func() { + defer ws.Close(websocket.StatusNormalClosure, "normal closure") + defer close(ch) + for { + var msg BuildLog + if err := wsjson.Read(ctx, ws, &msg); err != nil { + ch <- BuildLogFollowMsg{Err: err} + if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) { + return + } + continue + } + ch <- BuildLogFollowMsg{BuildLog: msg} + } + }() + return ch, nil +} + // DialEnvironmentStats opens a websocket connection for environment stats. func (c Client) DialEnvironmentStats(ctx context.Context, envID string) (*websocket.Conn, error) { return c.dialWebsocket(ctx, "/api/environments/"+envID+"/watch-stats") diff --git a/go.mod b/go.mod index 871e8e2c..6b75ad80 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,13 @@ go 1.14 require ( cdr.dev/slog v1.3.0 cdr.dev/wsep v0.0.0-20200728013649-82316a09813f - github.com/fatih/color v1.9.0 // indirect + github.com/briandowns/spinner v1.11.1 + github.com/fatih/color v1.9.0 github.com/gorilla/websocket v1.4.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/klauspost/compress v1.10.8 // indirect github.com/manifoldco/promptui v0.7.0 - github.com/mattn/go-colorable v0.1.6 // indirect + github.com/mattn/go-colorable v0.1.8 // indirect github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rjeczalik/notify v0.9.2 github.com/spf13/cobra v1.0.0 @@ -18,7 +19,7 @@ require ( go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 golang.org/x/crypto v0.0.0-20200422194213-44a606286825 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a - golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 + golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 golang.org/x/time v0.0.0-20191024005414-555d28b269f0 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 nhooyr.io/websocket v1.8.6 diff --git a/go.sum b/go.sum index 99efd17c..726c0bdd 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/briandowns/spinner v1.11.1 h1:OixPqDEcX3juo5AjQZAnFPbeUA0jvkp2qzB5gOZJ/L0= +github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= @@ -176,10 +178,11 @@ github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -334,8 +337,8 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM/fAoGlaiiHYiFYdm80= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 h1:5jaG59Zhd+8ZXe8C+lgiAGqkOaZBruqrWclLkgAww34= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= diff --git a/internal/cmd/envs.go b/internal/cmd/envs.go index 8a84f29f..b67ab1a8 100644 --- a/internal/cmd/envs.go +++ b/internal/cmd/envs.go @@ -64,6 +64,8 @@ func envsCommand() *cobra.Command { cmd.AddCommand(lsCmd) cmd.AddCommand(stopEnvCommand(&user)) + cmd.AddCommand(watchBuildLogCommand()) + cmd.AddCommand(rebuildEnvCommand()) return cmd } diff --git a/internal/cmd/rebuild.go b/internal/cmd/rebuild.go new file mode 100644 index 00000000..71d2599a --- /dev/null +++ b/internal/cmd/rebuild.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "cdr.dev/coder-cli/coder-sdk" + "github.com/briandowns/spinner" + "github.com/fatih/color" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "go.coder.com/flog" + "golang.org/x/xerrors" +) + +func rebuildEnvCommand() *cobra.Command { + var follow bool + var force bool + cmd := &cobra.Command{ + Use: "rebuild [environment_name]", + Short: "rebuild a Coder environment", + Args: cobra.ExactArgs(1), + Example: `coder envs rebuild front-end-env --follow +coder envs rebuild backend-env --force`, + Hidden: true, // TODO(@cmoog) un-hide + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient() + if err != nil { + return err + } + env, err := findEnv(ctx, client, args[0], coder.Me) + if err != nil { + return err + } + + if !force && env.LatestStat.ContainerStatus == coder.EnvironmentOn { + _, err = (&promptui.Prompt{ + Label: fmt.Sprintf("Rebuild environment \"%s\"? (will destroy any work outside of /home)", env.Name), + IsConfirm: true, + }).Run() + if err != nil { + return err + } + } + + if err = client.RebuildEnvironment(ctx, env.ID); err != nil { + return err + } + if follow { + if err = trailBuildLogs(ctx, client, env.ID); err != nil { + return err + } + } else { + flog.Info("Use \"coder envs watch-build %s\" to follow the build logs", env.Name) + } + return nil + }, + } + + cmd.Flags().BoolVar(&follow, "follow", false, "follow buildlog after initiating rebuild") + cmd.Flags().BoolVar(&force, "force", false, "force rebuild without showing a confirmation prompt") + return cmd +} + +// trailBuildLogs follows the build log for a given environment and prints the staged +// output with loaders and success/failure indicators for each stage +func trailBuildLogs(ctx context.Context, client *coder.Client, envID string) error { + const check = "✅" + const failure = "❌" + const loading = "⌛" + + newSpinner := func() *spinner.Spinner { return spinner.New(spinner.CharSets[11], 100*time.Millisecond) } + + logs, err := client.FollowEnvironmentBuildLog(ctx, envID) + if err != nil { + return err + } + var s *spinner.Spinner + for l := range logs { + if l.Err != nil { + return l.Err + } + switch l.BuildLog.Type { + case coder.BuildLogTypeStart: + // the FE uses this to reset the UI + // the CLI doesn't need to do anything here given that we only append to the trail + case coder.BuildLogTypeStage: + if s != nil { + s.Stop() + fmt.Print("\n") + } + s = newSpinner() + msg := fmt.Sprintf("%s %s", l.BuildLog.Time.Format(time.RFC3339), l.BuildLog.Msg) + s.Suffix = fmt.Sprintf(" -- %s", msg) + s.FinalMSG = fmt.Sprintf("%s -- %s", check, msg) + s.Start() + case coder.BuildLogTypeSubstage: + // TODO(@cmoog) add verbose substage printing + case coder.BuildLogTypeError: + if s != nil { + s.FinalMSG = fmt.Sprintf("%s %s", failure, strings.TrimPrefix(s.Suffix, " ")) + s.Stop() + } + fmt.Print(color.RedString("\t%s", l.BuildLog.Msg)) + s = newSpinner() + case coder.BuildLogTypeDone: + if s != nil { + s.Stop() + } + return nil + default: + return xerrors.Errorf("unknown buildlog type: %s", l.BuildLog.Type) + } + } + return nil +} + +func watchBuildLogCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "watch-build [environment_name]", + Example: "coder watch-build front-end-env", + Short: "trail the build log of a Coder environment", + Args: cobra.ExactArgs(1), + Hidden: true, // TODO(@cmoog) un-hide + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := newClient() + if err != nil { + return err + } + env, err := findEnv(ctx, client, args[0], coder.Me) + if err != nil { + return err + } + + if err = trailBuildLogs(ctx, client, env.ID); err != nil { + return err + } + return nil + }, + } + return cmd +}