diff --git a/cli/create.go b/cli/create.go index 79f569d4a02d6..1a2492374a186 100644 --- a/cli/create.go +++ b/cli/create.go @@ -26,8 +26,9 @@ func (r *RootCmd) create() *clibase.Cmd { stopAfter time.Duration workspaceName string - parameterFlags workspaceParameterFlags - autoUpdates string + parameterFlags workspaceParameterFlags + autoUpdates string + copyParametersFrom string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -76,7 +77,24 @@ func (r *RootCmd) create() *clibase.Cmd { return xerrors.Errorf("A workspace already exists named %q!", workspaceName) } + var sourceWorkspace codersdk.Workspace + if copyParametersFrom != "" { + sourceWorkspaceOwner, sourceWorkspaceName, err := splitNamedWorkspace(copyParametersFrom) + if err != nil { + return err + } + + sourceWorkspace, err = client.WorkspaceByOwnerAndName(inv.Context(), sourceWorkspaceOwner, sourceWorkspaceName, codersdk.WorkspaceOptions{}) + if err != nil { + return xerrors.Errorf("get source workspace: %w", err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Coder will use the same template %q as the source workspace.\n", sourceWorkspace.TemplateName) + templateName = sourceWorkspace.TemplateName + } + var template codersdk.Template + var templateVersionID uuid.UUID if templateName == "" { _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:")) @@ -118,11 +136,19 @@ func (r *RootCmd) create() *clibase.Cmd { } template = templateByName[option] + templateVersionID = template.ActiveVersionID + } else if sourceWorkspace.LatestBuild.TemplateVersionID != uuid.Nil { + template, err = client.Template(inv.Context(), sourceWorkspace.TemplateID) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + templateVersionID = sourceWorkspace.LatestBuild.TemplateVersionID } else { template, err = client.TemplateByName(inv.Context(), organization.ID, templateName) if err != nil { return xerrors.Errorf("get template by name: %w", err) } + templateVersionID = template.ActiveVersionID } var schedSpec *string @@ -134,18 +160,28 @@ func (r *RootCmd) create() *clibase.Cmd { schedSpec = ptr.Ref(sched.String()) } - cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) + cliBuildParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) if err != nil { return xerrors.Errorf("can't parse given parameter values: %w", err) } + var sourceWorkspaceParameters []codersdk.WorkspaceBuildParameter + if copyParametersFrom != "" { + sourceWorkspaceParameters, err = client.WorkspaceBuildParameters(inv.Context(), sourceWorkspace.LatestBuild.ID) + if err != nil { + return xerrors.Errorf("get source workspace build parameters: %w", err) + } + } + richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ Action: WorkspaceCreate, - TemplateVersionID: template.ActiveVersionID, + TemplateVersionID: templateVersionID, NewWorkspaceName: workspaceName, RichParameterFile: parameterFlags.richParameterFile, - RichParameters: cliRichParameters, + RichParameters: cliBuildParameters, + + SourceWorkspaceParameters: sourceWorkspaceParameters, }) if err != nil { return xerrors.Errorf("prepare build: %w", err) @@ -165,7 +201,7 @@ func (r *RootCmd) create() *clibase.Cmd { } workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, workspaceOwner, codersdk.CreateWorkspaceRequest{ - TemplateID: template.ID, + TemplateVersionID: templateVersionID, Name: workspaceName, AutostartSchedule: schedSpec, TTLMillis: ttlMillis, @@ -217,6 +253,12 @@ func (r *RootCmd) create() *clibase.Cmd { Default: string(codersdk.AutomaticUpdatesNever), Value: clibase.StringOf(&autoUpdates), }, + clibase.Option{ + Flag: "copy-parameters-from", + Env: "CODER_WORKSPACE_COPY_PARAMETERS_FROM", + Description: "Specify the source workspace name to copy parameters from.", + Value: clibase.StringOf(©ParametersFrom), + }, cliui.SkipPromptOption(), ) cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) @@ -228,7 +270,8 @@ type prepWorkspaceBuildArgs struct { TemplateVersionID uuid.UUID NewWorkspaceName string - LastBuildParameters []codersdk.WorkspaceBuildParameter + LastBuildParameters []codersdk.WorkspaceBuildParameter + SourceWorkspaceParameters []codersdk.WorkspaceBuildParameter PromptBuildOptions bool BuildOptions []codersdk.WorkspaceBuildParameter @@ -263,6 +306,7 @@ func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args p resolver := new(ParameterResolver). WithLastBuildParameters(args.LastBuildParameters). + WithSourceWorkspaceParameters(args.SourceWorkspaceParameters). WithPromptBuildOptions(args.PromptBuildOptions). WithBuildOptions(args.BuildOptions). WithPromptRichParameters(args.PromptRichParameters). diff --git a/cli/create_test.go b/cli/create_test.go index 0f3f06eb1db1d..42b526d404cfc 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -416,6 +416,124 @@ func TestCreateWithRichParameters(t *testing.T) { assert.ErrorContains(t, err, "parameter \""+wrongFirstParameterName+"\" is not present in the template") assert.ErrorContains(t, err, "Did you mean: "+firstParameterName) }) + + t.Run("CopyParameters", 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, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Firstly, create a regular workspace using template with parameters. + inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "-y", + "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue), + "--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue), + "--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue)) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + err := inv.Run() + require.NoError(t, err, "can't create first workspace") + + // Secondly, create a new workspace using parameters from the previous workspace. + const otherWorkspace = "other-workspace" + + inv, root = clitest.New(t, "create", "--copy-parameters-from", "my-workspace", otherWorkspace, "-y") + clitest.SetupConfig(t, member, root) + pty = ptytest.New(t).Attach(inv) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + err = inv.Run() + require.NoError(t, err, "can't create a workspace based on the source workspace") + + // Verify if the new workspace uses expected parameters. + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Name: otherWorkspace, + }) + require.NoError(t, err, "can't list available workspaces") + require.Len(t, workspaces.Workspaces, 1) + + otherWorkspaceLatestBuild := workspaces.Workspaces[0].LatestBuild + + buildParameters, err := client.WorkspaceBuildParameters(ctx, otherWorkspaceLatestBuild.ID) + require.NoError(t, err) + require.Len(t, buildParameters, 3) + require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue}) + require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue}) + require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue}) + }) + + t.Run("CopyParametersFromNotUpdatedWorkspace", 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, echoResponses) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Firstly, create a regular workspace using template with parameters. + inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "-y", + "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue), + "--parameter", fmt.Sprintf("%s=%s", secondParameterName, secondParameterValue), + "--parameter", fmt.Sprintf("%s=%s", immutableParameterName, immutableParameterValue)) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + err := inv.Run() + require.NoError(t, err, "can't create first workspace") + + // Secondly, update the template to the newer version. + version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{ + {Name: "third_parameter", Type: "string", DefaultValue: "not-relevant"}, + }), func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) + coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, version2.ID) + + // Thirdly, create a new workspace using parameters from the previous workspace. + const otherWorkspace = "other-workspace" + + inv, root = clitest.New(t, "create", "--copy-parameters-from", "my-workspace", otherWorkspace, "-y") + clitest.SetupConfig(t, member, root) + pty = ptytest.New(t).Attach(inv) + inv.Stdout = pty.Output() + inv.Stderr = pty.Output() + err = inv.Run() + require.NoError(t, err, "can't create a workspace based on the source workspace") + + // Verify if the new workspace uses expected parameters. + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Name: otherWorkspace, + }) + require.NoError(t, err, "can't list available workspaces") + require.Len(t, workspaces.Workspaces, 1) + + otherWorkspaceLatestBuild := workspaces.Workspaces[0].LatestBuild + require.Equal(t, version.ID, otherWorkspaceLatestBuild.TemplateVersionID) + + buildParameters, err := client.WorkspaceBuildParameters(ctx, otherWorkspaceLatestBuild.ID) + require.NoError(t, err) + require.Len(t, buildParameters, 3) + require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue}) + require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: secondParameterName, Value: secondParameterValue}) + require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: immutableParameterName, Value: immutableParameterValue}) + }) } func TestCreateValidateRichParameters(t *testing.T) { diff --git a/cli/parameterresolver.go b/cli/parameterresolver.go index 21a31825bd0cf..c97d9a3e1bfd2 100644 --- a/cli/parameterresolver.go +++ b/cli/parameterresolver.go @@ -23,7 +23,8 @@ const ( ) type ParameterResolver struct { - lastBuildParameters []codersdk.WorkspaceBuildParameter + lastBuildParameters []codersdk.WorkspaceBuildParameter + sourceWorkspaceParameters []codersdk.WorkspaceBuildParameter richParameters []codersdk.WorkspaceBuildParameter richParametersFile map[string]string @@ -38,6 +39,11 @@ func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.Workspace return pr } +func (pr *ParameterResolver) WithSourceWorkspaceParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver { + pr.sourceWorkspaceParameters = params + return pr +} + func (pr *ParameterResolver) WithRichParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver { pr.richParameters = params return pr @@ -69,6 +75,7 @@ func (pr *ParameterResolver) Resolve(inv *clibase.Invocation, action WorkspaceCL staged = pr.resolveWithParametersMapFile(staged) staged = pr.resolveWithCommandLineOrEnv(staged) + staged = pr.resolveWithSourceBuildParameters(staged, templateVersionParameters) staged = pr.resolveWithLastBuildParameters(staged, templateVersionParameters) if err = pr.verifyConstraints(staged, action, templateVersionParameters); err != nil { return nil, err @@ -160,6 +167,30 @@ next: return resolved } +func (pr *ParameterResolver) resolveWithSourceBuildParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter { +next: + for _, buildParameter := range pr.sourceWorkspaceParameters { + tvp := findTemplateVersionParameter(buildParameter, templateVersionParameters) + if tvp == nil { + continue // it looks like this parameter is not present anymore + } + + if tvp.Ephemeral { + continue // ephemeral parameters should not be passed to consecutive builds + } + + for i, r := range resolved { + if r.Name == buildParameter.Name { + resolved[i].Value = buildParameter.Value + continue next + } + } + + resolved = append(resolved, buildParameter) + } + return resolved +} + func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuildParameter, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) error { for _, r := range resolved { tvp := findTemplateVersionParameter(r, templateVersionParameters) diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index 2d4031999c3d6..7662ecd7ed7ee 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -14,6 +14,9 @@ OPTIONS: Specify automatic updates setting for the workspace (accepts 'always' or 'never'). + --copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM + Specify the source workspace name to copy parameters from. + --parameter string-array, $CODER_RICH_PARAMETER Rich parameter value in the format "name=value". diff --git a/docs/cli/create.md b/docs/cli/create.md index f7036ac84d9a3..0b3f63b32b3fd 100644 --- a/docs/cli/create.md +++ b/docs/cli/create.md @@ -30,6 +30,15 @@ coder create [flags] [name] Specify automatic updates setting for the workspace (accepts 'always' or 'never'). +### --copy-parameters-from + +| | | +| ----------- | -------------------------------------------------- | +| Type | string | +| Environment | $CODER_WORKSPACE_COPY_PARAMETERS_FROM | + +Specify the source workspace name to copy parameters from. + ### --parameter | | |