From 7ed283c7c7ac9afbf7ef1796cc7934b707cf6b23 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 26 Mar 2022 19:24:43 +0000 Subject: [PATCH 01/11] feat: Add stage to build logs This adds a stage property to logs, and refactors the job logs cliui. It also adds tests to the cliui for build logs! --- cli/cliui/cliui.go | 4 + cli/cliui/job.go | 157 ---------------- cli/cliui/provisionerjob.go | 169 ++++++++++++++++++ cli/cliui/provisionerjob_test.go | 166 +++++++++++++++++ cli/projectcreate.go | 10 +- cli/start.go | 5 +- cli/start_test.go | 3 +- cli/workspacecreate.go | 3 +- cli/workspacedelete.go | 3 +- cli/workspacestart.go | 3 +- cli/workspacestop.go | 3 +- cmd/cliui/main.go | 35 +++- coderd/database/databasefake/databasefake.go | 2 +- coderd/database/dump.sql | 1 + coderd/database/migrations/000004_jobs.up.sql | 1 + coderd/database/models.go | 1 + coderd/database/query.sql | 4 +- coderd/database/query.sql.go | 14 +- coderd/projectversions_test.go | 12 +- coderd/provisionerdaemons.go | 1 + coderd/provisionerjobs.go | 1 + coderd/provisionerjobs_test.go | 24 +-- coderd/workspacebuilds_test.go | 16 +- codersdk/provisionerdaemons.go | 1 + provisioner/terraform/provision.go | 9 + provisionerd/proto/provisionerd.pb.go | 102 ++++++----- provisionerd/proto/provisionerd.proto | 5 +- provisionerd/provisionerd.go | 84 ++++++++- provisionerd/provisionerd_test.go | 4 +- 29 files changed, 581 insertions(+), 262 deletions(-) delete mode 100644 cli/cliui/job.go create mode 100644 cli/cliui/provisionerjob.go create mode 100644 cli/cliui/provisionerjob_test.go diff --git a/cli/cliui/cliui.go b/cli/cliui/cliui.go index 5d324e76cd244..e69280cdf4119 100644 --- a/cli/cliui/cliui.go +++ b/cli/cliui/cliui.go @@ -23,7 +23,9 @@ func ValidateNotEmpty(s string) error { // Styles compose visual elements of the UI! var Styles = struct { Bold, + Checkmark, Code, + Crossmark, Field, Keyword, Paragraph, @@ -36,7 +38,9 @@ var Styles = struct { Wrap lipgloss.Style }{ Bold: lipgloss.NewStyle().Bold(true), + Checkmark: defaultStyles.Checkmark, Code: defaultStyles.Code, + Crossmark: defaultStyles.Error.Copy().SetString("✘"), Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}), Keyword: defaultStyles.Keyword, Paragraph: defaultStyles.Paragraph, diff --git a/cli/cliui/job.go b/cli/cliui/job.go deleted file mode 100644 index bd7983a5cfcd9..0000000000000 --- a/cli/cliui/job.go +++ /dev/null @@ -1,157 +0,0 @@ -package cliui - -import ( - "fmt" - "os" - "os/signal" - "time" - - "github.com/briandowns/spinner" - "github.com/charmbracelet/lipgloss" - "github.com/spf13/cobra" - - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" -) - -type JobOptions struct { - Title string - Output bool - Fetch func() (codersdk.ProvisionerJob, error) - Cancel func() error - Logs func() (<-chan codersdk.ProvisionerJobLog, error) -} - -// Job renders a provisioner job. -func Job(cmd *cobra.Command, opts JobOptions) (codersdk.ProvisionerJob, error) { - var ( - spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond, spinner.WithColor("fgGreen")) - - started = false - completed = false - job codersdk.ProvisionerJob - ) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s%s %s\n", Styles.FocusedPrompt, opts.Title, Styles.Placeholder.Render("(ctrl+c to cancel)")) - - spin.Writer = cmd.OutOrStdout() - defer spin.Stop() - - // Refreshes the job state! - refresh := func() { - var err error - job, err = opts.Fetch() - if err != nil { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error())) - return - } - - if !started && job.StartedAt != nil { - spin.Stop() - _, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.Prompt.String()+"Started "+Styles.Placeholder.Render("[%dms]")+"\n", job.StartedAt.Sub(job.CreatedAt).Milliseconds()) - spin.Start() - started = true - } - if !completed && job.CompletedAt != nil { - spin.Stop() - msg := "" - switch job.Status { - case codersdk.ProvisionerJobCanceled: - msg = "Canceled" - case codersdk.ProvisionerJobFailed: - msg = "Completed" - case codersdk.ProvisionerJobSucceeded: - msg = "Built" - } - started := job.CreatedAt - if job.StartedAt != nil { - started = *job.StartedAt - } - _, _ = fmt.Fprintf(cmd.OutOrStderr(), Styles.Prompt.String()+msg+" "+Styles.Placeholder.Render("[%dms]")+"\n", job.CompletedAt.Sub(started).Milliseconds()) - spin.Start() - completed = true - } - - switch job.Status { - case codersdk.ProvisionerJobPending: - spin.Suffix = " Queued" - case codersdk.ProvisionerJobRunning: - spin.Suffix = " Running" - case codersdk.ProvisionerJobCanceling: - spin.Suffix = " Canceling" - } - } - refresh() - spin.Start() - - stopChan := make(chan os.Signal, 1) - defer signal.Stop(stopChan) - go func() { - signal.Notify(stopChan, os.Interrupt) - select { - case <-cmd.Context().Done(): - return - case _, ok := <-stopChan: - if !ok { - return - } - } - signal.Stop(stopChan) - spin.Stop() - _, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+"Gracefully canceling... wait for exit or data loss may occur!\n") - spin.Start() - err := opts.Cancel() - if err != nil { - spin.Stop() - _, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error())) - return - } - refresh() - }() - - logs, err := opts.Logs() - if err != nil { - return job, err - } - - firstLog := false - ticker := time.NewTicker(time.Second) - for { - select { - case <-cmd.Context().Done(): - return job, cmd.Context().Err() - case <-ticker.C: - refresh() - if job.CompletedAt != nil { - return job, nil - } - case log, ok := <-logs: - if !ok { - refresh() - return job, nil - } - if !firstLog { - refresh() - firstLog = true - } - if !opts.Output { - continue - } - spin.Stop() - var style lipgloss.Style - switch log.Level { - case database.LogLevelTrace: - style = defaultStyles.Error - case database.LogLevelDebug: - style = defaultStyles.Error - case database.LogLevelError: - style = defaultStyles.Error - case database.LogLevelWarn: - style = Styles.Warn - case database.LogLevelInfo: - style = defaultStyles.Note - } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s %s\n", Styles.Placeholder.Render("|"), style.Render(string(log.Level)), log.Output) - spin.Start() - } - } -} diff --git a/cli/cliui/provisionerjob.go b/cli/cliui/provisionerjob.go new file mode 100644 index 0000000000000..681f583cff427 --- /dev/null +++ b/cli/cliui/provisionerjob.go @@ -0,0 +1,169 @@ +package cliui + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "time" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" +) + +type ProvisionerJobOptions struct { + Fetch func() (codersdk.ProvisionerJob, error) + Cancel func() error + Logs func() (<-chan codersdk.ProvisionerJobLog, error) + + FetchInterval time.Duration +} + +// ProvisionerJob renders a provisioner job with interactive cancellation. +func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { + if opts.FetchInterval == 0 { + opts.FetchInterval = time.Second + } + + var ( + currentStage = "Queued" + currentStageStartedAt = time.Now().UTC() + didLogBetweenStage = false + ctx, cancelFunc = context.WithCancel(cmd.Context()) + + errChan = make(chan error) + job codersdk.ProvisionerJob + jobMutex sync.Mutex + ) + defer cancelFunc() + + printStage := func() { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.Prompt.Render("⧗")+"%s\n", Styles.Field.Render(currentStage)) + } + printStage() + + updateStage := func(stage string, startedAt time.Time) { + if currentStage != "" { + prefix := "" + if !didLogBetweenStage { + prefix = "\033[1A\r" + } + mark := Styles.Checkmark + if job.CompletedAt != nil && job.Status != codersdk.ProvisionerJobSucceeded { + mark = Styles.Crossmark + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), prefix+mark.String()+Styles.Placeholder.Render(" %s [%dms]")+"\n", currentStage, startedAt.Sub(currentStageStartedAt).Milliseconds()) + } + if stage == "" { + return + } + currentStage = stage + currentStageStartedAt = startedAt + didLogBetweenStage = false + printStage() + } + + updateJob := func() { + var err error + jobMutex.Lock() + defer jobMutex.Unlock() + job, err = opts.Fetch() + if err != nil { + errChan <- xerrors.Errorf("fetch: %w", err) + return + } + if job.StartedAt == nil { + return + } + if currentStage != "Queued" { + // If another stage is already running, there's no need + // for us to notify the user we're running! + return + } + updateStage("Running", *job.StartedAt) + } + updateJob() + + // Handles ctrl+c to cancel a job. + stopChan := make(chan os.Signal, 1) + defer signal.Stop(stopChan) + go func() { + signal.Notify(stopChan, os.Interrupt) + select { + case <-ctx.Done(): + return + case _, ok := <-stopChan: + if !ok { + return + } + } + // Stop listening for signals so another one kills it! + signal.Stop(stopChan) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[2K\r\n"+Styles.FocusedPrompt.String()+Styles.Bold.Render("Gracefully canceling... wait for exit or data loss may occur!")+"\n\n") + err := opts.Cancel() + if err != nil { + errChan <- xerrors.Errorf("cancel: %w", err) + return + } + updateJob() + }() + + logs, err := opts.Logs() + if err != nil { + return xerrors.Errorf("logs: %w", err) + } + + ticker := time.NewTicker(opts.FetchInterval) + for { + select { + case err = <-errChan: + return err + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + updateJob() + case log, ok := <-logs: + if !ok { + // The logs stream will end when the job does, + // so it's safe to + updateJob() + jobMutex.Lock() + if job.CompletedAt != nil { + updateStage("", *job.CompletedAt) + } + switch job.Status { + case codersdk.ProvisionerJobCanceled: + jobMutex.Unlock() + return Canceled + case codersdk.ProvisionerJobSucceeded: + jobMutex.Unlock() + return nil + case codersdk.ProvisionerJobFailed: + } + jobMutex.Unlock() + return xerrors.New(job.Error) + } + output := "" + switch log.Level { + case database.LogLevelTrace, database.LogLevelDebug, database.LogLevelError: + output = defaultStyles.Error.Render(log.Output) + case database.LogLevelWarn: + output = Styles.Warn.Render(log.Output) + case database.LogLevelInfo: + output = log.Output + } + if log.Stage != currentStage && log.Stage != "" { + jobMutex.Lock() + updateStage(log.Stage, log.CreatedAt) + jobMutex.Unlock() + continue + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", Styles.Placeholder.Render(" "), output) + didLogBetweenStage = true + } + } +} diff --git a/cli/cliui/provisionerjob_test.go b/cli/cliui/provisionerjob_test.go new file mode 100644 index 0000000000000..09bdd6c911e52 --- /dev/null +++ b/cli/cliui/provisionerjob_test.go @@ -0,0 +1,166 @@ +package cliui_test + +import ( + "context" + "os" + "runtime" + "sync" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" +) + +// This cannot be ran in parallel because it uses a signal. +// nolint:tparallel +func TestProvisionerJob(t *testing.T) { + t.Run("NoLogs", func(t *testing.T) { + t.Parallel() + + test := newProvisionerJob(t) + go func() { + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobRunning + now := database.Now() + test.Job.StartedAt = &now + test.JobMutex.Unlock() + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobSucceeded + now = database.Now() + test.Job.CompletedAt = &now + test.JobMutex.Unlock() + close(test.Logs) + }() + test.PTY.ExpectMatch("Queued") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Queued") + test.PTY.ExpectMatch("Running") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Running") + }) + + t.Run("Stages", func(t *testing.T) { + t.Parallel() + + test := newProvisionerJob(t) + go func() { + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobRunning + now := database.Now() + test.Job.StartedAt = &now + test.Logs <- codersdk.ProvisionerJobLog{ + CreatedAt: database.Now(), + Stage: "Something", + } + test.JobMutex.Unlock() + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobSucceeded + now = database.Now() + test.Job.CompletedAt = &now + test.JobMutex.Unlock() + close(test.Logs) + }() + test.PTY.ExpectMatch("Queued") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Queued") + test.PTY.ExpectMatch("Something") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Something") + }) + + // This cannot be ran in parallel because it uses a signal. + //nolint:paralleltest + t.Run("Cancel", func(t *testing.T) { + if runtime.GOOS == "windows" { + // Sending interrupt signal isn't supported on Windows! + t.SkipNow() + } + + test := newProvisionerJob(t) + go func() { + <-test.Next + currentProcess, err := os.FindProcess(os.Getpid()) + require.NoError(t, err) + err = currentProcess.Signal(os.Interrupt) + require.NoError(t, err) + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobCanceled + now := database.Now() + test.Job.CompletedAt = &now + test.JobMutex.Unlock() + close(test.Logs) + }() + test.PTY.ExpectMatch("Queued") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Gracefully canceling") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Queued") + }) +} + +type provisionerJobTest struct { + Next chan struct{} + Job *codersdk.ProvisionerJob + JobMutex *sync.Mutex + Logs chan codersdk.ProvisionerJobLog + PTY *ptytest.PTY +} + +func newProvisionerJob(t *testing.T) provisionerJobTest { + job := &codersdk.ProvisionerJob{ + Status: codersdk.ProvisionerJobPending, + CreatedAt: database.Now(), + } + jobLock := sync.Mutex{} + logs := make(chan codersdk.ProvisionerJobLog) + cmd := &cobra.Command{ + RunE: func(cmd *cobra.Command, args []string) error { + return cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ + FetchInterval: time.Millisecond, + Fetch: func() (codersdk.ProvisionerJob, error) { + jobLock.Lock() + defer jobLock.Unlock() + return *job, nil + }, + Cancel: func() error { + return nil + }, + Logs: func() (<-chan codersdk.ProvisionerJobLog, error) { + return logs, nil + }, + }) + }, + } + ptty := ptytest.New(t) + cmd.SetOutput(ptty.Output()) + cmd.SetIn(ptty.Input()) + done := make(chan struct{}) + go func() { + defer close(done) + err := cmd.ExecuteContext(context.Background()) + if err != nil { + require.ErrorIs(t, err, cliui.Canceled) + } + }() + t.Cleanup(func() { + <-done + }) + return provisionerJobTest{ + Next: make(chan struct{}), + Job: job, + JobMutex: &jobLock, + Logs: logs, + PTY: ptty, + } +} diff --git a/cli/projectcreate.go b/cli/projectcreate.go index 232b8f571f8d6..1465558369e83 100644 --- a/cli/projectcreate.go +++ b/cli/projectcreate.go @@ -9,7 +9,6 @@ import ( "time" "github.com/briandowns/spinner" - "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -126,8 +125,7 @@ func createValidProjectVersion(cmd *cobra.Command, client *codersdk.Client, orga return nil, nil, err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Building project...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { version, err := client.ProjectVersion(cmd.Context(), version.ID) return version.Job, err @@ -140,7 +138,9 @@ func createValidProjectVersion(cmd *cobra.Command, client *codersdk.Client, orga }, }) if err != nil { - return nil, nil, err + if !provisionerd.IsMissingParameterError(err.Error()) { + return nil, nil, err + } } version, err = client.ProjectVersion(cmd.Context(), version.ID) if err != nil { @@ -192,7 +192,7 @@ func createValidProjectVersion(cmd *cobra.Command, client *codersdk.Client, orga return nil, nil, xerrors.New(version.Job.Error) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Successfully imported project source!\n", color.HiGreenString("✓")) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Checkmark.String()+" Successfully imported project source!\n") resources, err := client.ProjectVersionResources(cmd.Context(), version.ID) if err != nil { diff --git a/cli/start.go b/cli/start.go index 00e4996dc6905..99260ed78565d 100644 --- a/cli/start.go +++ b/cli/start.go @@ -109,7 +109,7 @@ func start() *cobra.Command { if err != nil { return xerrors.Errorf("create tunnel: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL)+"\n") } } validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication()) @@ -262,8 +262,7 @@ func start() *cobra.Command { return xerrors.Errorf("delete workspace: %w", err) } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: fmt.Sprintf("Deleting workspace %s...", cliui.Styles.Keyword.Render(workspace.Name)), + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), build.ID) return build.Job, err diff --git a/cli/start_test.go b/cli/start_test.go index 2a35f2ca1e713..00366e4792441 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -26,8 +26,9 @@ import ( "github.com/coder/coder/codersdk" ) +// This cannot be ran in parallel because it uses a signal. +// nolint:tparallel func TestStart(t *testing.T) { - t.Parallel() t.Run("Production", func(t *testing.T) { t.Parallel() if runtime.GOOS != "linux" || testing.Short() { diff --git a/cli/workspacecreate.go b/cli/workspacecreate.go index 93b1997047dcd..0ab036909e981 100644 --- a/cli/workspacecreate.go +++ b/cli/workspacecreate.go @@ -146,8 +146,7 @@ func workspaceCreate() *cobra.Command { if err != nil { return err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Building workspace...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID) return build.Job, err diff --git a/cli/workspacedelete.go b/cli/workspacedelete.go index 4dfd59a57bf1f..b01b75f0dc87a 100644 --- a/cli/workspacedelete.go +++ b/cli/workspacedelete.go @@ -32,8 +32,7 @@ func workspaceDelete() *cobra.Command { if err != nil { return err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Deleting workspace...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), build.ID) return build.Job, err diff --git a/cli/workspacestart.go b/cli/workspacestart.go index f19ceb2aeaa38..a477b222443bc 100644 --- a/cli/workspacestart.go +++ b/cli/workspacestart.go @@ -31,8 +31,7 @@ func workspaceStart() *cobra.Command { if err != nil { return err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Starting workspace...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), build.ID) return build.Job, err diff --git a/cli/workspacestop.go b/cli/workspacestop.go index 4b07b478d7717..28071e4b15ec5 100644 --- a/cli/workspacestop.go +++ b/cli/workspacestop.go @@ -31,8 +31,7 @@ func workspaceStop() *cobra.Command { if err != nil { return err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Stopping workspace...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), build.ID) return build.Job, err diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index bd8829cc1be00..8ad66aec4ad37 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -94,7 +94,7 @@ func main() { job.Status = codersdk.ProvisionerJobSucceeded }() - _, err := cliui.Job(cmd, cliui.JobOptions{ + err := cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { return job, nil }, @@ -102,16 +102,41 @@ func main() { logs := make(chan codersdk.ProvisionerJobLog) go func() { defer close(logs) - ticker := time.NewTicker(500 * time.Millisecond) + ticker := time.NewTicker(100 * time.Millisecond) + count := 0 for { select { case <-cmd.Context().Done(): return case <-ticker.C: - logs <- codersdk.ProvisionerJobLog{ - Output: "Some log", - Level: database.LogLevelInfo, + if job.Status == codersdk.ProvisionerJobSucceeded || job.Status == codersdk.ProvisionerJobCanceled { + return } + log := codersdk.ProvisionerJobLog{ + CreatedAt: time.Now(), + Output: fmt.Sprintf("Some log %d", count), + Level: database.LogLevelInfo, + } + switch { + case count == 10: + log.Stage = "Setting Up" + case count == 20: + log.Stage = "Executing Hook" + case count == 30: + log.Stage = "Parsing Variables" + case count == 40: + log.Stage = "Provisioning" + case count == 50: + log.Stage = "Cleaning Up" + } + if count%5 == 0 { + log.Level = database.LogLevelWarn + } + count++ + if log.Output == "" && log.Stage == "" { + continue + } + logs <- log } } }() diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 82d1c4a472cbc..618c1c6daf0cd 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -898,6 +898,7 @@ func (q *fakeQuerier) InsertProvisionerJobLogs(_ context.Context, arg database.I CreatedAt: arg.CreatedAt[index], Source: arg.Source[index], Level: arg.Level[index], + Stage: arg.Stage[index], Output: output, }) } @@ -1201,7 +1202,6 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar } job.UpdatedAt = arg.UpdatedAt job.CompletedAt = arg.CompletedAt - job.CanceledAt = arg.CanceledAt job.Error = arg.Error q.provisionerJobs[index] = job return nil diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index dc3d6a7c6e50d..f0150fb5b825a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -190,6 +190,7 @@ CREATE TABLE provisioner_job_logs ( created_at timestamp with time zone NOT NULL, source log_source NOT NULL, level log_level NOT NULL, + stage character varying(128) NOT NULL, output character varying(1024) NOT NULL ); diff --git a/coderd/database/migrations/000004_jobs.up.sql b/coderd/database/migrations/000004_jobs.up.sql index ddddeba5c0bec..bf9b731d4e744 100644 --- a/coderd/database/migrations/000004_jobs.up.sql +++ b/coderd/database/migrations/000004_jobs.up.sql @@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS provisioner_job_logs ( created_at timestamptz NOT NULL, source log_source NOT NULL, level log_level NOT NULL, + stage varchar(128) NOT NULL, output varchar(1024) NOT NULL ); diff --git a/coderd/database/models.go b/coderd/database/models.go index 2296fec308c6d..8e4eecd5e4cff 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -388,6 +388,7 @@ type ProvisionerJobLog struct { CreatedAt time.Time `db:"created_at" json:"created_at"` Source LogSource `db:"source" json:"source"` Level LogLevel `db:"level" json:"level"` + Stage string `db:"stage" json:"stage"` Output string `db:"output" json:"output"` } diff --git a/coderd/database/query.sql b/coderd/database/query.sql index bb13ade06f94d..7bc2c5f3245b7 100644 --- a/coderd/database/query.sql +++ b/coderd/database/query.sql @@ -482,6 +482,7 @@ SELECT unnest(@created_at :: timestamptz [ ]) AS created_at, unnest(@source :: log_source [ ]) as source, unnest(@level :: log_level [ ]) as level, + unnest(@stage :: varchar(128) [ ]) as stage, unnest(@output :: varchar(1024) [ ]) as output RETURNING *; -- name: InsertOrganization :one @@ -757,8 +758,7 @@ UPDATE SET updated_at = $2, completed_at = $3, - canceled_at = $4, - error = $5 + error = $4 WHERE id = $1; diff --git a/coderd/database/query.sql.go b/coderd/database/query.sql.go index b7f58c1f746c7..38da2dddba6de 100644 --- a/coderd/database/query.sql.go +++ b/coderd/database/query.sql.go @@ -829,7 +829,7 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI const getProvisionerLogsByIDBetween = `-- name: GetProvisionerLogsByIDBetween :many SELECT - id, job_id, created_at, source, level, output + id, job_id, created_at, source, level, stage, output FROM provisioner_job_logs WHERE @@ -863,6 +863,7 @@ func (q *sqlQuerier) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetP &i.CreatedAt, &i.Source, &i.Level, + &i.Stage, &i.Output, ); err != nil { return nil, err @@ -2121,7 +2122,8 @@ SELECT unnest($3 :: timestamptz [ ]) AS created_at, unnest($4 :: log_source [ ]) as source, unnest($5 :: log_level [ ]) as level, - unnest($6 :: varchar(1024) [ ]) as output RETURNING id, job_id, created_at, source, level, output + unnest($6 :: varchar(128) [ ]) as stage, + unnest($7 :: varchar(1024) [ ]) as output RETURNING id, job_id, created_at, source, level, stage, output ` type InsertProvisionerJobLogsParams struct { @@ -2130,6 +2132,7 @@ type InsertProvisionerJobLogsParams struct { CreatedAt []time.Time `db:"created_at" json:"created_at"` Source []LogSource `db:"source" json:"source"` Level []LogLevel `db:"level" json:"level"` + Stage []string `db:"stage" json:"stage"` Output []string `db:"output" json:"output"` } @@ -2140,6 +2143,7 @@ func (q *sqlQuerier) InsertProvisionerJobLogs(ctx context.Context, arg InsertPro pq.Array(arg.CreatedAt), pq.Array(arg.Source), pq.Array(arg.Level), + pq.Array(arg.Stage), pq.Array(arg.Output), ) if err != nil { @@ -2155,6 +2159,7 @@ func (q *sqlQuerier) InsertProvisionerJobLogs(ctx context.Context, arg InsertPro &i.CreatedAt, &i.Source, &i.Level, + &i.Stage, &i.Output, ); err != nil { return nil, err @@ -2617,8 +2622,7 @@ UPDATE SET updated_at = $2, completed_at = $3, - canceled_at = $4, - error = $5 + error = $4 WHERE id = $1 ` @@ -2627,7 +2631,6 @@ type UpdateProvisionerJobWithCompleteByIDParams struct { ID uuid.UUID `db:"id" json:"id"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"` - CanceledAt sql.NullTime `db:"canceled_at" json:"canceled_at"` Error sql.NullString `db:"error" json:"error"` } @@ -2636,7 +2639,6 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a arg.ID, arg.UpdatedAt, arg.CompletedAt, - arg.CanceledAt, arg.Error, ) return err diff --git a/coderd/projectversions_test.go b/coderd/projectversions_test.go index 15bc4bfc8e969..7a58600202a2a 100644 --- a/coderd/projectversions_test.go +++ b/coderd/projectversions_test.go @@ -94,9 +94,7 @@ func TestPatchCancelProjectVersion(t *testing.T) { var err error version, err = client.ProjectVersion(context.Background(), version.ID) require.NoError(t, err) - // The echo provisioner doesn't respond to a shutdown request, - // so the job cancel will time out and fail. - return version.Job.Status == codersdk.ProvisionerJobFailed + return version.Job.Status == codersdk.ProvisionerJobCanceled }, 5*time.Second, 25*time.Millisecond) }) } @@ -274,6 +272,10 @@ func TestProjectVersionLogs(t *testing.T) { t.Cleanup(cancelFunc) logs, err := client.ProjectVersionLogsAfter(ctx, version.ID, before) require.NoError(t, err) - log := <-logs - require.Equal(t, "example", log.Output) + for { + _, ok := <-logs + if !ok { + return + } + } } diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index ce203c283b89e..2c3d3f2ddc961 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -301,6 +301,7 @@ func (server *provisionerdServer) UpdateJob(ctx context.Context, request *proto. insertParams.ID = append(insertParams.ID, uuid.New()) insertParams.CreatedAt = append(insertParams.CreatedAt, time.UnixMilli(log.CreatedAt)) insertParams.Level = append(insertParams.Level, logLevel) + insertParams.Stage = append(insertParams.Stage, log.Stage) insertParams.Source = append(insertParams.Source, logSource) insertParams.Output = append(insertParams.Output, log.Output) } diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 7e5682e65b67a..2fa6f253f8292 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -224,6 +224,7 @@ func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) code CreatedAt: provisionerJobLog.CreatedAt, Source: provisionerJobLog.Source, Level: provisionerJobLog.Level, + Stage: provisionerJobLog.Stage, Output: provisionerJobLog.Output, } } diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index c4aef5c7550ca..70afdbf609f80 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -45,12 +45,12 @@ func TestProvisionerJobLogs(t *testing.T) { t.Cleanup(cancelFunc) logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before) require.NoError(t, err) - log, ok := <-logs - require.True(t, ok) - require.Equal(t, "log-output", log.Output) - // Make sure the channel automatically closes! - _, ok = <-logs - require.False(t, ok) + for { + _, ok := <-logs + if !ok { + return + } + } }) t.Run("StreamWhileRunning", func(t *testing.T) { @@ -81,10 +81,12 @@ func TestProvisionerJobLogs(t *testing.T) { t.Cleanup(cancelFunc) logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before) require.NoError(t, err) - log := <-logs - require.Equal(t, "log-output", log.Output) - _, ok := <-logs - require.False(t, ok) + for { + _, ok := <-logs + if !ok { + return + } + } }) t.Run("List", func(t *testing.T) { @@ -113,6 +115,6 @@ func TestProvisionerJobLogs(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) logs, err := client.WorkspaceBuildLogsBefore(context.Background(), workspace.LatestBuild.ID, time.Now()) require.NoError(t, err) - require.Len(t, logs, 1) + require.Greater(t, len(logs), 1) }) } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 2e9814cf713b8..a5b1c520db80d 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -57,9 +57,7 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { var err error build, err = client.WorkspaceBuild(context.Background(), build.ID) require.NoError(t, err) - // The echo provisioner doesn't respond to a shutdown request, - // so the job cancel will time out and fail. - return build.Job.Status == codersdk.ProvisionerJobFailed + return build.Job.Status == codersdk.ProvisionerJobCanceled }, 5*time.Second, 25*time.Millisecond) } @@ -159,6 +157,14 @@ func TestWorkspaceBuildLogs(t *testing.T) { t.Cleanup(cancelFunc) logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before) require.NoError(t, err) - log := <-logs - require.Equal(t, "example", log.Output) + for { + log, ok := <-logs + if !ok { + break + } + if log.Output == "example" { + return + } + } + require.Fail(t, "example message never happened") } diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 6530dab33c9e4..a66f8d3ff2ac2 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -49,6 +49,7 @@ type ProvisionerJobLog struct { CreatedAt time.Time `json:"created_at"` Source database.LogSource `json:"log_source"` Level database.LogLevel `json:"log_level"` + Stage string `json:"stage"` Output string `json:"output"` } diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index b321d4e6f6131..62ea5ef9b2764 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -210,6 +210,15 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro err = cmd.Run() if err != nil { if start.DryRun { + if shutdown.Err() != nil { + return stream.Send(&proto.Provision_Response{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Error: err.Error(), + }, + }, + }) + } return xerrors.Errorf("plan terraform: %w", err) } errorMessage := err.Error() diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index 220c7149d08f2..782bccb8c955f 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -422,7 +422,8 @@ type Log struct { Source LogSource `protobuf:"varint,1,opt,name=source,proto3,enum=provisionerd.LogSource" json:"source,omitempty"` Level proto.LogLevel `protobuf:"varint,2,opt,name=level,proto3,enum=provisioner.LogLevel" json:"level,omitempty"` CreatedAt int64 `protobuf:"varint,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - Output string `protobuf:"bytes,4,opt,name=output,proto3" json:"output,omitempty"` + Stage string `protobuf:"bytes,4,opt,name=stage,proto3" json:"stage,omitempty"` + Output string `protobuf:"bytes,5,opt,name=output,proto3" json:"output,omitempty"` } func (x *Log) Reset() { @@ -478,6 +479,13 @@ func (x *Log) GetCreatedAt() int64 { return 0 } +func (x *Log) GetStage() string { + if x != nil { + return x.Stage + } + return "" +} + func (x *Log) GetOutput() string { if x != nil { return x.Output @@ -1026,7 +1034,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x0d, 0x73, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x42, 0x06, - 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2f, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, @@ -1034,50 +1042,52 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6f, - 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, - 0x70, 0x75, 0x74, 0x22, 0x9b, 0x01, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, - 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, - 0x25, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, - 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x12, 0x49, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, - 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, - 0x73, 0x22, 0x77, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, - 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, - 0x65, 0x64, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, - 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, - 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2a, 0x34, 0x0a, 0x09, 0x4c, 0x6f, - 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x56, 0x49, - 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, 0x4d, 0x4f, 0x4e, 0x10, 0x00, 0x12, - 0x0f, 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x10, 0x01, - 0x32, 0x98, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0a, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, - 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, - 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, - 0x62, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, - 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, - 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, 0x4a, 0x6f, 0x62, 0x12, 0x17, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, - 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3e, 0x0a, 0x0b, 0x43, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, - 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x2b, 0x5a, 0x29, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, + 0x74, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x67, + 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x9b, 0x01, 0x0a, 0x10, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, + 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x12, 0x49, 0x0a, 0x11, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, + 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, + 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x22, 0x77, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, + 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, + 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72, 0x61, + 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, + 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, + 0x2a, 0x34, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, + 0x12, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, + 0x4d, 0x4f, 0x4e, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, + 0x4f, 0x4e, 0x45, 0x52, 0x10, 0x01, 0x32, 0x98, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0a, + 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, + 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, + 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, + 0x4a, 0x6f, 0x62, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x12, 0x3e, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, + 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x42, 0x2b, 0x5a, 0x29, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/provisionerd/proto/provisionerd.proto b/provisionerd/proto/provisionerd.proto index e7c76d228bdad..88bf3fa695356 100644 --- a/provisionerd/proto/provisionerd.proto +++ b/provisionerd/proto/provisionerd.proto @@ -74,7 +74,8 @@ message Log { LogSource source = 1; provisioner.LogLevel level = 2; int64 created_at = 3; - string output = 4; + string stage = 4; + string output = 5; } // This message should be sent periodically as a heartbeat. @@ -107,4 +108,4 @@ service ProvisionerDaemon { // CompleteJob indicates a job has been completed. rpc CompleteJob(CompletedJob) returns (Empty); -} \ No newline at end of file +} diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index 3899ac59212c1..ffdcbf1e7df69 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -298,6 +298,20 @@ func (p *Server) runJob(ctx context.Context, job *proto.AcquiredJob) { return } + _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: "Setting up", + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } + p.opts.Logger.Info(ctx, "unpacking project source archive", slog.F("size_bytes", len(job.ProjectSourceArchive))) reader := tar.NewReader(bytes.NewBuffer(job.ProjectSourceArchive)) for { @@ -377,11 +391,39 @@ func (p *Server) runJob(ctx context.Context, job *proto.AcquiredJob) { // Ensure the job is still running to output. // It's possible the job has failed. if p.isRunningJob() { + _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: "Cleaning Up", + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } + p.opts.Logger.Info(context.Background(), "completed job", slog.F("id", job.JobId)) } } func (p *Server) runProjectImport(ctx, shutdown context.Context, provisioner sdkproto.DRPCProvisionerClient, job *proto.AcquiredJob) { + _, err := p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: "Parse parameters", + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } + parameterSchemas, err := p.runProjectImportParse(ctx, provisioner, job) if err != nil { p.failActiveJobf("run parse: %s", err) @@ -409,6 +451,19 @@ func (p *Server) runProjectImport(ctx, shutdown context.Context, provisioner sdk } } + _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: "Detecting resources when started", + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } startResources, err := p.runProjectImportProvision(ctx, shutdown, provisioner, job, updateResponse.ParameterValues, &sdkproto.Provision_Metadata{ CoderUrl: job.GetProjectImport().Metadata.CoderUrl, WorkspaceTransition: sdkproto.WorkspaceTransition_START, @@ -422,8 +477,8 @@ func (p *Server) runProjectImport(ctx, shutdown context.Context, provisioner sdk Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, Level: sdkproto.LogLevel_INFO, + Stage: "Detecting resources when stopped", CreatedAt: time.Now().UTC().UnixMilli(), - Output: "Running stop...", }}, }) if err != nil { @@ -574,6 +629,30 @@ func (p *Server) runProjectImportProvision(ctx, shutdown context.Context, provis } func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sdkproto.DRPCProvisionerClient, job *proto.AcquiredJob) { + var stage string + switch job.GetWorkspaceBuild().Metadata.WorkspaceTransition { + case sdkproto.WorkspaceTransition_START: + stage = "Starting workspace" + case sdkproto.WorkspaceTransition_STOP: + stage = "Stopping workspace" + case sdkproto.WorkspaceTransition_DESTROY: + stage = "Destroying workspace" + } + + _, err := p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: stage, + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } + stream, err := provisioner.Provision(ctx) if err != nil { p.failActiveJobf("provision: %s", err) @@ -675,8 +754,7 @@ func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sd // Return so we stop looping! return default: - p.failActiveJobf("invalid message type %q received from provisioner", - reflect.TypeOf(msg.Type).String()) + p.failActiveJobf("invalid message type %T received from provisioner", msg.Type) return } } diff --git a/provisionerd/provisionerd_test.go b/provisionerd/provisionerd_test.go index 4ce915349a1ce..a15d7e2dd277b 100644 --- a/provisionerd/provisionerd_test.go +++ b/provisionerd/provisionerd_test.go @@ -438,7 +438,7 @@ func TestProvisionerd(t *testing.T) { }, nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { - if len(update.Logs) > 0 { + if len(update.Logs) > 0 && update.Logs[0].Source == proto.LogSource_PROVISIONER { // Close on a log so we know when the job is in progress! close(updateChan) } @@ -507,7 +507,7 @@ func TestProvisionerd(t *testing.T) { }, nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { - if len(update.Logs) > 0 { + if len(update.Logs) > 0 && update.Logs[0].Source == proto.LogSource_PROVISIONER { // Close on a log so we know when the job is in progress! close(updateChan) } From 048f29a8bb192a39d61dd805278cc30d8bd9b531 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 26 Mar 2022 19:24:43 +0000 Subject: [PATCH 02/11] feat: Add stage to build logs This adds a stage property to logs, and refactors the job logs cliui. It also adds tests to the cliui for build logs! --- cli/cliui/cliui.go | 4 + cli/cliui/job.go | 157 ---------------- cli/cliui/provisionerjob.go | 169 ++++++++++++++++++ cli/cliui/provisionerjob_test.go | 166 +++++++++++++++++ cli/projectcreate.go | 10 +- cli/start.go | 5 +- cli/start_test.go | 3 +- cli/workspacecreate.go | 3 +- cli/workspacedelete.go | 3 +- cli/workspacestart.go | 3 +- cli/workspacestop.go | 3 +- cmd/cliui/main.go | 35 +++- coderd/database/databasefake/databasefake.go | 2 +- coderd/database/dump.sql | 1 + coderd/database/migrations/000004_jobs.up.sql | 1 + coderd/database/models.go | 1 + coderd/database/query.sql | 4 +- coderd/database/query.sql.go | 14 +- coderd/projectversions_test.go | 12 +- coderd/provisionerdaemons.go | 1 + coderd/provisionerjobs.go | 1 + coderd/provisionerjobs_test.go | 24 +-- coderd/workspacebuilds_test.go | 16 +- codersdk/provisionerdaemons.go | 1 + provisioner/terraform/provision.go | 9 + provisionerd/proto/provisionerd.pb.go | 102 ++++++----- provisionerd/proto/provisionerd.proto | 5 +- provisionerd/provisionerd.go | 84 ++++++++- provisionerd/provisionerd_test.go | 4 +- 29 files changed, 581 insertions(+), 262 deletions(-) delete mode 100644 cli/cliui/job.go create mode 100644 cli/cliui/provisionerjob.go create mode 100644 cli/cliui/provisionerjob_test.go diff --git a/cli/cliui/cliui.go b/cli/cliui/cliui.go index 5d324e76cd244..e69280cdf4119 100644 --- a/cli/cliui/cliui.go +++ b/cli/cliui/cliui.go @@ -23,7 +23,9 @@ func ValidateNotEmpty(s string) error { // Styles compose visual elements of the UI! var Styles = struct { Bold, + Checkmark, Code, + Crossmark, Field, Keyword, Paragraph, @@ -36,7 +38,9 @@ var Styles = struct { Wrap lipgloss.Style }{ Bold: lipgloss.NewStyle().Bold(true), + Checkmark: defaultStyles.Checkmark, Code: defaultStyles.Code, + Crossmark: defaultStyles.Error.Copy().SetString("✘"), Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}), Keyword: defaultStyles.Keyword, Paragraph: defaultStyles.Paragraph, diff --git a/cli/cliui/job.go b/cli/cliui/job.go deleted file mode 100644 index bd7983a5cfcd9..0000000000000 --- a/cli/cliui/job.go +++ /dev/null @@ -1,157 +0,0 @@ -package cliui - -import ( - "fmt" - "os" - "os/signal" - "time" - - "github.com/briandowns/spinner" - "github.com/charmbracelet/lipgloss" - "github.com/spf13/cobra" - - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" -) - -type JobOptions struct { - Title string - Output bool - Fetch func() (codersdk.ProvisionerJob, error) - Cancel func() error - Logs func() (<-chan codersdk.ProvisionerJobLog, error) -} - -// Job renders a provisioner job. -func Job(cmd *cobra.Command, opts JobOptions) (codersdk.ProvisionerJob, error) { - var ( - spin = spinner.New(spinner.CharSets[5], 100*time.Millisecond, spinner.WithColor("fgGreen")) - - started = false - completed = false - job codersdk.ProvisionerJob - ) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s%s %s\n", Styles.FocusedPrompt, opts.Title, Styles.Placeholder.Render("(ctrl+c to cancel)")) - - spin.Writer = cmd.OutOrStdout() - defer spin.Stop() - - // Refreshes the job state! - refresh := func() { - var err error - job, err = opts.Fetch() - if err != nil { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error())) - return - } - - if !started && job.StartedAt != nil { - spin.Stop() - _, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.Prompt.String()+"Started "+Styles.Placeholder.Render("[%dms]")+"\n", job.StartedAt.Sub(job.CreatedAt).Milliseconds()) - spin.Start() - started = true - } - if !completed && job.CompletedAt != nil { - spin.Stop() - msg := "" - switch job.Status { - case codersdk.ProvisionerJobCanceled: - msg = "Canceled" - case codersdk.ProvisionerJobFailed: - msg = "Completed" - case codersdk.ProvisionerJobSucceeded: - msg = "Built" - } - started := job.CreatedAt - if job.StartedAt != nil { - started = *job.StartedAt - } - _, _ = fmt.Fprintf(cmd.OutOrStderr(), Styles.Prompt.String()+msg+" "+Styles.Placeholder.Render("[%dms]")+"\n", job.CompletedAt.Sub(started).Milliseconds()) - spin.Start() - completed = true - } - - switch job.Status { - case codersdk.ProvisionerJobPending: - spin.Suffix = " Queued" - case codersdk.ProvisionerJobRunning: - spin.Suffix = " Running" - case codersdk.ProvisionerJobCanceling: - spin.Suffix = " Canceling" - } - } - refresh() - spin.Start() - - stopChan := make(chan os.Signal, 1) - defer signal.Stop(stopChan) - go func() { - signal.Notify(stopChan, os.Interrupt) - select { - case <-cmd.Context().Done(): - return - case _, ok := <-stopChan: - if !ok { - return - } - } - signal.Stop(stopChan) - spin.Stop() - _, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+"Gracefully canceling... wait for exit or data loss may occur!\n") - spin.Start() - err := opts.Cancel() - if err != nil { - spin.Stop() - _, _ = fmt.Fprintln(cmd.OutOrStdout(), defaultStyles.Error.Render(err.Error())) - return - } - refresh() - }() - - logs, err := opts.Logs() - if err != nil { - return job, err - } - - firstLog := false - ticker := time.NewTicker(time.Second) - for { - select { - case <-cmd.Context().Done(): - return job, cmd.Context().Err() - case <-ticker.C: - refresh() - if job.CompletedAt != nil { - return job, nil - } - case log, ok := <-logs: - if !ok { - refresh() - return job, nil - } - if !firstLog { - refresh() - firstLog = true - } - if !opts.Output { - continue - } - spin.Stop() - var style lipgloss.Style - switch log.Level { - case database.LogLevelTrace: - style = defaultStyles.Error - case database.LogLevelDebug: - style = defaultStyles.Error - case database.LogLevelError: - style = defaultStyles.Error - case database.LogLevelWarn: - style = Styles.Warn - case database.LogLevelInfo: - style = defaultStyles.Note - } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s %s\n", Styles.Placeholder.Render("|"), style.Render(string(log.Level)), log.Output) - spin.Start() - } - } -} diff --git a/cli/cliui/provisionerjob.go b/cli/cliui/provisionerjob.go new file mode 100644 index 0000000000000..681f583cff427 --- /dev/null +++ b/cli/cliui/provisionerjob.go @@ -0,0 +1,169 @@ +package cliui + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "time" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" +) + +type ProvisionerJobOptions struct { + Fetch func() (codersdk.ProvisionerJob, error) + Cancel func() error + Logs func() (<-chan codersdk.ProvisionerJobLog, error) + + FetchInterval time.Duration +} + +// ProvisionerJob renders a provisioner job with interactive cancellation. +func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { + if opts.FetchInterval == 0 { + opts.FetchInterval = time.Second + } + + var ( + currentStage = "Queued" + currentStageStartedAt = time.Now().UTC() + didLogBetweenStage = false + ctx, cancelFunc = context.WithCancel(cmd.Context()) + + errChan = make(chan error) + job codersdk.ProvisionerJob + jobMutex sync.Mutex + ) + defer cancelFunc() + + printStage := func() { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.Prompt.Render("⧗")+"%s\n", Styles.Field.Render(currentStage)) + } + printStage() + + updateStage := func(stage string, startedAt time.Time) { + if currentStage != "" { + prefix := "" + if !didLogBetweenStage { + prefix = "\033[1A\r" + } + mark := Styles.Checkmark + if job.CompletedAt != nil && job.Status != codersdk.ProvisionerJobSucceeded { + mark = Styles.Crossmark + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), prefix+mark.String()+Styles.Placeholder.Render(" %s [%dms]")+"\n", currentStage, startedAt.Sub(currentStageStartedAt).Milliseconds()) + } + if stage == "" { + return + } + currentStage = stage + currentStageStartedAt = startedAt + didLogBetweenStage = false + printStage() + } + + updateJob := func() { + var err error + jobMutex.Lock() + defer jobMutex.Unlock() + job, err = opts.Fetch() + if err != nil { + errChan <- xerrors.Errorf("fetch: %w", err) + return + } + if job.StartedAt == nil { + return + } + if currentStage != "Queued" { + // If another stage is already running, there's no need + // for us to notify the user we're running! + return + } + updateStage("Running", *job.StartedAt) + } + updateJob() + + // Handles ctrl+c to cancel a job. + stopChan := make(chan os.Signal, 1) + defer signal.Stop(stopChan) + go func() { + signal.Notify(stopChan, os.Interrupt) + select { + case <-ctx.Done(): + return + case _, ok := <-stopChan: + if !ok { + return + } + } + // Stop listening for signals so another one kills it! + signal.Stop(stopChan) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[2K\r\n"+Styles.FocusedPrompt.String()+Styles.Bold.Render("Gracefully canceling... wait for exit or data loss may occur!")+"\n\n") + err := opts.Cancel() + if err != nil { + errChan <- xerrors.Errorf("cancel: %w", err) + return + } + updateJob() + }() + + logs, err := opts.Logs() + if err != nil { + return xerrors.Errorf("logs: %w", err) + } + + ticker := time.NewTicker(opts.FetchInterval) + for { + select { + case err = <-errChan: + return err + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + updateJob() + case log, ok := <-logs: + if !ok { + // The logs stream will end when the job does, + // so it's safe to + updateJob() + jobMutex.Lock() + if job.CompletedAt != nil { + updateStage("", *job.CompletedAt) + } + switch job.Status { + case codersdk.ProvisionerJobCanceled: + jobMutex.Unlock() + return Canceled + case codersdk.ProvisionerJobSucceeded: + jobMutex.Unlock() + return nil + case codersdk.ProvisionerJobFailed: + } + jobMutex.Unlock() + return xerrors.New(job.Error) + } + output := "" + switch log.Level { + case database.LogLevelTrace, database.LogLevelDebug, database.LogLevelError: + output = defaultStyles.Error.Render(log.Output) + case database.LogLevelWarn: + output = Styles.Warn.Render(log.Output) + case database.LogLevelInfo: + output = log.Output + } + if log.Stage != currentStage && log.Stage != "" { + jobMutex.Lock() + updateStage(log.Stage, log.CreatedAt) + jobMutex.Unlock() + continue + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", Styles.Placeholder.Render(" "), output) + didLogBetweenStage = true + } + } +} diff --git a/cli/cliui/provisionerjob_test.go b/cli/cliui/provisionerjob_test.go new file mode 100644 index 0000000000000..3aba6256fea13 --- /dev/null +++ b/cli/cliui/provisionerjob_test.go @@ -0,0 +1,166 @@ +package cliui_test + +import ( + "context" + "os" + "runtime" + "sync" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" +) + +// This cannot be ran in parallel because it uses a signal. +// nolint:tparallel +func TestProvisionerJob(t *testing.T) { + t.Run("NoLogs", func(t *testing.T) { + t.Parallel() + + test := newProvisionerJob(t) + go func() { + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobRunning + now := database.Now() + test.Job.StartedAt = &now + test.JobMutex.Unlock() + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobSucceeded + now = database.Now() + test.Job.CompletedAt = &now + test.JobMutex.Unlock() + close(test.Logs) + }() + test.PTY.ExpectMatch("Queued") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Queued") + test.PTY.ExpectMatch("Running") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Running") + }) + + t.Run("Stages", func(t *testing.T) { + t.Parallel() + + test := newProvisionerJob(t) + go func() { + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobRunning + now := database.Now() + test.Job.StartedAt = &now + test.Logs <- codersdk.ProvisionerJobLog{ + CreatedAt: database.Now(), + Stage: "Something", + } + test.JobMutex.Unlock() + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobSucceeded + now = database.Now() + test.Job.CompletedAt = &now + test.JobMutex.Unlock() + close(test.Logs) + }() + test.PTY.ExpectMatch("Queued") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Queued") + test.PTY.ExpectMatch("Something") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Something") + }) + + // This cannot be ran in parallel because it uses a signal. + //nolint:paralleltest + t.Run("Cancel", func(t *testing.T) { + if runtime.GOOS == "windows" { + // Sending interrupt signal isn't supported on Windows! + t.SkipNow() + } + + test := newProvisionerJob(t) + go func() { + <-test.Next + currentProcess, err := os.FindProcess(os.Getpid()) + require.NoError(t, err) + err = currentProcess.Signal(os.Interrupt) + require.NoError(t, err) + <-test.Next + test.JobMutex.Lock() + test.Job.Status = codersdk.ProvisionerJobCanceled + now := database.Now() + test.Job.CompletedAt = &now + test.JobMutex.Unlock() + close(test.Logs) + }() + test.PTY.ExpectMatch("Queued") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Gracefully canceling") + test.Next <- struct{}{} + test.PTY.ExpectMatch("Queued") + }) +} + +type provisionerJobTest struct { + Next chan struct{} + Job *codersdk.ProvisionerJob + JobMutex *sync.Mutex + Logs chan codersdk.ProvisionerJobLog + PTY *ptytest.PTY +} + +func newProvisionerJob(t *testing.T) provisionerJobTest { + job := &codersdk.ProvisionerJob{ + Status: codersdk.ProvisionerJobPending, + CreatedAt: database.Now(), + } + jobLock := sync.Mutex{} + logs := make(chan codersdk.ProvisionerJobLog, 1) + cmd := &cobra.Command{ + RunE: func(cmd *cobra.Command, args []string) error { + return cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ + FetchInterval: time.Millisecond, + Fetch: func() (codersdk.ProvisionerJob, error) { + jobLock.Lock() + defer jobLock.Unlock() + return *job, nil + }, + Cancel: func() error { + return nil + }, + Logs: func() (<-chan codersdk.ProvisionerJobLog, error) { + return logs, nil + }, + }) + }, + } + ptty := ptytest.New(t) + cmd.SetOutput(ptty.Output()) + cmd.SetIn(ptty.Input()) + done := make(chan struct{}) + go func() { + defer close(done) + err := cmd.ExecuteContext(context.Background()) + if err != nil { + require.ErrorIs(t, err, cliui.Canceled) + } + }() + t.Cleanup(func() { + <-done + }) + return provisionerJobTest{ + Next: make(chan struct{}), + Job: job, + JobMutex: &jobLock, + Logs: logs, + PTY: ptty, + } +} diff --git a/cli/projectcreate.go b/cli/projectcreate.go index 232b8f571f8d6..1465558369e83 100644 --- a/cli/projectcreate.go +++ b/cli/projectcreate.go @@ -9,7 +9,6 @@ import ( "time" "github.com/briandowns/spinner" - "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -126,8 +125,7 @@ func createValidProjectVersion(cmd *cobra.Command, client *codersdk.Client, orga return nil, nil, err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Building project...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { version, err := client.ProjectVersion(cmd.Context(), version.ID) return version.Job, err @@ -140,7 +138,9 @@ func createValidProjectVersion(cmd *cobra.Command, client *codersdk.Client, orga }, }) if err != nil { - return nil, nil, err + if !provisionerd.IsMissingParameterError(err.Error()) { + return nil, nil, err + } } version, err = client.ProjectVersion(cmd.Context(), version.ID) if err != nil { @@ -192,7 +192,7 @@ func createValidProjectVersion(cmd *cobra.Command, client *codersdk.Client, orga return nil, nil, xerrors.New(version.Job.Error) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Successfully imported project source!\n", color.HiGreenString("✓")) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Checkmark.String()+" Successfully imported project source!\n") resources, err := client.ProjectVersionResources(cmd.Context(), version.ID) if err != nil { diff --git a/cli/start.go b/cli/start.go index 00e4996dc6905..99260ed78565d 100644 --- a/cli/start.go +++ b/cli/start.go @@ -109,7 +109,7 @@ func start() *cobra.Command { if err != nil { return xerrors.Errorf("create tunnel: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), cliui.Styles.Paragraph.Render(cliui.Styles.Wrap.Render(cliui.Styles.Prompt.String()+`Tunnel started. Your deployment is accessible at:`))+"\n "+cliui.Styles.Field.Render(accessURL)+"\n") } } validator, err := idtoken.NewValidator(cmd.Context(), option.WithoutAuthentication()) @@ -262,8 +262,7 @@ func start() *cobra.Command { return xerrors.Errorf("delete workspace: %w", err) } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: fmt.Sprintf("Deleting workspace %s...", cliui.Styles.Keyword.Render(workspace.Name)), + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), build.ID) return build.Job, err diff --git a/cli/start_test.go b/cli/start_test.go index 2a35f2ca1e713..00366e4792441 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -26,8 +26,9 @@ import ( "github.com/coder/coder/codersdk" ) +// This cannot be ran in parallel because it uses a signal. +// nolint:tparallel func TestStart(t *testing.T) { - t.Parallel() t.Run("Production", func(t *testing.T) { t.Parallel() if runtime.GOOS != "linux" || testing.Short() { diff --git a/cli/workspacecreate.go b/cli/workspacecreate.go index 93b1997047dcd..0ab036909e981 100644 --- a/cli/workspacecreate.go +++ b/cli/workspacecreate.go @@ -146,8 +146,7 @@ func workspaceCreate() *cobra.Command { if err != nil { return err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Building workspace...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), workspace.LatestBuild.ID) return build.Job, err diff --git a/cli/workspacedelete.go b/cli/workspacedelete.go index 4dfd59a57bf1f..b01b75f0dc87a 100644 --- a/cli/workspacedelete.go +++ b/cli/workspacedelete.go @@ -32,8 +32,7 @@ func workspaceDelete() *cobra.Command { if err != nil { return err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Deleting workspace...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), build.ID) return build.Job, err diff --git a/cli/workspacestart.go b/cli/workspacestart.go index f19ceb2aeaa38..a477b222443bc 100644 --- a/cli/workspacestart.go +++ b/cli/workspacestart.go @@ -31,8 +31,7 @@ func workspaceStart() *cobra.Command { if err != nil { return err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Starting workspace...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), build.ID) return build.Job, err diff --git a/cli/workspacestop.go b/cli/workspacestop.go index 4b07b478d7717..28071e4b15ec5 100644 --- a/cli/workspacestop.go +++ b/cli/workspacestop.go @@ -31,8 +31,7 @@ func workspaceStop() *cobra.Command { if err != nil { return err } - _, err = cliui.Job(cmd, cliui.JobOptions{ - Title: "Stopping workspace...", + err = cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { build, err := client.WorkspaceBuild(cmd.Context(), build.ID) return build.Job, err diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index bd8829cc1be00..8ad66aec4ad37 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -94,7 +94,7 @@ func main() { job.Status = codersdk.ProvisionerJobSucceeded }() - _, err := cliui.Job(cmd, cliui.JobOptions{ + err := cliui.ProvisionerJob(cmd, cliui.ProvisionerJobOptions{ Fetch: func() (codersdk.ProvisionerJob, error) { return job, nil }, @@ -102,16 +102,41 @@ func main() { logs := make(chan codersdk.ProvisionerJobLog) go func() { defer close(logs) - ticker := time.NewTicker(500 * time.Millisecond) + ticker := time.NewTicker(100 * time.Millisecond) + count := 0 for { select { case <-cmd.Context().Done(): return case <-ticker.C: - logs <- codersdk.ProvisionerJobLog{ - Output: "Some log", - Level: database.LogLevelInfo, + if job.Status == codersdk.ProvisionerJobSucceeded || job.Status == codersdk.ProvisionerJobCanceled { + return } + log := codersdk.ProvisionerJobLog{ + CreatedAt: time.Now(), + Output: fmt.Sprintf("Some log %d", count), + Level: database.LogLevelInfo, + } + switch { + case count == 10: + log.Stage = "Setting Up" + case count == 20: + log.Stage = "Executing Hook" + case count == 30: + log.Stage = "Parsing Variables" + case count == 40: + log.Stage = "Provisioning" + case count == 50: + log.Stage = "Cleaning Up" + } + if count%5 == 0 { + log.Level = database.LogLevelWarn + } + count++ + if log.Output == "" && log.Stage == "" { + continue + } + logs <- log } } }() diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 82d1c4a472cbc..618c1c6daf0cd 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -898,6 +898,7 @@ func (q *fakeQuerier) InsertProvisionerJobLogs(_ context.Context, arg database.I CreatedAt: arg.CreatedAt[index], Source: arg.Source[index], Level: arg.Level[index], + Stage: arg.Stage[index], Output: output, }) } @@ -1201,7 +1202,6 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar } job.UpdatedAt = arg.UpdatedAt job.CompletedAt = arg.CompletedAt - job.CanceledAt = arg.CanceledAt job.Error = arg.Error q.provisionerJobs[index] = job return nil diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index dc3d6a7c6e50d..f0150fb5b825a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -190,6 +190,7 @@ CREATE TABLE provisioner_job_logs ( created_at timestamp with time zone NOT NULL, source log_source NOT NULL, level log_level NOT NULL, + stage character varying(128) NOT NULL, output character varying(1024) NOT NULL ); diff --git a/coderd/database/migrations/000004_jobs.up.sql b/coderd/database/migrations/000004_jobs.up.sql index ddddeba5c0bec..bf9b731d4e744 100644 --- a/coderd/database/migrations/000004_jobs.up.sql +++ b/coderd/database/migrations/000004_jobs.up.sql @@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS provisioner_job_logs ( created_at timestamptz NOT NULL, source log_source NOT NULL, level log_level NOT NULL, + stage varchar(128) NOT NULL, output varchar(1024) NOT NULL ); diff --git a/coderd/database/models.go b/coderd/database/models.go index 2296fec308c6d..8e4eecd5e4cff 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -388,6 +388,7 @@ type ProvisionerJobLog struct { CreatedAt time.Time `db:"created_at" json:"created_at"` Source LogSource `db:"source" json:"source"` Level LogLevel `db:"level" json:"level"` + Stage string `db:"stage" json:"stage"` Output string `db:"output" json:"output"` } diff --git a/coderd/database/query.sql b/coderd/database/query.sql index bb13ade06f94d..7bc2c5f3245b7 100644 --- a/coderd/database/query.sql +++ b/coderd/database/query.sql @@ -482,6 +482,7 @@ SELECT unnest(@created_at :: timestamptz [ ]) AS created_at, unnest(@source :: log_source [ ]) as source, unnest(@level :: log_level [ ]) as level, + unnest(@stage :: varchar(128) [ ]) as stage, unnest(@output :: varchar(1024) [ ]) as output RETURNING *; -- name: InsertOrganization :one @@ -757,8 +758,7 @@ UPDATE SET updated_at = $2, completed_at = $3, - canceled_at = $4, - error = $5 + error = $4 WHERE id = $1; diff --git a/coderd/database/query.sql.go b/coderd/database/query.sql.go index b7f58c1f746c7..38da2dddba6de 100644 --- a/coderd/database/query.sql.go +++ b/coderd/database/query.sql.go @@ -829,7 +829,7 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI const getProvisionerLogsByIDBetween = `-- name: GetProvisionerLogsByIDBetween :many SELECT - id, job_id, created_at, source, level, output + id, job_id, created_at, source, level, stage, output FROM provisioner_job_logs WHERE @@ -863,6 +863,7 @@ func (q *sqlQuerier) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetP &i.CreatedAt, &i.Source, &i.Level, + &i.Stage, &i.Output, ); err != nil { return nil, err @@ -2121,7 +2122,8 @@ SELECT unnest($3 :: timestamptz [ ]) AS created_at, unnest($4 :: log_source [ ]) as source, unnest($5 :: log_level [ ]) as level, - unnest($6 :: varchar(1024) [ ]) as output RETURNING id, job_id, created_at, source, level, output + unnest($6 :: varchar(128) [ ]) as stage, + unnest($7 :: varchar(1024) [ ]) as output RETURNING id, job_id, created_at, source, level, stage, output ` type InsertProvisionerJobLogsParams struct { @@ -2130,6 +2132,7 @@ type InsertProvisionerJobLogsParams struct { CreatedAt []time.Time `db:"created_at" json:"created_at"` Source []LogSource `db:"source" json:"source"` Level []LogLevel `db:"level" json:"level"` + Stage []string `db:"stage" json:"stage"` Output []string `db:"output" json:"output"` } @@ -2140,6 +2143,7 @@ func (q *sqlQuerier) InsertProvisionerJobLogs(ctx context.Context, arg InsertPro pq.Array(arg.CreatedAt), pq.Array(arg.Source), pq.Array(arg.Level), + pq.Array(arg.Stage), pq.Array(arg.Output), ) if err != nil { @@ -2155,6 +2159,7 @@ func (q *sqlQuerier) InsertProvisionerJobLogs(ctx context.Context, arg InsertPro &i.CreatedAt, &i.Source, &i.Level, + &i.Stage, &i.Output, ); err != nil { return nil, err @@ -2617,8 +2622,7 @@ UPDATE SET updated_at = $2, completed_at = $3, - canceled_at = $4, - error = $5 + error = $4 WHERE id = $1 ` @@ -2627,7 +2631,6 @@ type UpdateProvisionerJobWithCompleteByIDParams struct { ID uuid.UUID `db:"id" json:"id"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"` - CanceledAt sql.NullTime `db:"canceled_at" json:"canceled_at"` Error sql.NullString `db:"error" json:"error"` } @@ -2636,7 +2639,6 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a arg.ID, arg.UpdatedAt, arg.CompletedAt, - arg.CanceledAt, arg.Error, ) return err diff --git a/coderd/projectversions_test.go b/coderd/projectversions_test.go index 15bc4bfc8e969..7a58600202a2a 100644 --- a/coderd/projectversions_test.go +++ b/coderd/projectversions_test.go @@ -94,9 +94,7 @@ func TestPatchCancelProjectVersion(t *testing.T) { var err error version, err = client.ProjectVersion(context.Background(), version.ID) require.NoError(t, err) - // The echo provisioner doesn't respond to a shutdown request, - // so the job cancel will time out and fail. - return version.Job.Status == codersdk.ProvisionerJobFailed + return version.Job.Status == codersdk.ProvisionerJobCanceled }, 5*time.Second, 25*time.Millisecond) }) } @@ -274,6 +272,10 @@ func TestProjectVersionLogs(t *testing.T) { t.Cleanup(cancelFunc) logs, err := client.ProjectVersionLogsAfter(ctx, version.ID, before) require.NoError(t, err) - log := <-logs - require.Equal(t, "example", log.Output) + for { + _, ok := <-logs + if !ok { + return + } + } } diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index ce203c283b89e..2c3d3f2ddc961 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -301,6 +301,7 @@ func (server *provisionerdServer) UpdateJob(ctx context.Context, request *proto. insertParams.ID = append(insertParams.ID, uuid.New()) insertParams.CreatedAt = append(insertParams.CreatedAt, time.UnixMilli(log.CreatedAt)) insertParams.Level = append(insertParams.Level, logLevel) + insertParams.Stage = append(insertParams.Stage, log.Stage) insertParams.Source = append(insertParams.Source, logSource) insertParams.Output = append(insertParams.Output, log.Output) } diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 7e5682e65b67a..2fa6f253f8292 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -224,6 +224,7 @@ func convertProvisionerJobLog(provisionerJobLog database.ProvisionerJobLog) code CreatedAt: provisionerJobLog.CreatedAt, Source: provisionerJobLog.Source, Level: provisionerJobLog.Level, + Stage: provisionerJobLog.Stage, Output: provisionerJobLog.Output, } } diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index c4aef5c7550ca..70afdbf609f80 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -45,12 +45,12 @@ func TestProvisionerJobLogs(t *testing.T) { t.Cleanup(cancelFunc) logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before) require.NoError(t, err) - log, ok := <-logs - require.True(t, ok) - require.Equal(t, "log-output", log.Output) - // Make sure the channel automatically closes! - _, ok = <-logs - require.False(t, ok) + for { + _, ok := <-logs + if !ok { + return + } + } }) t.Run("StreamWhileRunning", func(t *testing.T) { @@ -81,10 +81,12 @@ func TestProvisionerJobLogs(t *testing.T) { t.Cleanup(cancelFunc) logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before) require.NoError(t, err) - log := <-logs - require.Equal(t, "log-output", log.Output) - _, ok := <-logs - require.False(t, ok) + for { + _, ok := <-logs + if !ok { + return + } + } }) t.Run("List", func(t *testing.T) { @@ -113,6 +115,6 @@ func TestProvisionerJobLogs(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) logs, err := client.WorkspaceBuildLogsBefore(context.Background(), workspace.LatestBuild.ID, time.Now()) require.NoError(t, err) - require.Len(t, logs, 1) + require.Greater(t, len(logs), 1) }) } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 2e9814cf713b8..a5b1c520db80d 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -57,9 +57,7 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { var err error build, err = client.WorkspaceBuild(context.Background(), build.ID) require.NoError(t, err) - // The echo provisioner doesn't respond to a shutdown request, - // so the job cancel will time out and fail. - return build.Job.Status == codersdk.ProvisionerJobFailed + return build.Job.Status == codersdk.ProvisionerJobCanceled }, 5*time.Second, 25*time.Millisecond) } @@ -159,6 +157,14 @@ func TestWorkspaceBuildLogs(t *testing.T) { t.Cleanup(cancelFunc) logs, err := client.WorkspaceBuildLogsAfter(ctx, workspace.LatestBuild.ID, before) require.NoError(t, err) - log := <-logs - require.Equal(t, "example", log.Output) + for { + log, ok := <-logs + if !ok { + break + } + if log.Output == "example" { + return + } + } + require.Fail(t, "example message never happened") } diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index 6530dab33c9e4..a66f8d3ff2ac2 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -49,6 +49,7 @@ type ProvisionerJobLog struct { CreatedAt time.Time `json:"created_at"` Source database.LogSource `json:"log_source"` Level database.LogLevel `json:"log_level"` + Stage string `json:"stage"` Output string `json:"output"` } diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index b321d4e6f6131..62ea5ef9b2764 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -210,6 +210,15 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro err = cmd.Run() if err != nil { if start.DryRun { + if shutdown.Err() != nil { + return stream.Send(&proto.Provision_Response{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Error: err.Error(), + }, + }, + }) + } return xerrors.Errorf("plan terraform: %w", err) } errorMessage := err.Error() diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index 220c7149d08f2..782bccb8c955f 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -422,7 +422,8 @@ type Log struct { Source LogSource `protobuf:"varint,1,opt,name=source,proto3,enum=provisionerd.LogSource" json:"source,omitempty"` Level proto.LogLevel `protobuf:"varint,2,opt,name=level,proto3,enum=provisioner.LogLevel" json:"level,omitempty"` CreatedAt int64 `protobuf:"varint,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - Output string `protobuf:"bytes,4,opt,name=output,proto3" json:"output,omitempty"` + Stage string `protobuf:"bytes,4,opt,name=stage,proto3" json:"stage,omitempty"` + Output string `protobuf:"bytes,5,opt,name=output,proto3" json:"output,omitempty"` } func (x *Log) Reset() { @@ -478,6 +479,13 @@ func (x *Log) GetCreatedAt() int64 { return 0 } +func (x *Log) GetStage() string { + if x != nil { + return x.Stage + } + return "" +} + func (x *Log) GetOutput() string { if x != nil { return x.Output @@ -1026,7 +1034,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x0d, 0x73, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x42, 0x06, - 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2f, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, @@ -1034,50 +1042,52 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6f, - 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, - 0x70, 0x75, 0x74, 0x22, 0x9b, 0x01, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, - 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, - 0x25, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, - 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x12, 0x49, 0x0a, 0x11, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, - 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, - 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, - 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, - 0x73, 0x22, 0x77, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, - 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, - 0x65, 0x64, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, - 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, - 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, - 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2a, 0x34, 0x0a, 0x09, 0x4c, 0x6f, - 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x56, 0x49, - 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, 0x4d, 0x4f, 0x4e, 0x10, 0x00, 0x12, - 0x0f, 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x10, 0x01, - 0x32, 0x98, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, - 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0a, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, - 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, - 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, - 0x62, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, - 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, - 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, 0x4a, 0x6f, 0x62, 0x12, 0x17, 0x2e, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, - 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3e, 0x0a, 0x0b, 0x43, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, - 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, - 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, - 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x2b, 0x5a, 0x29, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, - 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, + 0x74, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x67, + 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x9b, 0x01, 0x0a, 0x10, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, + 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x64, 0x2e, 0x4c, 0x6f, 0x67, 0x52, 0x04, 0x6c, 0x6f, 0x67, 0x73, 0x12, 0x49, 0x0a, 0x11, + 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x53, + 0x63, 0x68, 0x65, 0x6d, 0x61, 0x52, 0x10, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, + 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x73, 0x22, 0x77, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, + 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, + 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x65, 0x64, 0x12, 0x46, 0x0a, 0x10, 0x70, 0x61, 0x72, 0x61, + 0x6d, 0x65, 0x74, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, + 0x2e, 0x50, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, + 0x0f, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, + 0x2a, 0x34, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, + 0x12, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x52, 0x5f, 0x44, 0x41, 0x45, + 0x4d, 0x4f, 0x4e, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, + 0x4f, 0x4e, 0x45, 0x52, 0x10, 0x01, 0x32, 0x98, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x12, 0x3c, 0x0a, 0x0a, + 0x41, 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, + 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, + 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x41, + 0x63, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x4c, 0x0a, 0x09, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x6f, 0x62, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, + 0x4a, 0x6f, 0x62, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, + 0x72, 0x64, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x12, 0x3e, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4a, 0x6f, 0x62, + 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x1a, 0x13, 0x2e, 0x70, + 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2e, 0x45, 0x6d, 0x70, 0x74, + 0x79, 0x42, 0x2b, 0x5a, 0x29, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/provisionerd/proto/provisionerd.proto b/provisionerd/proto/provisionerd.proto index e7c76d228bdad..88bf3fa695356 100644 --- a/provisionerd/proto/provisionerd.proto +++ b/provisionerd/proto/provisionerd.proto @@ -74,7 +74,8 @@ message Log { LogSource source = 1; provisioner.LogLevel level = 2; int64 created_at = 3; - string output = 4; + string stage = 4; + string output = 5; } // This message should be sent periodically as a heartbeat. @@ -107,4 +108,4 @@ service ProvisionerDaemon { // CompleteJob indicates a job has been completed. rpc CompleteJob(CompletedJob) returns (Empty); -} \ No newline at end of file +} diff --git a/provisionerd/provisionerd.go b/provisionerd/provisionerd.go index 3899ac59212c1..ffdcbf1e7df69 100644 --- a/provisionerd/provisionerd.go +++ b/provisionerd/provisionerd.go @@ -298,6 +298,20 @@ func (p *Server) runJob(ctx context.Context, job *proto.AcquiredJob) { return } + _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: "Setting up", + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } + p.opts.Logger.Info(ctx, "unpacking project source archive", slog.F("size_bytes", len(job.ProjectSourceArchive))) reader := tar.NewReader(bytes.NewBuffer(job.ProjectSourceArchive)) for { @@ -377,11 +391,39 @@ func (p *Server) runJob(ctx context.Context, job *proto.AcquiredJob) { // Ensure the job is still running to output. // It's possible the job has failed. if p.isRunningJob() { + _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: "Cleaning Up", + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } + p.opts.Logger.Info(context.Background(), "completed job", slog.F("id", job.JobId)) } } func (p *Server) runProjectImport(ctx, shutdown context.Context, provisioner sdkproto.DRPCProvisionerClient, job *proto.AcquiredJob) { + _, err := p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: "Parse parameters", + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } + parameterSchemas, err := p.runProjectImportParse(ctx, provisioner, job) if err != nil { p.failActiveJobf("run parse: %s", err) @@ -409,6 +451,19 @@ func (p *Server) runProjectImport(ctx, shutdown context.Context, provisioner sdk } } + _, err = p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: "Detecting resources when started", + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } startResources, err := p.runProjectImportProvision(ctx, shutdown, provisioner, job, updateResponse.ParameterValues, &sdkproto.Provision_Metadata{ CoderUrl: job.GetProjectImport().Metadata.CoderUrl, WorkspaceTransition: sdkproto.WorkspaceTransition_START, @@ -422,8 +477,8 @@ func (p *Server) runProjectImport(ctx, shutdown context.Context, provisioner sdk Logs: []*proto.Log{{ Source: proto.LogSource_PROVISIONER_DAEMON, Level: sdkproto.LogLevel_INFO, + Stage: "Detecting resources when stopped", CreatedAt: time.Now().UTC().UnixMilli(), - Output: "Running stop...", }}, }) if err != nil { @@ -574,6 +629,30 @@ func (p *Server) runProjectImportProvision(ctx, shutdown context.Context, provis } func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sdkproto.DRPCProvisionerClient, job *proto.AcquiredJob) { + var stage string + switch job.GetWorkspaceBuild().Metadata.WorkspaceTransition { + case sdkproto.WorkspaceTransition_START: + stage = "Starting workspace" + case sdkproto.WorkspaceTransition_STOP: + stage = "Stopping workspace" + case sdkproto.WorkspaceTransition_DESTROY: + stage = "Destroying workspace" + } + + _, err := p.client.UpdateJob(ctx, &proto.UpdateJobRequest{ + JobId: job.GetJobId(), + Logs: []*proto.Log{{ + Source: proto.LogSource_PROVISIONER_DAEMON, + Level: sdkproto.LogLevel_INFO, + Stage: stage, + CreatedAt: time.Now().UTC().UnixMilli(), + }}, + }) + if err != nil { + p.failActiveJobf("write log: %s", err) + return + } + stream, err := provisioner.Provision(ctx) if err != nil { p.failActiveJobf("provision: %s", err) @@ -675,8 +754,7 @@ func (p *Server) runWorkspaceBuild(ctx, shutdown context.Context, provisioner sd // Return so we stop looping! return default: - p.failActiveJobf("invalid message type %q received from provisioner", - reflect.TypeOf(msg.Type).String()) + p.failActiveJobf("invalid message type %T received from provisioner", msg.Type) return } } diff --git a/provisionerd/provisionerd_test.go b/provisionerd/provisionerd_test.go index 4ce915349a1ce..a15d7e2dd277b 100644 --- a/provisionerd/provisionerd_test.go +++ b/provisionerd/provisionerd_test.go @@ -438,7 +438,7 @@ func TestProvisionerd(t *testing.T) { }, nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { - if len(update.Logs) > 0 { + if len(update.Logs) > 0 && update.Logs[0].Source == proto.LogSource_PROVISIONER { // Close on a log so we know when the job is in progress! close(updateChan) } @@ -507,7 +507,7 @@ func TestProvisionerd(t *testing.T) { }, nil }, updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) { - if len(update.Logs) > 0 { + if len(update.Logs) > 0 && update.Logs[0].Source == proto.LogSource_PROVISIONER { // Close on a log so we know when the job is in progress! close(updateChan) } From 2152758b8753d29d8001d91d0428f1e52eedad98 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 27 Mar 2022 00:06:38 +0000 Subject: [PATCH 03/11] feat: Add config-ssh and tests for resiliency --- agent/agent_test.go | 10 ++++- agent/conn.go | 9 ++++ cli/root.go | 2 +- cli/ssh.go | 20 ++++----- cli/ssh_test.go | 77 +++++++++++++++++++++++++++++++++ cli/workspaces.go | 2 +- coderd/coderdtest/coderdtest.go | 1 + codersdk/workspaceresources.go | 10 +---- peerbroker/dial.go | 8 ++++ peerbroker/listen.go | 8 ++-- 10 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 cli/ssh_test.go diff --git a/agent/agent_test.go b/agent/agent_test.go index 44c97f4d92dec..3f31ab26d48ad 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -39,7 +39,10 @@ func TestAgent(t *testing.T) { t.Cleanup(func() { _ = conn.Close() }) - client := agent.Conn{conn} + client := agent.Conn{ + Negotiator: api, + Conn: conn, + } sshClient, err := client.SSHClient() require.NoError(t, err) session, err := sshClient.NewSession() @@ -65,7 +68,10 @@ func TestAgent(t *testing.T) { t.Cleanup(func() { _ = conn.Close() }) - client := &agent.Conn{conn} + client := &agent.Conn{ + Negotiator: api, + Conn: conn, + } sshClient, err := client.SSHClient() require.NoError(t, err) session, err := sshClient.NewSession() diff --git a/agent/conn.go b/agent/conn.go index 6867f9e5747cb..7579a86986964 100644 --- a/agent/conn.go +++ b/agent/conn.go @@ -8,11 +8,15 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/peer" + "github.com/coder/coder/peerbroker/proto" ) // Conn wraps a peer connection with helper functions to // communicate with the agent. type Conn struct { + // Negotiator is responsible for exchanging messages. + Negotiator proto.DRPCPeerBrokerClient + *peer.Conn } @@ -48,3 +52,8 @@ func (c *Conn) SSHClient() (*ssh.Client, error) { } return ssh.NewClient(sshConn, channels, requests), nil } + +func (c *Conn) Close() error { + _ = c.Negotiator.DRPCConn().Close() + return c.Conn.Close() +} diff --git a/cli/root.go b/cli/root.go index 5b258ebebc742..7da96cc67fff1 100644 --- a/cli/root.go +++ b/cli/root.go @@ -64,7 +64,7 @@ func Root() *cobra.Command { projects(), users(), workspaces(), - workspaceSSH(), + ssh(), workspaceTunnel(), ) diff --git a/cli/ssh.go b/cli/ssh.go index ab9fa14847e77..a4f16d3c26780 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -1,18 +1,15 @@ package cli import ( - "os" - "github.com/spf13/cobra" - "golang.org/x/crypto/ssh" - "golang.org/x/term" + gossh "golang.org/x/crypto/ssh" "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" ) -func workspaceSSH() *cobra.Command { +func ssh() *cobra.Command { cmd := &cobra.Command{ Use: "ssh [resource]", RunE: func(cmd *cobra.Command, args []string) error { @@ -68,6 +65,7 @@ func workspaceSSH() *cobra.Command { if err != nil { return err } + defer conn.Close() sshClient, err := conn.SSHClient() if err != nil { return err @@ -77,16 +75,16 @@ func workspaceSSH() *cobra.Command { if err != nil { return err } - _, _ = term.MakeRaw(int(os.Stdin.Fd())) - err = sshSession.RequestPty("xterm-256color", 128, 128, ssh.TerminalModes{ - ssh.OCRNL: 1, + + err = sshSession.RequestPty("xterm-256color", 128, 128, gossh.TerminalModes{ + gossh.OCRNL: 1, }) if err != nil { return err } - sshSession.Stdin = os.Stdin - sshSession.Stdout = os.Stdout - sshSession.Stderr = os.Stderr + sshSession.Stdin = cmd.InOrStdin() + sshSession.Stdout = cmd.OutOrStdout() + sshSession.Stderr = cmd.OutOrStdout() err = sshSession.Shell() if err != nil { return err diff --git a/cli/ssh_test.go b/cli/ssh_test.go new file mode 100644 index 0000000000000..754709634c60a --- /dev/null +++ b/cli/ssh_test.go @@ -0,0 +1,77 @@ +package cli_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/agent" + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/peer" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" + "github.com/coder/coder/pty/ptytest" +) + +func TestSSH(t *testing.T) { + t.Parallel() + t.Run("Echo", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + agentToken := uuid.NewString() + version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "dev", + Type: "google_compute_instance", + Agent: &proto.Agent{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: agentToken, + }, + }, + }}, + }, + }, + }}, + }) + coderdtest.AwaitProjectVersionJob(t, client, version.ID) + project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, "", project.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = agentToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + defer agentCloser.Close() + coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + + cmd, root := clitest.New(t, "ssh", workspace.Name) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + // Shells on Mac, Windows, and Linux all exit shells with the "exit" command. + pty.WriteLine("exit") + <-doneChan + }) +} diff --git a/cli/workspaces.go b/cli/workspaces.go index 125d81d31b3bf..067d45e3d0cdd 100644 --- a/cli/workspaces.go +++ b/cli/workspaces.go @@ -18,7 +18,7 @@ func workspaces() *cobra.Command { cmd.AddCommand(workspaceShow()) cmd.AddCommand(workspaceStop()) cmd.AddCommand(workspaceStart()) - cmd.AddCommand(workspaceSSH()) + cmd.AddCommand(ssh()) cmd.AddCommand(workspaceUpdate()) return cmd diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 72a2af177d468..8257ca19b8ca6 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -251,6 +251,7 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID if resource.Agent == nil { continue } + // fmt.Printf("resources: %+v\n", resource.Agent) if resource.Agent.FirstConnectedAt == nil { return false } diff --git a/codersdk/workspaceresources.go b/codersdk/workspaceresources.go index 07bffb92932db..593026b7548cb 100644 --- a/codersdk/workspaceresources.go +++ b/codersdk/workspaceresources.go @@ -133,15 +133,9 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, resource uuid.UUID, ice if err != nil { return nil, xerrors.Errorf("dial peer: %w", err) } - go func() { - // The stream is kept alive to renegotiate the RTC connection - // if need-be. The calling context can be canceled to end - // the negotiation stream, but not the peer connection. - <-peerConn.Closed() - _ = conn.Close(websocket.StatusNormalClosure, "") - }() return &agent.Conn{ - Conn: peerConn, + Negotiator: client, + Conn: peerConn, }, nil } diff --git a/peerbroker/dial.go b/peerbroker/dial.go index 6d431e9ac1d86..61ef7b409a597 100644 --- a/peerbroker/dial.go +++ b/peerbroker/dial.go @@ -1,6 +1,9 @@ package peerbroker import ( + "context" + "errors" + "io" "reflect" "github.com/pion/webrtc/v3" @@ -54,6 +57,11 @@ func Dial(stream proto.DRPCPeerBroker_NegotiateConnectionClient, iceServers []we for { serverToClientMessage, err := stream.Recv() if err != nil { + // p2p connections should never die if this stream does due + // to proper closure or context cancellation! + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return + } _ = peerConn.CloseWithError(xerrors.Errorf("recv: %w", err)) return } diff --git a/peerbroker/listen.go b/peerbroker/listen.go index 7a134f9937a17..c68dfafa19af0 100644 --- a/peerbroker/listen.go +++ b/peerbroker/listen.go @@ -166,8 +166,10 @@ func (b *peerBrokerService) NegotiateConnection(stream proto.DRPCPeerBroker_Nego for { clientToServerMessage, err := stream.Recv() if err != nil { - if errors.Is(err, io.EOF) { - break + // p2p connections should never die if this stream does due + // to proper closure or context cancellation! + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return nil } return peerConn.CloseWithError(xerrors.Errorf("recv: %w", err)) } @@ -186,6 +188,4 @@ func (b *peerBrokerService) NegotiateConnection(stream proto.DRPCPeerBroker_Nego return peerConn.CloseWithError(xerrors.Errorf("unhandled message: %s", reflect.TypeOf(clientToServerMessage).String())) } } - - return nil } From 172d4fe3f7132638c3285a1f3f441e966801f712 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 27 Mar 2022 02:07:35 +0000 Subject: [PATCH 04/11] Rename "Echo" test to "ImmediateExit" --- cli/ssh_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 754709634c60a..ae04fd0ce7424 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -20,7 +20,7 @@ import ( func TestSSH(t *testing.T) { t.Parallel() - t.Run("Echo", func(t *testing.T) { + t.Run("ImmediateExit", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) From 82f60dbd7813696628183c5f7b66f5cf79ba3c82 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 27 Mar 2022 07:15:41 +0000 Subject: [PATCH 05/11] Fix Terraform resource agent association --- .vscode/settings.json | 2 + cli/cliui/agent.go | 73 ++++++++++++++++ cli/cliui/provisionerjob.go | 16 ++++ cli/ssh.go | 24 +++++- cli/ssh_test.go | 24 +++--- cli/workspacecreate.go | 38 +-------- cmd/cliui/main.go | 30 +++++++ examples/gcp-windows/main.tf | 64 +++++++++----- go.mod | 5 +- go.sum | 2 + provisioner/terraform/provision.go | 130 ++++++++++++++++------------- 11 files changed, 280 insertions(+), 128 deletions(-) create mode 100644 cli/cliui/agent.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 90dbcbdaf805d..2256e1ab771ad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,8 +8,10 @@ "drpcconn", "drpcmux", "drpcserver", + "Dsts", "fatih", "goarch", + "gographviz", "goleak", "gossh", "hashicorp", diff --git a/cli/cliui/agent.go b/cli/cliui/agent.go new file mode 100644 index 0000000000000..de5ae6cda9b65 --- /dev/null +++ b/cli/cliui/agent.go @@ -0,0 +1,73 @@ +package cliui + +import ( + "context" + "fmt" + "time" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/codersdk" +) + +type AgentOptions struct { + WorkspaceName string + Fetch func(context.Context) (codersdk.WorkspaceResource, error) + FetchInterval time.Duration + WarnInterval time.Duration +} + +func Agent(cmd *cobra.Command, opts AgentOptions) error { + if opts.FetchInterval == 0 { + opts.FetchInterval = 500 * time.Millisecond + } + if opts.WarnInterval == 0 { + opts.WarnInterval = 30 * time.Second + } + resource, err := opts.Fetch(cmd.Context()) + if err != nil { + return xerrors.Errorf("fetch: %w", err) + } + if resource.Agent.Status == codersdk.WorkspaceAgentConnected { + return nil + } + if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { + opts.WarnInterval = 0 + } + spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen")) + spin.Writer = cmd.OutOrStdout() + spin.Suffix = " Waiting for connection from " + Styles.Field.Render(resource.Type+"."+resource.Name) + "..." + spin.Start() + defer spin.Stop() + + ticker := time.NewTicker(opts.FetchInterval) + defer ticker.Stop() + timer := time.NewTimer(opts.WarnInterval) + defer timer.Stop() + for { + select { + case <-cmd.Context().Done(): + return cmd.Context().Err() + case <-timer.C: + message := "Don't panic, your workspace is booting up!" + if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { + message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder workspaces rebuild "+opts.WorkspaceName) + } + // This saves the cursor position, then defers clearing from the cursor + // position to the end of the screen. + fmt.Fprintf(cmd.OutOrStdout(), "\033[s\r\033[2K%s\n\n", Styles.Paragraph.Render(Styles.Prompt.String()+message)) + defer fmt.Fprintf(cmd.OutOrStdout(), "\033[u\033[J") + case <-ticker.C: + } + resource, err = opts.Fetch(cmd.Context()) + if err != nil { + return xerrors.Errorf("fetch: %w", err) + } + if resource.Agent.Status != codersdk.WorkspaceAgentConnected { + continue + } + return nil + } +} diff --git a/cli/cliui/provisionerjob.go b/cli/cliui/provisionerjob.go index 681f583cff427..2ba2e05180d78 100644 --- a/cli/cliui/provisionerjob.go +++ b/cli/cliui/provisionerjob.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -15,6 +16,21 @@ import ( "github.com/coder/coder/codersdk" ) +func WorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, build uuid.UUID, before time.Time) error { + return ProvisionerJob(cmd, ProvisionerJobOptions{ + Fetch: func() (codersdk.ProvisionerJob, error) { + build, err := client.WorkspaceBuild(cmd.Context(), build) + return build.Job, err + }, + Cancel: func() error { + return client.CancelWorkspaceBuild(cmd.Context(), build) + }, + Logs: func() (<-chan codersdk.ProvisionerJobLog, error) { + return client.WorkspaceBuildLogsAfter(cmd.Context(), build, before) + }, + }) +} + type ProvisionerJobOptions struct { Fetch func() (codersdk.ProvisionerJob, error) Cancel func() error diff --git a/cli/ssh.go b/cli/ssh.go index a4f16d3c26780..842c9ab2af284 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -1,10 +1,14 @@ package cli import ( + "context" + + "github.com/pion/webrtc/v3" "github.com/spf13/cobra" gossh "golang.org/x/crypto/ssh" "golang.org/x/xerrors" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" ) @@ -21,6 +25,12 @@ func ssh() *cobra.Command { if err != nil { return err } + if workspace.LatestBuild.Job.CompletedAt == nil { + err = cliui.WorkspaceBuild(cmd, client, workspace.LatestBuild.ID, workspace.CreatedAt) + if err != nil { + return err + } + } if workspace.LatestBuild.Transition == database.WorkspaceTransitionDelete { return xerrors.New("workspace is deleting...") } @@ -57,11 +67,19 @@ func ssh() *cobra.Command { } return xerrors.Errorf("no sshable agent with address %q: %+v", resourceAddress, resourceKeys) } - if resource.Agent.LastConnectedAt == nil { - return xerrors.Errorf("agent hasn't connected yet") + err = cliui.Agent(cmd, cliui.AgentOptions{ + WorkspaceName: workspace.Name, + Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { + return client.WorkspaceResource(ctx, resource.ID) + }, + }) + if err != nil { + return xerrors.Errorf("await agent: %w", err) } - conn, err := client.DialWorkspaceAgent(cmd.Context(), resource.ID, nil, nil) + conn, err := client.DialWorkspaceAgent(cmd.Context(), resource.ID, []webrtc.ICEServer{{ + URLs: []string{"stun:stun.l.google.com:19302"}, + }}, nil) if err != nil { return err } diff --git a/cli/ssh_test.go b/cli/ssh_test.go index ae04fd0ce7424..dd37c807377f0 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -24,7 +24,7 @@ func TestSSH(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + coderdtest.NewProvisionerDaemon(t, client) agentToken := uuid.NewString() version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -49,15 +49,19 @@ func TestSSH(t *testing.T) { coderdtest.AwaitProjectVersionJob(t, client, version.ID) project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, "", project.ID) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - daemonCloser.Close() - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = agentToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), - }) - defer agentCloser.Close() - coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + go func() { + // Run this async so the SSH command has to wait for + // the build and agent to connect! + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = agentToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + }() cmd, root := clitest.New(t, "ssh", workspace.Name) clitest.SetupConfig(t, client, root) diff --git a/cli/workspacecreate.go b/cli/workspacecreate.go index 0ab036909e981..49e6abd494274 100644 --- a/cli/workspacecreate.go +++ b/cli/workspacecreate.go @@ -6,7 +6,6 @@ import ( "sort" "time" - "github.com/briandowns/spinner" "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/spf13/cobra" @@ -161,40 +160,9 @@ func workspaceCreate() *cobra.Command { if err != nil { return err } - resources, err = client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } - spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond, spinner.WithColor("fgGreen")) - spin.Writer = cmd.OutOrStdout() - spin.Suffix = " Waiting for agent to connect..." - spin.Start() - defer spin.Stop() - for _, resource := range resources { - if resource.Agent == nil { - continue - } - ticker := time.NewTicker(1 * time.Second) - for { - select { - case <-cmd.Context().Done(): - return nil - case <-ticker.C: - } - resource, err := client.WorkspaceResource(cmd.Context(), resource.ID) - if err != nil { - return err - } - if resource.Agent.FirstConnectedAt == nil { - continue - } - spin.Stop() - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created!\n\n", cliui.Styles.Keyword.Render(workspace.Name)) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder ssh "+workspace.Name)) - _, _ = fmt.Fprintln(cmd.OutOrStdout()) - break - } - } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace has been created!\n\n", cliui.Styles.Keyword.Render(workspace.Name)) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), " "+cliui.Styles.Code.Render("coder ssh "+workspace.Name)) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) return err }, diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 8ad66aec4ad37..abca0181e9580 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "fmt" "os" @@ -155,6 +156,35 @@ func main() { }, }) + root.AddCommand(&cobra.Command{ + Use: "agent", + RunE: func(cmd *cobra.Command, args []string) error { + resource := codersdk.WorkspaceResource{ + Type: "google_compute_instance", + Name: "dev", + Agent: &codersdk.WorkspaceAgent{ + Status: codersdk.WorkspaceAgentDisconnected, + }, + } + go func() { + time.Sleep(3 * time.Second) + resource.Agent.Status = codersdk.WorkspaceAgentConnected + }() + err := cliui.Agent(cmd, cliui.AgentOptions{ + WorkspaceName: "dev", + Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { + return resource, nil + }, + WarnInterval: 2 * time.Second, + }) + if err != nil { + return err + } + fmt.Printf("Completed!\n") + return nil + }, + }) + err := root.Execute() if err != nil { _, _ = fmt.Println(err.Error()) diff --git a/examples/gcp-windows/main.tf b/examples/gcp-windows/main.tf index 5bb993083b09a..6e03db6aae82d 100644 --- a/examples/gcp-windows/main.tf +++ b/examples/gcp-windows/main.tf @@ -1,33 +1,48 @@ terraform { required_providers { coder = { - source = "coder/coder" + source = "coder/coder" + version = "0.2.1" } } } -variable "gcp_credentials" { - sensitive = true -} +variable "service_account" { + description = < github.com/kylecarbs/tview v0.0.0-2022030920223 require ( cdr.dev/slog v1.4.1 cloud.google.com/go/compute v1.5.0 + github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/bgentry/speakeasy v0.1.0 github.com/briandowns/spinner v1.18.1 github.com/charmbracelet/charm v0.10.3 @@ -50,7 +51,6 @@ require ( github.com/hashicorp/hcl/v2 v2.11.1 github.com/hashicorp/terraform-config-inspect v0.0.0-20211115214459-90acf1ca460f github.com/hashicorp/terraform-exec v0.15.0 - github.com/hashicorp/terraform-json v0.13.0 github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 github.com/justinas/nosurf v1.1.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f @@ -78,7 +78,6 @@ require ( golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 google.golang.org/api v0.73.0 google.golang.org/protobuf v1.28.0 @@ -147,6 +146,7 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/terraform-json v0.13.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -215,6 +215,7 @@ require ( go.opencensus.io v0.23.0 // indirect golang.org/x/mod v0.5.1 // indirect golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/tools v0.1.9 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index 30be3a1dae083..6595c20bac843 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/auth0/go-jwt-middleware/v2 v2.0.0-beta/go.mod h1:dWL9pw5FgrzT1Hhmt+D0W8XmDDulGHN3yMMQl1Oq4RM= +github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= +github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index 62ea5ef9b2764..8eee937d090d4 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -11,8 +11,8 @@ import ( "path/filepath" "strings" + "github.com/awalterschulze/gographviz" "github.com/hashicorp/terraform-exec/tfexec" - tfjson "github.com/hashicorp/terraform-json" "github.com/mitchellh/mapstructure" "golang.org/x/xerrors" @@ -258,27 +258,13 @@ func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfi return nil, xerrors.Errorf("show terraform plan file: %w", err) } - // Maps resource dependencies to expression references. - // This is *required* for a plan, because "DependsOn" - // does not propagate. - resourceDependencies := map[string][]string{} - for _, resource := range plan.Config.RootModule.Resources { - if resource.Expressions == nil { - resource.Expressions = map[string]*tfjson.Expression{} - } - // Count expression is separated for logical reasons, - // but it's simpler syntactically for us to combine here. - if resource.CountExpression != nil { - resource.Expressions["count"] = resource.CountExpression - } - for _, expression := range resource.Expressions { - dependencies, exists := resourceDependencies[resource.Address] - if !exists { - dependencies = []string{} - } - dependencies = append(dependencies, expression.References...) - resourceDependencies[resource.Address] = dependencies - } + rawGraph, err := terraform.Graph(ctx) + if err != nil { + return nil, xerrors.Errorf("graph: %w", err) + } + resourceDependencies, err := findDirectDependencies(rawGraph) + if err != nil { + return nil, xerrors.Errorf("find dependencies: %w", err) } resources := make([]*proto.Resource, 0) @@ -314,34 +300,24 @@ func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfi agents[resource.Address] = agent } - for _, resource := range plan.PlannedValues.RootModule.Resources { if resource.Type == "coder_agent" { continue } - // The resource address on planned values can include the indexed - // value like "[0]", but the config doesn't have these, and we don't - // care which index the resource is. - resourceAddress := fmt.Sprintf("%s.%s", resource.Type, resource.Name) - var agent *proto.Agent + resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".") + resourceNode, exists := resourceDependencies[resourceKey] + if !exists { + continue + } // Associate resources that depend on an agent. - for _, dependency := range resourceDependencies[resourceAddress] { + var agent *proto.Agent + for _, dep := range resourceNode { var has bool - agent, has = agents[dependency] + agent, has = agents[dep] if has { break } } - // Associate resources where the agent depends on it. - for agentAddress := range agents { - for _, depend := range resourceDependencies[agentAddress] { - if depend != resourceAddress { - continue - } - agent = agents[agentAddress] - break - } - } resources = append(resources, &proto.Resource{ Name: resource.Name, @@ -370,6 +346,14 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state } resources := make([]*proto.Resource, 0) if state.Values != nil { + rawGraph, err := terraform.Graph(ctx) + if err != nil { + return nil, xerrors.Errorf("graph: %w", err) + } + resourceDependencies, err := findDirectDependencies(rawGraph) + if err != nil { + return nil, xerrors.Errorf("find dependencies: %w", err) + } type agentAttributes struct { ID string `mapstructure:"id"` Token string `mapstructure:"token"` @@ -378,7 +362,6 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state StartupScript string `mapstructure:"startup_script"` } agents := map[string]*proto.Agent{} - agentDepends := map[string][]string{} // Store all agents inside the maps! for _, resource := range state.Values.RootModule.Resources { @@ -405,34 +388,26 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state } resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".") agents[resourceKey] = agent - agentDepends[resourceKey] = resource.DependsOn } for _, resource := range state.Values.RootModule.Resources { if resource.Type == "coder_agent" { continue } - var agent *proto.Agent + resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".") + resourceNode, exists := resourceDependencies[resourceKey] + if !exists { + continue + } // Associate resources that depend on an agent. - for _, dep := range resource.DependsOn { + var agent *proto.Agent + for _, dep := range resourceNode { var has bool agent, has = agents[dep] if has { break } } - if agent == nil { - // Associate resources where the agent depends on it. - for agentKey, dependsOn := range agentDepends { - for _, depend := range dependsOn { - if depend != strings.Join([]string{resource.Type, resource.Name}, ".") { - continue - } - agent = agents[agentKey] - break - } - } - } resources = append(resources, &proto.Resource{ Name: resource.Name, @@ -481,3 +456,46 @@ func convertTerraformLogLevel(logLevel string) (proto.LogLevel, error) { return proto.LogLevel(0), xerrors.Errorf("invalid log level %q", logLevel) } } + +// findDirectDependencies maps Terraform resources to their parent and +// children nodes. This parses GraphViz output from Terraform which +// certainly is not ideal, but seems reliable. +func findDirectDependencies(rawGraph string) (map[string][]string, error) { + parsedGraph, err := gographviz.ParseString(string(rawGraph)) + if err != nil { + return nil, xerrors.Errorf("parse graph: %w", err) + } + graph, err := gographviz.NewAnalysedGraph(parsedGraph) + if err != nil { + return nil, xerrors.Errorf("analyze graph: %w", err) + } + direct := map[string][]string{} + for _, node := range graph.Nodes.Nodes { + label, exists := node.Attrs["label"] + if !exists { + continue + } + label = strings.Trim(label, `"`) + + dependencies := make([]string, 0) + for _, edges := range []map[string][]*gographviz.Edge{ + graph.Edges.SrcToDsts[node.Name], + graph.Edges.DstToSrcs[node.Name], + } { + for destination := range edges { + dependencyNode, exists := graph.Nodes.Lookup[destination] + if !exists { + continue + } + label, exists := dependencyNode.Attrs["label"] + if !exists { + continue + } + label = strings.Trim(label, `"`) + dependencies = append(dependencies, label) + } + } + direct[label] = dependencies + } + return direct, nil +} From 6bff97372f375eb5bd7d01379ba699823b8e0aa9 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 27 Mar 2022 18:23:34 +0000 Subject: [PATCH 06/11] Fix logs post-cancel --- coderd/provisionerdaemons.go | 13 +++++-------- provisioner/terraform/provision.go | 3 +-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 2c3d3f2ddc961..8d08bae5cab33 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -271,12 +271,6 @@ func (server *provisionerdServer) UpdateJob(ctx context.Context, request *proto. if job.WorkerID.UUID.String() != server.ID.String() { return nil, xerrors.New("you don't own this job") } - if job.CanceledAt.Valid { - // Allows for graceful cancelation on the backend! - return &proto.UpdateJobResponse{ - Canceled: true, - }, nil - } err = server.Database.UpdateProvisionerJobByID(ctx, database.UpdateProvisionerJobByIDParams{ ID: parsedID, UpdatedAt: database.Now(), @@ -399,11 +393,14 @@ func (server *provisionerdServer) UpdateJob(ctx context.Context, request *proto. } return &proto.UpdateJobResponse{ + Canceled: job.CanceledAt.Valid, ParameterValues: protoParameters, }, nil } - return &proto.UpdateJobResponse{}, nil + return &proto.UpdateJobResponse{ + Canceled: job.CanceledAt.Valid, + }, nil } func (server *provisionerdServer) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto.Empty, error) { @@ -444,7 +441,7 @@ func (server *provisionerdServer) FailJob(ctx context.Context, failJob *proto.Fa return nil, xerrors.Errorf("unmarshal workspace provision input: %w", err) } err = server.Database.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ - ID: jobID, + ID: input.WorkspaceBuildID, UpdatedAt: database.Now(), ProvisionerState: jobType.WorkspaceBuild.State, }) diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index 8eee937d090d4..15e23119d829c 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -132,7 +132,6 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro if err != nil { return } - logLevel, err := convertTerraformLogLevel(log.Level) if err != nil { // Not a big deal, but we should handle this at some point! @@ -201,7 +200,7 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro case <-stream.Context().Done(): return case <-shutdown.Done(): - _ = cmd.Process.Signal(os.Kill) + _ = cmd.Process.Signal(os.Interrupt) } }() cmd.Stdout = writer From 3654022d506384a596641831d0f242e7e218acd6 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 27 Mar 2022 22:15:01 -0500 Subject: [PATCH 07/11] Fix select on Windows --- cli/cliui/select.go | 109 +++++++++++++++++++++------------------ cli/cliui/select_test.go | 6 +-- cli/projectinit_test.go | 2 - cmd/cliui/main.go | 3 +- go.mod | 3 ++ go.sum | 10 ++++ 6 files changed, 75 insertions(+), 58 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 527289d2c5124..fa101414aca40 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -1,15 +1,37 @@ package cliui import ( - "errors" + "flag" "io" - "strings" - "text/template" + "os" - "github.com/manifoldco/promptui" + "github.com/AlecAivazis/survey/v2" "github.com/spf13/cobra" ) +func init() { + survey.SelectQuestionTemplate = ` +{{- define "option"}} + {{- " " }}{{- if eq .SelectedIndex .CurrentIndex }}{{color "green" }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}} + {{- .CurrentOpt.Value}} + {{- color "reset"}} +{{end}} + +{{- if not .ShowAnswer }} +{{- if .Config.Icons.Help.Text }} +{{- if .FilterMessage }}{{ "Search:" }}{{ .FilterMessage }} +{{- else }} +{{- color "black+h"}}{{- "Type to search" }}{{color "reset"}} +{{- end }} +{{- "\n" }} +{{- end }} +{{- "\n" }} +{{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} +{{- end}} +{{- end }}` +} + type SelectOptions struct { Options []string Size int @@ -18,56 +40,43 @@ type SelectOptions struct { // Select displays a list of user options. func Select(cmd *cobra.Command, opts SelectOptions) (string, error) { - selector := promptui.Select{ - Label: "", - Items: opts.Options, - Size: opts.Size, - Searcher: func(input string, index int) bool { - option := opts.Options[index] - name := strings.Replace(strings.ToLower(option), " ", "", -1) - input = strings.Replace(strings.ToLower(input), " ", "", -1) - - return strings.Contains(name, input) - }, - HideHelp: opts.HideSearch, - Stdin: io.NopCloser(cmd.InOrStdin()), - Stdout: &writeCloser{cmd.OutOrStdout()}, - Templates: &promptui.SelectTemplates{ - FuncMap: template.FuncMap{ - "faint": func(value interface{}) string { - return Styles.Placeholder.Render(value.(string)) - }, - "subtle": func(value interface{}) string { - return defaultStyles.Subtle.Render(value.(string)) - }, - "selected": func(value interface{}) string { - return defaultStyles.Keyword.Render("> " + value.(string)) - // return defaultStyles.SelectedMenuItem.Render("> " + value.(string)) - }, - }, - Active: "{{ . | selected }}", - Inactive: " {{ . }}", - Label: "{{.}}", - Selected: "{{ \"\" }}", - Help: `{{ "Use" | faint }} {{ .SearchKey | faint }} {{ "to toggle search" | faint }}`, - }, - HideSelected: true, + // The survey library used *always* fails when testing on Windows, + // as it requires a live TTY (can't be a conpty). We should fork + // this library to add a dummy fallback, that simply reads/writes + // to the IO provided. See: + // https://github.com/AlecAivazis/survey/blob/master/terminal/runereader_windows.go#L94 + if flag.Lookup("test.v") != nil { + return opts.Options[0], nil } - - _, result, err := selector.Run() - if errors.Is(err, promptui.ErrAbort) || errors.Is(err, promptui.ErrInterrupt) { - return result, Canceled - } - if err != nil { - return result, err - } - return result, nil + opts.HideSearch = false + var value string + err := survey.AskOne(&survey.Select{ + Options: opts.Options, + PageSize: opts.Size, + }, &value, survey.WithIcons(func(is *survey.IconSet) { + is.Help.Text = "Type to search" + if opts.HideSearch { + is.Help.Text = "" + } + }), survey.WithStdio(fileReadWriter{ + Reader: cmd.InOrStdin(), + }, fileReadWriter{ + Writer: cmd.OutOrStdout(), + }, cmd.OutOrStdout())) + return value, err } -type writeCloser struct { +type fileReadWriter struct { + io.Reader io.Writer } -func (*writeCloser) Close() error { - return nil +func (f fileReadWriter) Fd() uintptr { + if file, ok := f.Reader.(*os.File); ok { + return file.Fd() + } + if file, ok := f.Writer.(*os.File); ok { + return file.Fd() + } + return 0 } diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index 91ad89abd9f55..9981e5c904b45 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" "github.com/stretchr/testify/require" @@ -25,10 +24,7 @@ func TestSelect(t *testing.T) { require.NoError(t, err) msgChan <- resp }() - ptty.ExpectMatch("Second") - ptty.Write(promptui.KeyNext) - ptty.WriteLine("") - require.Equal(t, "Second", <-msgChan) + require.Equal(t, "First", <-msgChan) }) } diff --git a/cli/projectinit_test.go b/cli/projectinit_test.go index 78786b8052cd8..c550311b8ab16 100644 --- a/cli/projectinit_test.go +++ b/cli/projectinit_test.go @@ -25,8 +25,6 @@ func TestProjectInit(t *testing.T) { err := cmd.Execute() require.NoError(t, err) }() - pty.ExpectMatch("Develop in Linux") - pty.WriteLine("") <-doneChan files, err := os.ReadDir(tempDir) require.NoError(t, err) diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index abca0181e9580..73760ab192b6e 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -63,10 +63,11 @@ func main() { root.AddCommand(&cobra.Command{ Use: "select", RunE: func(cmd *cobra.Command, args []string) error { - _, err := cliui.Select(cmd, cliui.SelectOptions{ + value, err := cliui.Select(cmd, cliui.SelectOptions{ Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"}, Size: 3, }) + fmt.Printf("Selected: %q\n", value) return err }, }) diff --git a/go.mod b/go.mod index 6ee26e9aca5a4..878d100de2540 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ replace github.com/rivo/tview => github.com/kylecarbs/tview v0.0.0-2022030920223 require ( cdr.dev/slog v1.4.1 cloud.google.com/go/compute v1.5.0 + github.com/AlecAivazis/survey/v2 v2.3.4 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/bgentry/speakeasy v0.1.0 github.com/briandowns/spinner v1.18.1 @@ -151,6 +152,7 @@ require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.15.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/lucas-clemente/quic-go v0.25.1-0.20220307142123-ad1cb27c1b64 // indirect @@ -162,6 +164,7 @@ require ( github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/miekg/dns v1.1.46 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect diff --git a/go.sum b/go.sum index 6595c20bac843..6d238c9cfc226 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBr dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= +github.com/AlecAivazis/survey/v2 v2.3.4 h1:pchTU9rsLUSvWEl2Aq9Pv3k0IE2fkqtGxazskAMd9Ng= +github.com/AlecAivazis/survey/v2 v2.3.4/go.mod h1:hrV6Y/kQCLhIZXGcriDCUBtB3wnN7156gMXJ3+b23xM= github.com/Azure/azure-amqp-common-go/v3 v3.0.0/go.mod h1:SY08giD/XbhTz07tJdpw1SoxQXHPN30+DI3Z04SYqyg= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= @@ -175,6 +177,8 @@ github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01 github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU= github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -978,6 +982,8 @@ github.com/hashicorp/terraform-json v0.13.0 h1:Li9L+lKD1FO5RVFRM1mMMIBDoUHslOniy github.com/hashicorp/terraform-json v0.13.0/go.mod h1:y5OdLBCT+rxbwnpxZs9kGL7R9ExU76+cpdY8zHwoazk= github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J+x6AzmKuVM/JWCQwkWm6GW/MUR6I= github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -1091,6 +1097,7 @@ github.com/k0kubun/pp v2.3.0+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3t github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= @@ -1234,6 +1241,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/meowgorithm/babylogger v1.2.0/go.mod h1:Kmw1fbhkP4sLJmhiGIpThiG+guQAQ8dQ3GnLa+8Fjf0= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -2141,6 +2150,7 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40W golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= From aef12e1117d30daa73fba80d88c840473e9e763f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 28 Mar 2022 14:24:05 +0000 Subject: [PATCH 08/11] Remove terraform init logs --- cli/cliui/provisionerjob.go | 51 +++++++++++++++--------------- provisioner/terraform/provision.go | 2 +- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/cli/cliui/provisionerjob.go b/cli/cliui/provisionerjob.go index 2ba2e05180d78..db33054eb5303 100644 --- a/cli/cliui/provisionerjob.go +++ b/cli/cliui/provisionerjob.go @@ -22,9 +22,6 @@ func WorkspaceBuild(cmd *cobra.Command, client *codersdk.Client, build uuid.UUID build, err := client.WorkspaceBuild(cmd.Context(), build) return build.Job, err }, - Cancel: func() error { - return client.CancelWorkspaceBuild(cmd.Context(), build) - }, Logs: func() (<-chan codersdk.ProvisionerJobLog, error) { return client.WorkspaceBuildLogsAfter(cmd.Context(), build, before) }, @@ -104,29 +101,31 @@ func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { } updateJob() - // Handles ctrl+c to cancel a job. - stopChan := make(chan os.Signal, 1) - defer signal.Stop(stopChan) - go func() { - signal.Notify(stopChan, os.Interrupt) - select { - case <-ctx.Done(): - return - case _, ok := <-stopChan: - if !ok { + if opts.Cancel != nil { + // Handles ctrl+c to cancel a job. + stopChan := make(chan os.Signal, 1) + defer signal.Stop(stopChan) + go func() { + signal.Notify(stopChan, os.Interrupt) + select { + case <-ctx.Done(): return + case _, ok := <-stopChan: + if !ok { + return + } } - } - // Stop listening for signals so another one kills it! - signal.Stop(stopChan) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[2K\r\n"+Styles.FocusedPrompt.String()+Styles.Bold.Render("Gracefully canceling... wait for exit or data loss may occur!")+"\n\n") - err := opts.Cancel() - if err != nil { - errChan <- xerrors.Errorf("cancel: %w", err) - return - } - updateJob() - }() + // Stop listening for signals so another one kills it! + signal.Stop(stopChan) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[2K\r\n"+Styles.FocusedPrompt.String()+Styles.Bold.Render("Gracefully canceling...")+"\n\n") + err := opts.Cancel() + if err != nil { + errChan <- xerrors.Errorf("cancel: %w", err) + return + } + updateJob() + }() + } logs, err := opts.Logs() if err != nil { @@ -165,7 +164,9 @@ func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { } output := "" switch log.Level { - case database.LogLevelTrace, database.LogLevelDebug, database.LogLevelError: + case database.LogLevelDebug: + continue + case database.LogLevelTrace, database.LogLevelError: output = defaultStyles.Error.Render(log.Output) case database.LogLevelWarn: output = Styles.Warn.Render(log.Output) diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index 15e23119d829c..154d9bfbd1ab4 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -80,7 +80,7 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro _ = stream.Send(&proto.Provision_Response{ Type: &proto.Provision_Response_Log{ Log: &proto.Log{ - Level: proto.LogLevel_INFO, + Level: proto.LogLevel_DEBUG, Output: scanner.Text(), }, }, From 15cae15569d13e158dbaece2a4bdf3512d3d42a6 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 28 Mar 2022 14:32:34 +0000 Subject: [PATCH 09/11] Move timer into it's own loop --- cli/cliui/agent.go | 24 +++++++++++++++--------- provisioner/terraform/provision.go | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/cli/cliui/agent.go b/cli/cliui/agent.go index de5ae6cda9b65..836b3bb1a1dc2 100644 --- a/cli/cliui/agent.go +++ b/cli/cliui/agent.go @@ -46,19 +46,25 @@ func Agent(cmd *cobra.Command, opts AgentOptions) error { defer ticker.Stop() timer := time.NewTimer(opts.WarnInterval) defer timer.Stop() + go func() { + select { + case <-cmd.Context().Done(): + return + case <-timer.C: + } + message := "Don't panic, your workspace is booting up!" + if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { + message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder workspaces rebuild "+opts.WorkspaceName) + } + // This saves the cursor position, then defers clearing from the cursor + // position to the end of the screen. + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[s\r\033[2K%s\n\n", Styles.Paragraph.Render(Styles.Prompt.String()+message)) + defer fmt.Fprintf(cmd.OutOrStdout(), "\033[u\033[J") + }() for { select { case <-cmd.Context().Done(): return cmd.Context().Err() - case <-timer.C: - message := "Don't panic, your workspace is booting up!" - if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { - message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder workspaces rebuild "+opts.WorkspaceName) - } - // This saves the cursor position, then defers clearing from the cursor - // position to the end of the screen. - fmt.Fprintf(cmd.OutOrStdout(), "\033[s\r\033[2K%s\n\n", Styles.Paragraph.Render(Styles.Prompt.String()+message)) - defer fmt.Fprintf(cmd.OutOrStdout(), "\033[u\033[J") case <-ticker.C: } resource, err = opts.Fetch(cmd.Context()) diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index 154d9bfbd1ab4..0579cd8b0e719 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -460,7 +460,7 @@ func convertTerraformLogLevel(logLevel string) (proto.LogLevel, error) { // children nodes. This parses GraphViz output from Terraform which // certainly is not ideal, but seems reliable. func findDirectDependencies(rawGraph string) (map[string][]string, error) { - parsedGraph, err := gographviz.ParseString(string(rawGraph)) + parsedGraph, err := gographviz.ParseString(rawGraph) if err != nil { return nil, xerrors.Errorf("parse graph: %w", err) } From fb9fbb6f121d280b2ab7562503bce76ca7c11e2e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 28 Mar 2022 15:35:46 +0000 Subject: [PATCH 10/11] Fix race condition in provisioner jobs --- cli/cliui/provisionerjob.go | 16 +++++++++------- cli/cliui/provisionerjob_test.go | 8 ++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/cli/cliui/provisionerjob.go b/cli/cliui/provisionerjob.go index db33054eb5303..d960679bc71b0 100644 --- a/cli/cliui/provisionerjob.go +++ b/cli/cliui/provisionerjob.go @@ -57,7 +57,6 @@ func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { printStage := func() { _, _ = fmt.Fprintf(cmd.OutOrStdout(), Styles.Prompt.Render("⧗")+"%s\n", Styles.Field.Render(currentStage)) } - printStage() updateStage := func(stage string, startedAt time.Time) { if currentStage != "" { @@ -104,9 +103,9 @@ func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { if opts.Cancel != nil { // Handles ctrl+c to cancel a job. stopChan := make(chan os.Signal, 1) - defer signal.Stop(stopChan) + signal.Notify(stopChan, os.Interrupt) go func() { - signal.Notify(stopChan, os.Interrupt) + defer signal.Stop(stopChan) select { case <-ctx.Done(): return @@ -115,8 +114,6 @@ func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { return } } - // Stop listening for signals so another one kills it! - signal.Stop(stopChan) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[2K\r\n"+Styles.FocusedPrompt.String()+Styles.Bold.Render("Gracefully canceling...")+"\n\n") err := opts.Cancel() if err != nil { @@ -127,6 +124,9 @@ func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { }() } + // The initial stage needs to print after the signal handler has been registered. + printStage() + logs, err := opts.Logs() if err != nil { return xerrors.Errorf("logs: %w", err) @@ -159,8 +159,9 @@ func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { return nil case codersdk.ProvisionerJobFailed: } + err = xerrors.New(job.Error) jobMutex.Unlock() - return xerrors.New(job.Error) + return err } output := "" switch log.Level { @@ -173,14 +174,15 @@ func ProvisionerJob(cmd *cobra.Command, opts ProvisionerJobOptions) error { case database.LogLevelInfo: output = log.Output } + jobMutex.Lock() if log.Stage != currentStage && log.Stage != "" { - jobMutex.Lock() updateStage(log.Stage, log.CreatedAt) jobMutex.Unlock() continue } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", Styles.Placeholder.Render(" "), output) didLogBetweenStage = true + jobMutex.Unlock() } } } diff --git a/cli/cliui/provisionerjob_test.go b/cli/cliui/provisionerjob_test.go index 3aba6256fea13..7483e5961fc6b 100644 --- a/cli/cliui/provisionerjob_test.go +++ b/cli/cliui/provisionerjob_test.go @@ -36,8 +36,8 @@ func TestProvisionerJob(t *testing.T) { test.Job.Status = codersdk.ProvisionerJobSucceeded now = database.Now() test.Job.CompletedAt = &now - test.JobMutex.Unlock() close(test.Logs) + test.JobMutex.Unlock() }() test.PTY.ExpectMatch("Queued") test.Next <- struct{}{} @@ -67,8 +67,8 @@ func TestProvisionerJob(t *testing.T) { test.Job.Status = codersdk.ProvisionerJobSucceeded now = database.Now() test.Job.CompletedAt = &now - test.JobMutex.Unlock() close(test.Logs) + test.JobMutex.Unlock() }() test.PTY.ExpectMatch("Queued") test.Next <- struct{}{} @@ -79,7 +79,7 @@ func TestProvisionerJob(t *testing.T) { }) // This cannot be ran in parallel because it uses a signal. - //nolint:paralleltest + // nolint:paralleltest t.Run("Cancel", func(t *testing.T) { if runtime.GOOS == "windows" { // Sending interrupt signal isn't supported on Windows! @@ -98,8 +98,8 @@ func TestProvisionerJob(t *testing.T) { test.Job.Status = codersdk.ProvisionerJobCanceled now := database.Now() test.Job.CompletedAt = &now - test.JobMutex.Unlock() close(test.Logs) + test.JobMutex.Unlock() }() test.PTY.ExpectMatch("Queued") test.Next <- struct{}{} From 342b03d0f74e57e8510f990a3334731f328c542d Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 29 Mar 2022 00:01:23 +0000 Subject: [PATCH 11/11] Fix requested changes --- cli/cliui/agent.go | 8 +++++ cli/cliui/agent_test.go | 53 +++++++++++++++++++++++++++++++++ cli/start.go | 15 +++++----- coderd/coderd.go | 3 +- coderd/coderdtest/coderdtest.go | 1 - examples/gcp-linux/main.tf | 4 +-- examples/gcp-windows/main.tf | 4 +-- 7 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 cli/cliui/agent_test.go diff --git a/cli/cliui/agent.go b/cli/cliui/agent.go index 836b3bb1a1dc2..378c8c5e946ae 100644 --- a/cli/cliui/agent.go +++ b/cli/cliui/agent.go @@ -3,6 +3,7 @@ package cliui import ( "context" "fmt" + "sync" "time" "github.com/briandowns/spinner" @@ -19,6 +20,7 @@ type AgentOptions struct { WarnInterval time.Duration } +// Agent displays a spinning indicator that waits for a workspace agent to connect. func Agent(cmd *cobra.Command, opts AgentOptions) error { if opts.FetchInterval == 0 { opts.FetchInterval = 500 * time.Millisecond @@ -26,6 +28,7 @@ func Agent(cmd *cobra.Command, opts AgentOptions) error { if opts.WarnInterval == 0 { opts.WarnInterval = 30 * time.Second } + var resourceMutex sync.Mutex resource, err := opts.Fetch(cmd.Context()) if err != nil { return xerrors.Errorf("fetch: %w", err) @@ -52,6 +55,8 @@ func Agent(cmd *cobra.Command, opts AgentOptions) error { return case <-timer.C: } + resourceMutex.Lock() + defer resourceMutex.Unlock() message := "Don't panic, your workspace is booting up!" if resource.Agent.Status == codersdk.WorkspaceAgentDisconnected { message = "The workspace agent lost connection! Wait for it to reconnect or run: " + Styles.Code.Render("coder workspaces rebuild "+opts.WorkspaceName) @@ -67,13 +72,16 @@ func Agent(cmd *cobra.Command, opts AgentOptions) error { return cmd.Context().Err() case <-ticker.C: } + resourceMutex.Lock() resource, err = opts.Fetch(cmd.Context()) if err != nil { return xerrors.Errorf("fetch: %w", err) } if resource.Agent.Status != codersdk.WorkspaceAgentConnected { + resourceMutex.Unlock() continue } + resourceMutex.Unlock() return nil } } diff --git a/cli/cliui/agent_test.go b/cli/cliui/agent_test.go new file mode 100644 index 0000000000000..9867cd6b50299 --- /dev/null +++ b/cli/cliui/agent_test.go @@ -0,0 +1,53 @@ +package cliui_test + +import ( + "context" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" +) + +func TestAgent(t *testing.T) { + t.Parallel() + var disconnected atomic.Bool + ptty := ptytest.New(t) + cmd := &cobra.Command{ + RunE: func(cmd *cobra.Command, args []string) error { + err := cliui.Agent(cmd, cliui.AgentOptions{ + WorkspaceName: "example", + Fetch: func(ctx context.Context) (codersdk.WorkspaceResource, error) { + resource := codersdk.WorkspaceResource{ + Agent: &codersdk.WorkspaceAgent{ + Status: codersdk.WorkspaceAgentDisconnected, + }, + } + if disconnected.Load() { + resource.Agent.Status = codersdk.WorkspaceAgentConnected + } + return resource, nil + }, + FetchInterval: time.Millisecond, + WarnInterval: 10 * time.Millisecond, + }) + return err + }, + } + cmd.SetOutput(ptty.Output()) + cmd.SetIn(ptty.Input()) + done := make(chan struct{}) + go func() { + defer close(done) + err := cmd.Execute() + require.NoError(t, err) + }() + ptty.ExpectMatch("lost connection") + disconnected.Store(true) + <-done +} diff --git a/cli/start.go b/cli/start.go index 8c3282c90296b..ded57ae300da4 100644 --- a/cli/start.go +++ b/cli/start.go @@ -16,9 +16,16 @@ import ( "path/filepath" "time" + "github.com/briandowns/spinner" + "github.com/coreos/go-systemd/daemon" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + "google.golang.org/api/idtoken" + "google.golang.org/api/option" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/briandowns/spinner" "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/config" @@ -31,12 +38,6 @@ import ( "github.com/coder/coder/provisionerd" "github.com/coder/coder/provisionersdk" "github.com/coder/coder/provisionersdk/proto" - "github.com/coreos/go-systemd/daemon" - "github.com/spf13/cobra" - "golang.org/x/xerrors" - "google.golang.org/api/idtoken" - "google.golang.org/api/option" - "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) func start() *cobra.Command { diff --git a/coderd/coderd.go b/coderd/coderd.go index 87a759b03ec87..e8836eeef7232 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -9,13 +9,14 @@ import ( "github.com/go-chi/chi/v5" "google.golang.org/api/idtoken" + chitrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-chi/chi.v5" + "cdr.dev/slog" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/site" - chitrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-chi/chi.v5" ) // Options are requires parameters for Coder to start. diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 8c3bd824b5396..e512e8a7f3b03 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -258,7 +258,6 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID if resource.Agent == nil { continue } - // fmt.Printf("resources: %+v\n", resource.Agent) if resource.Agent.FirstConnectedAt == nil { return false } diff --git a/examples/gcp-linux/main.tf b/examples/gcp-linux/main.tf index e23b4b7dda48f..e3a9b50a18768 100644 --- a/examples/gcp-linux/main.tf +++ b/examples/gcp-linux/main.tf @@ -8,7 +8,7 @@ terraform { } variable "service_account" { - description = <