diff --git a/cli/autoupdate.go b/cli/autoupdate.go new file mode 100644 index 0000000000..7418b02c29 --- /dev/null +++ b/cli/autoupdate.go @@ -0,0 +1,58 @@ +package cli + +import ( + "fmt" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +func (r *RootCmd) autoupdate() *clibase.Cmd { + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Annotations: workspaceCommand, + Use: "autoupdate ", + Short: "Toggle auto-update policy for a workspace", + Middleware: clibase.Chain( + clibase.RequireNArgs(2), + r.InitClient(client), + ), + Handler: func(inv *clibase.Invocation) error { + policy := strings.ToLower(inv.Args[1]) + err := validateAutoUpdatePolicy(policy) + if err != nil { + return xerrors.Errorf("validate policy: %w", err) + } + + workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) + if err != nil { + return xerrors.Errorf("get workspace: %w", err) + } + + err = client.UpdateWorkspaceAutomaticUpdates(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceAutomaticUpdatesRequest{ + AutomaticUpdates: codersdk.AutomaticUpdates(policy), + }) + if err != nil { + return xerrors.Errorf("update workspace automatic updates policy: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "Updated workspace %q auto-update policy to %q\n", workspace.Name, policy) + return nil + }, + } + + cmd.Options = append(cmd.Options, cliui.SkipPromptOption()) + return cmd +} + +func validateAutoUpdatePolicy(arg string) error { + switch codersdk.AutomaticUpdates(arg) { + case codersdk.AutomaticUpdatesAlways, codersdk.AutomaticUpdatesNever: + return nil + default: + return xerrors.Errorf("invalid option %q must be either of %q or %q", arg, codersdk.AutomaticUpdatesAlways, codersdk.AutomaticUpdatesNever) + } +} diff --git a/cli/autoupdate_test.go b/cli/autoupdate_test.go new file mode 100644 index 0000000000..2022dc7fe2 --- /dev/null +++ b/cli/autoupdate_test.go @@ -0,0 +1,79 @@ +package cli_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" +) + +func TestAutoUpdate(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + require.Equal(t, codersdk.AutomaticUpdatesNever, workspace.AutomaticUpdates) + + expectedPolicy := codersdk.AutomaticUpdatesAlways + inv, root := clitest.New(t, "autoupdate", workspace.Name, string(expectedPolicy)) + clitest.SetupConfig(t, member, root) + var buf bytes.Buffer + inv.Stdout = &buf + err := inv.Run() + require.NoError(t, err) + require.Contains(t, buf.String(), fmt.Sprintf("Updated workspace %q auto-update policy to %q", workspace.Name, expectedPolicy)) + + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.Equal(t, expectedPolicy, workspace.AutomaticUpdates) + }) + + t.Run("InvalidArgs", func(t *testing.T) { + type testcase struct { + Name string + Args []string + ErrorContains string + } + + cases := []testcase{ + { + Name: "NoPolicy", + Args: []string{"autoupdate", "ws"}, + ErrorContains: "wanted 2 args but got 1", + }, + { + Name: "InvalidPolicy", + Args: []string{"autoupdate", "ws", "sometimes"}, + ErrorContains: `invalid option "sometimes" must be either of`, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, c.Args...) + clitest.SetupConfig(t, client, root) + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), c.ErrorContains) + }) + } + }) +} diff --git a/cli/create.go b/cli/create.go index 733eb99a71..79f569d4a0 100644 --- a/cli/create.go +++ b/cli/create.go @@ -140,9 +140,9 @@ func (r *RootCmd) create() *clibase.Cmd { } richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ - Action: WorkspaceCreate, - Template: template, - NewWorkspaceName: workspaceName, + Action: WorkspaceCreate, + TemplateVersionID: template.ActiveVersionID, + NewWorkspaceName: workspaceName, RichParameterFile: parameterFlags.richParameterFile, RichParameters: cliRichParameters, @@ -224,10 +224,9 @@ func (r *RootCmd) create() *clibase.Cmd { } type prepWorkspaceBuildArgs struct { - Action WorkspaceCLIAction - Template codersdk.Template - NewWorkspaceName string - WorkspaceID uuid.UUID + Action WorkspaceCLIAction + TemplateVersionID uuid.UUID + NewWorkspaceName string LastBuildParameters []codersdk.WorkspaceBuildParameter @@ -244,7 +243,7 @@ type prepWorkspaceBuildArgs struct { func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) { ctx := inv.Context() - templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID) + templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID) if err != nil { return nil, xerrors.Errorf("get template version: %w", err) } diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index f615e650b5..7ecbb72483 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -600,9 +600,9 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd { } richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ - Action: WorkspaceCreate, - Template: tpl, - NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter? + Action: WorkspaceCreate, + TemplateVersionID: tpl.ActiveVersionID, + NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter? RichParameterFile: parameterFlags.richParameterFile, RichParameters: cliRichParameters, diff --git a/cli/parameter.go b/cli/parameter.go index bca83ee1a6..f35e4246c7 100644 --- a/cli/parameter.go +++ b/cli/parameter.go @@ -20,6 +20,13 @@ type workspaceParameterFlags struct { richParameterFile string richParameters []string + + promptRichParameters bool +} + +func (wpf *workspaceParameterFlags) allOptions() []clibase.Option { + options := append(wpf.cliBuildOptions(), wpf.cliParameters()...) + return append(options, wpf.alwaysPrompt()) } func (wpf *workspaceParameterFlags) cliBuildOptions() []clibase.Option { @@ -55,6 +62,14 @@ func (wpf *workspaceParameterFlags) cliParameters() []clibase.Option { } } +func (wpf *workspaceParameterFlags) alwaysPrompt() clibase.Option { + return clibase.Option{ + Flag: "always-prompt", + Description: "Always prompt all parameters. Does not pull parameter values from existing workspace.", + Value: clibase.BoolOf(&wpf.promptRichParameters), + } +} + func asWorkspaceBuildParameters(nameValuePairs []string) ([]codersdk.WorkspaceBuildParameter, error) { var params []codersdk.WorkspaceBuildParameter for _, nameValue := range nameValuePairs { diff --git a/cli/parameterresolver.go b/cli/parameterresolver.go index 97cf622b75..3b8ee3a855 100644 --- a/cli/parameterresolver.go +++ b/cli/parameterresolver.go @@ -194,7 +194,7 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild (action == WorkspaceUpdate && promptParameterOption) || (action == WorkspaceUpdate && tvp.Mutable && tvp.Required) || (action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) || - (action == WorkspaceUpdate && tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) { + (tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) { parameterValue, err := cliui.RichParameter(inv, tvp) if err != nil { return nil, err diff --git a/cli/restart.go b/cli/restart.go index e5182ff481..a8f7b40f2c 100644 --- a/cli/restart.go +++ b/cli/restart.go @@ -25,7 +25,7 @@ func (r *RootCmd) restart() *clibase.Cmd { clibase.RequireNArgs(1), r.InitClient(client), ), - Options: append(parameterFlags.cliBuildOptions(), cliui.SkipPromptOption()), + Options: clibase.OptionSet{cliui.SkipPromptOption()}, Handler: func(inv *clibase.Invocation) error { ctx := inv.Context() out := inv.Stdout @@ -35,25 +35,7 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } - lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } - - buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) - if err != nil { - return xerrors.Errorf("can't parse build options: %w", err) - } - - buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Action: WorkspaceRestart, - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - - LastBuildParameters: lastBuildParameters, - - PromptBuildOptions: parameterFlags.promptBuildOptions, - BuildOptions: buildOptions, - }) + startReq, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, WorkspaceRestart) if err != nil { return err } @@ -72,27 +54,18 @@ func (r *RootCmd) restart() *clibase.Cmd { if err != nil { return err } + err = cliui.WorkspaceBuild(ctx, out, client, build.ID) if err != nil { return err } - req := codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParameters, - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - } - - build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, req) + build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, startReq) // It's possible for a workspace build to fail due to the template requiring starting // workspaces with the active version. if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusUnauthorized { - build, err = startWorkspaceActiveVersion(inv, client, startWorkspaceActiveVersionArgs{ - BuildOptions: buildOptions, - LastBuildParameters: lastBuildParameters, - PromptBuildOptions: parameterFlags.promptBuildOptions, - Workspace: workspace, - }) + _, _ = fmt.Fprintln(inv.Stdout, "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.") + build, err = startWorkspace(inv, client, workspace, parameterFlags, WorkspaceUpdate) if err != nil { return xerrors.Errorf("start workspace with active template version: %w", err) } @@ -112,5 +85,8 @@ func (r *RootCmd) restart() *clibase.Cmd { return nil }, } + + cmd.Options = append(cmd.Options, parameterFlags.allOptions()...) + return cmd } diff --git a/cli/restart_test.go b/cli/restart_test.go index cdf22c9b98..d38944caeb 100644 --- a/cli/restart_test.go +++ b/cli/restart_test.go @@ -239,4 +239,55 @@ func TestRestartWithParameters(t *testing.T) { Value: immutableParameterValue, }) }) + + t.Run("AlwaysPrompt", func(t *testing.T) { + t.Parallel() + + // Create the workspace + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, mutableParamsResponse) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: mutableParameterName, + Value: mutableParameterValue, + }, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + inv, root := clitest.New(t, "restart", workspace.Name, "-y", "--always-prompt") + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + // We should be prompted for the parameters again. + newValue := "xyz" + pty.ExpectMatch(mutableParameterName) + pty.WriteLine(newValue) + pty.ExpectMatch("workspace has been restarted") + <-doneChan + + // Verify that the updated values are persisted. + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: mutableParameterName, + Value: newValue, + }) + }) } diff --git a/cli/root.go b/cli/root.go index b4d416295c..ecb5e1309a 100644 --- a/cli/root.go +++ b/cli/root.go @@ -97,6 +97,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { r.version(defaultVersionInfo), // Workspace Commands + r.autoupdate(), r.configSSH(), r.create(), r.deleteWorkspace(), diff --git a/cli/start.go b/cli/start.go index b74426570e..d159160711 100644 --- a/cli/start.go +++ b/cli/start.go @@ -5,7 +5,6 @@ import ( "net/http" "time" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/clibase" @@ -25,52 +24,19 @@ func (r *RootCmd) start() *clibase.Cmd { clibase.RequireNArgs(1), r.InitClient(client), ), - Options: append(parameterFlags.cliBuildOptions(), cliui.SkipPromptOption()), + Options: clibase.OptionSet{cliui.SkipPromptOption()}, Handler: func(inv *clibase.Invocation) error { workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0]) if err != nil { return err } - lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } - - buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) - if err != nil { - return xerrors.Errorf("unable to parse build options: %w", err) - } - - buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Action: WorkspaceStart, - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - - LastBuildParameters: lastBuildParameters, - - PromptBuildOptions: parameterFlags.promptBuildOptions, - BuildOptions: buildOptions, - }) - if err != nil { - return err - } - - req := codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParameters, - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - } - - build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req) + build, err := startWorkspace(inv, client, workspace, parameterFlags, WorkspaceStart) // It's possible for a workspace build to fail due to the template requiring starting // workspaces with the active version. if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusUnauthorized { - build, err = startWorkspaceActiveVersion(inv, client, startWorkspaceActiveVersionArgs{ - BuildOptions: buildOptions, - LastBuildParameters: lastBuildParameters, - PromptBuildOptions: parameterFlags.promptBuildOptions, - Workspace: workspace, - }) + _, _ = fmt.Fprintln(inv.Stdout, "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.") + build, err = startWorkspace(inv, client, workspace, parameterFlags, WorkspaceUpdate) if err != nil { return xerrors.Errorf("start workspace with active template version: %w", err) } @@ -90,75 +56,69 @@ func (r *RootCmd) start() *clibase.Cmd { return nil }, } + + cmd.Options = append(cmd.Options, parameterFlags.allOptions()...) + return cmd } -type prepStartWorkspaceArgs struct { - Action WorkspaceCLIAction - TemplateVersionID uuid.UUID - - LastBuildParameters []codersdk.WorkspaceBuildParameter - - PromptBuildOptions bool - BuildOptions []codersdk.WorkspaceBuildParameter -} - -func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args prepStartWorkspaceArgs) ([]codersdk.WorkspaceBuildParameter, error) { - ctx := inv.Context() - - templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID) - if err != nil { - return nil, xerrors.Errorf("get template version: %w", err) +func buildWorkspaceStartRequest(inv *clibase.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, action WorkspaceCLIAction) (codersdk.CreateWorkspaceBuildRequest, error) { + version := workspace.LatestBuild.TemplateVersionID + if workspace.AutomaticUpdates == codersdk.AutomaticUpdatesAlways || action == WorkspaceUpdate { + version = workspace.TemplateActiveVersionID + if version != workspace.LatestBuild.TemplateVersionID { + action = WorkspaceUpdate + } } - templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID) + lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) if err != nil { - return nil, xerrors.Errorf("get template version rich parameters: %w", err) + return codersdk.CreateWorkspaceBuildRequest{}, err } - resolver := new(ParameterResolver). - WithLastBuildParameters(args.LastBuildParameters). - WithPromptBuildOptions(args.PromptBuildOptions). - WithBuildOptions(args.BuildOptions) - return resolver.Resolve(inv, args.Action, templateVersionParameters) -} - -type startWorkspaceActiveVersionArgs struct { - BuildOptions []codersdk.WorkspaceBuildParameter - LastBuildParameters []codersdk.WorkspaceBuildParameter - PromptBuildOptions bool - Workspace codersdk.Workspace -} - -func startWorkspaceActiveVersion(inv *clibase.Invocation, client *codersdk.Client, args startWorkspaceActiveVersionArgs) (codersdk.WorkspaceBuild, error) { - _, _ = fmt.Fprintln(inv.Stdout, "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.") - - template, err := client.Template(inv.Context(), args.Workspace.TemplateID) + buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) if err != nil { - return codersdk.WorkspaceBuild{}, xerrors.Errorf("get template: %w", err) + return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse build options: %w", err) } - buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Action: WorkspaceStart, - TemplateVersionID: template.ActiveVersionID, + cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) + if err != nil { + return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse build options: %w", err) + } - LastBuildParameters: args.LastBuildParameters, + buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + Action: action, + TemplateVersionID: version, + NewWorkspaceName: workspace.Name, + LastBuildParameters: lastBuildParameters, - PromptBuildOptions: args.PromptBuildOptions, - BuildOptions: args.BuildOptions, + PromptBuildOptions: parameterFlags.promptBuildOptions, + BuildOptions: buildOptions, + PromptRichParameters: parameterFlags.promptRichParameters, + RichParameters: cliRichParameters, + RichParameterFile: parameterFlags.richParameterFile, }) if err != nil { - return codersdk.WorkspaceBuild{}, err + return codersdk.CreateWorkspaceBuildRequest{}, err } - build, err := client.CreateWorkspaceBuild(inv.Context(), args.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + return codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, RichParameterValues: buildParameters, - TemplateVersionID: template.ActiveVersionID, - }) + TemplateVersionID: version, + }, nil +} + +func startWorkspace(inv *clibase.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, action WorkspaceCLIAction) (codersdk.WorkspaceBuild, error) { + req, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, action) if err != nil { return codersdk.WorkspaceBuild{}, err } + build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req) + if err != nil { + return codersdk.WorkspaceBuild{}, xerrors.Errorf("create workspace build: %w", err) + } + return build, nil } diff --git a/cli/start_test.go b/cli/start_test.go index 8a0e015f5c..f7db8b8673 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -26,6 +26,52 @@ const ( immutableParameterName = "immutable_parameter" immutableParameterDescription = "This is immutable parameter" immutableParameterValue = "abc" + + mutableParameterName = "mutable_parameter" + mutableParameterValue = "hello" +) + +var ( + mutableParamsResponse = &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: mutableParameterName, + Description: "This is a mutable parameter", + Required: true, + Mutable: true, + }, + }, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } + + immutableParamsResponse = &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: immutableParameterName, + Description: immutableParameterDescription, + Required: true, + }, + }, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } ) func TestStart(t *testing.T) { @@ -147,26 +193,6 @@ func TestStart(t *testing.T) { func TestStartWithParameters(t *testing.T) { t.Parallel() - echoResponses := &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: []*proto.Response{ - { - Type: &proto.Response_Plan{ - Plan: &proto.PlanComplete{ - Parameters: []*proto.RichParameter{ - { - Name: immutableParameterName, - Description: immutableParameterDescription, - Required: true, - }, - }, - }, - }, - }, - }, - ProvisionApply: echo.ApplyComplete, - } - t.Run("DoNotAskForImmutables", func(t *testing.T) { t.Parallel() @@ -174,7 +200,7 @@ func TestStartWithParameters(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, immutableParamsResponse) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { @@ -218,4 +244,133 @@ func TestStartWithParameters(t *testing.T) { Value: immutableParameterValue, }) }) + + t.Run("AlwaysPrompt", func(t *testing.T) { + t.Parallel() + + // Create the workspace + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, mutableParamsResponse) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: mutableParameterName, + Value: mutableParameterValue, + }, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Stop the workspace + workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID) + + // Start the workspace again + inv, root := clitest.New(t, "start", workspace.Name, "--always-prompt") + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + newValue := "xyz" + pty.ExpectMatch(mutableParameterName) + pty.WriteLine(newValue) + pty.ExpectMatch("workspace has been started") + <-doneChan + + // Verify that the updated values are persisted. + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{ + Name: mutableParameterName, + Value: newValue, + }) + }) +} + +// TestStartAutoUpdate also tests restart since the flows are virtually identical. +func TestStartAutoUpdate(t *testing.T) { + t.Parallel() + + const ( + stringParameterName = "myparam" + stringParameterValue = "abc" + ) + + stringRichParameters := []*proto.RichParameter{ + {Name: stringParameterName, Type: "string", Mutable: true, Required: true}, + } + + type testcase struct { + Name string + Cmd string + } + + cases := []testcase{ + { + Name: "StartOK", + Cmd: "start", + }, + { + Name: "RestartOK", + Cmd: "restart", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) + workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutomaticUpdates = codersdk.AutomaticUpdatesAlways + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + if c.Cmd == "start" { + coderdtest.MustTransitionWorkspace(t, member, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + } + version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(stringRichParameters), func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) + coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, version2.ID) + + inv, root := clitest.New(t, c.Cmd, "-y", workspace.Name) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + pty.ExpectMatch(stringParameterName) + pty.WriteLine(stringParameterValue) + <-doneChan + + workspace = coderdtest.MustWorkspace(t, member, workspace.ID) + require.Equal(t, version2.ID, workspace.LatestBuild.TemplateVersionID) + }) + } } diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index d04546ce01..4aaf44bd57 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -14,6 +14,7 @@ USAGE: $ coder templates init SUBCOMMANDS: + autoupdate Toggle auto-update policy for a workspace config-ssh Add an SSH Host entry for your workspaces "ssh coder.workspace" create Create a workspace diff --git a/cli/testdata/coder_autoupdate_--help.golden b/cli/testdata/coder_autoupdate_--help.golden new file mode 100644 index 0000000000..96207daba5 --- /dev/null +++ b/cli/testdata/coder_autoupdate_--help.golden @@ -0,0 +1,13 @@ +coder v0.0.0-devel + +USAGE: + coder autoupdate [flags] + + Toggle auto-update policy for a workspace + +OPTIONS: + -y, --yes bool + Bypass prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_restart_--help.golden b/cli/testdata/coder_restart_--help.golden index db0f600d7c..b274812f7f 100644 --- a/cli/testdata/coder_restart_--help.golden +++ b/cli/testdata/coder_restart_--help.golden @@ -6,12 +6,23 @@ USAGE: Restart a workspace OPTIONS: + --always-prompt bool + Always prompt all parameters. Does not pull parameter values from + existing workspace. + --build-option string-array, $CODER_BUILD_OPTION Build option value in the format "name=value". --build-options bool Prompt for one-time build options defined with ephemeral parameters. + --parameter string-array, $CODER_RICH_PARAMETER + Rich parameter value in the format "name=value". + + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE + Specify a file path with values for rich parameters defined in the + template. + -y, --yes bool Bypass prompts. diff --git a/cli/testdata/coder_start_--help.golden b/cli/testdata/coder_start_--help.golden index 0c129342b4..10df0d5bc4 100644 --- a/cli/testdata/coder_start_--help.golden +++ b/cli/testdata/coder_start_--help.golden @@ -6,12 +6,23 @@ USAGE: Start a workspace OPTIONS: + --always-prompt bool + Always prompt all parameters. Does not pull parameter values from + existing workspace. + --build-option string-array, $CODER_BUILD_OPTION Build option value in the format "name=value". --build-options bool Prompt for one-time build options defined with ephemeral parameters. + --parameter string-array, $CODER_RICH_PARAMETER + Rich parameter value in the format "name=value". + + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE + Specify a file path with values for rich parameters defined in the + template. + -y, --yes bool Bypass prompts. diff --git a/cli/update.go b/cli/update.go index cdff4b4a8d..86f553a6ce 100644 --- a/cli/update.go +++ b/cli/update.go @@ -10,11 +10,7 @@ import ( ) func (r *RootCmd) update() *clibase.Cmd { - var ( - alwaysPrompt bool - - parameterFlags workspaceParameterFlags - ) + var parameterFlags workspaceParameterFlags client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -31,58 +27,16 @@ func (r *RootCmd) update() *clibase.Cmd { if err != nil { return err } - if !workspace.Outdated && !alwaysPrompt && !parameterFlags.promptBuildOptions && len(parameterFlags.buildOptions) == 0 { + if !workspace.Outdated && !parameterFlags.promptRichParameters && !parameterFlags.promptBuildOptions && len(parameterFlags.buildOptions) == 0 { _, _ = fmt.Fprintf(inv.Stdout, "Workspace isn't outdated!\n") return nil } - buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) + build, err := startWorkspace(inv, client, workspace, parameterFlags, WorkspaceUpdate) if err != nil { - return err + return xerrors.Errorf("start workspace: %w", err) } - template, err := client.Template(inv.Context(), workspace.TemplateID) - if err != nil { - return err - } - - lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } - - cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) - if err != nil { - return xerrors.Errorf("can't parse given parameter values: %w", err) - } - - buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ - Action: WorkspaceUpdate, - Template: template, - NewWorkspaceName: workspace.Name, - WorkspaceID: workspace.LatestBuild.ID, - - LastBuildParameters: lastBuildParameters, - - PromptBuildOptions: parameterFlags.promptBuildOptions, - BuildOptions: buildOptions, - - PromptRichParameters: alwaysPrompt, - RichParameters: cliRichParameters, - RichParameterFile: parameterFlags.richParameterFile, - }) - if err != nil { - return err - } - - build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: template.ActiveVersionID, - Transition: codersdk.WorkspaceTransitionStart, - RichParameterValues: buildParameters, - }) - if err != nil { - return err - } logs, closer, err := client.WorkspaceBuildLogsAfter(inv.Context(), build.ID, 0) if err != nil { return err @@ -99,14 +53,6 @@ func (r *RootCmd) update() *clibase.Cmd { }, } - cmd.Options = clibase.OptionSet{ - { - Flag: "always-prompt", - Description: "Always prompt all parameters. Does not pull parameter values from existing workspace.", - Value: clibase.BoolOf(&alwaysPrompt), - }, - } - cmd.Options = append(cmd.Options, parameterFlags.cliBuildOptions()...) - cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) + cmd.Options = append(cmd.Options, parameterFlags.allOptions()...) return cmd } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 2722f0b346..0799e72f91 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -934,11 +934,8 @@ func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID require.NoError(t, err, "unexpected error fetching workspace") require.Equal(t, workspace.LatestBuild.Transition, codersdk.WorkspaceTransition(from), "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition) - template, err := client.Template(ctx, workspace.TemplateID) - require.NoError(t, err, "fetch workspace template") - req := codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: template.ActiveVersionID, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, Transition: codersdk.WorkspaceTransition(to), } diff --git a/docs/cli.md b/docs/cli.md index 57ce052fa4..7fab3533a1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,6 +25,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | Name | Purpose | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| [autoupdate](./cli/autoupdate.md) | Toggle auto-update policy for a workspace | | [config-ssh](./cli/config-ssh.md) | Add an SSH Host entry for your workspaces "ssh coder.workspace" | | [create](./cli/create.md) | Create a workspace | | [delete](./cli/delete.md) | Delete a workspace | diff --git a/docs/cli/autoupdate.md b/docs/cli/autoupdate.md new file mode 100644 index 0000000000..12751dfd29 --- /dev/null +++ b/docs/cli/autoupdate.md @@ -0,0 +1,21 @@ + + +# autoupdate + +Toggle auto-update policy for a workspace + +## Usage + +```console +coder autoupdate [flags] +``` + +## Options + +### -y, --yes + +| | | +| ---- | ----------------- | +| Type | bool | + +Bypass prompts. diff --git a/docs/cli/restart.md b/docs/cli/restart.md index d3b6010a92..9e4889a27c 100644 --- a/docs/cli/restart.md +++ b/docs/cli/restart.md @@ -12,6 +12,14 @@ coder restart [flags] ## Options +### --always-prompt + +| | | +| ---- | ----------------- | +| Type | bool | + +Always prompt all parameters. Does not pull parameter values from existing workspace. + ### --build-option | | | @@ -29,6 +37,24 @@ Build option value in the format "name=value". Prompt for one-time build options defined with ephemeral parameters. +### --parameter + +| | | +| ----------- | ---------------------------------- | +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER | + +Rich parameter value in the format "name=value". + +### --rich-parameter-file + +| | | +| ----------- | --------------------------------------- | +| Type | string | +| Environment | $CODER_RICH_PARAMETER_FILE | + +Specify a file path with values for rich parameters defined in the template. + ### -y, --yes | | | diff --git a/docs/cli/start.md b/docs/cli/start.md index 120edfde67..c1bff28d17 100644 --- a/docs/cli/start.md +++ b/docs/cli/start.md @@ -12,6 +12,14 @@ coder start [flags] ## Options +### --always-prompt + +| | | +| ---- | ----------------- | +| Type | bool | + +Always prompt all parameters. Does not pull parameter values from existing workspace. + ### --build-option | | | @@ -29,6 +37,24 @@ Build option value in the format "name=value". Prompt for one-time build options defined with ephemeral parameters. +### --parameter + +| | | +| ----------- | ---------------------------------- | +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER | + +Rich parameter value in the format "name=value". + +### --rich-parameter-file + +| | | +| ----------- | --------------------------------------- | +| Type | string | +| Environment | $CODER_RICH_PARAMETER_FILE | + +Specify a file path with values for rich parameters defined in the template. + ### -y, --yes | | | diff --git a/docs/manifest.json b/docs/manifest.json index 7353f80931..661b2f6753 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -569,6 +569,11 @@ "path": "./cli.md", "icon_path": "./images/icons/terminal.svg", "children": [ + { + "title": "autoupdate", + "description": "Toggle auto-update policy for a workspace", + "path": "cli/autoupdate.md" + }, { "title": "coder", "path": "cli.md" diff --git a/enterprise/cli/start_test.go b/enterprise/cli/start_test.go index 665b67d4f8..be6101e7f9 100644 --- a/enterprise/cli/start_test.go +++ b/enterprise/cli/start_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "bytes" "testing" "github.com/google/uuid" @@ -154,21 +155,26 @@ func TestStart(t *testing.T) { require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, c.Client, ws.LatestBuild.ID) + initialTemplateVersion := ws.LatestBuild.TemplateVersionID + if cmd == "start" { // Stop the workspace so that we can start it. - coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { - req.TemplateVersionID = oldVersion.ID - }) + coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) } // Start the workspace. Every test permutation should // pass. + var buf bytes.Buffer inv, conf := newCLI(t, cmd, ws.Name, "-y") + inv.Stdout = &buf clitest.SetupConfig(t, c.Client, conf) err = inv.Run() require.NoError(t, err) ws = coderdtest.MustWorkspace(t, c.Client, ws.ID) require.Equal(t, c.ExpectedVersion, ws.LatestBuild.TemplateVersionID) + if initialTemplateVersion != ws.LatestBuild.TemplateVersionID { + require.Contains(t, buf.String(), "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.") + } }) } })