diff --git a/cli/restart.go b/cli/restart.go index a936c30594878..e5182ff481d1c 100644 --- a/cli/restart.go +++ b/cli/restart.go @@ -2,15 +2,15 @@ package cli import ( "fmt" + "net/http" "time" "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) restart() *clibase.Cmd { @@ -40,19 +40,14 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } - template, err := client.Template(inv.Context(), workspace.TemplateID) - 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, - Template: template, + Action: WorkspaceRestart, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, LastBuildParameters: lastBuildParameters, @@ -82,13 +77,29 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } - build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + req := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, RichParameterValues: buildParameters, - }) - if err != nil { + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + } + + build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, req) + // 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, + }) + if err != nil { + return xerrors.Errorf("start workspace with active template version: %w", err) + } + } else if err != nil { return err } + err = cliui.WorkspaceBuild(ctx, out, client, build.ID) if err != nil { return err diff --git a/cli/start.go b/cli/start.go index 32f14985c7991..b74426570e75f 100644 --- a/cli/start.go +++ b/cli/start.go @@ -2,8 +2,10 @@ package cli import ( "fmt" + "net/http" "time" + "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/clibase" @@ -35,19 +37,14 @@ func (r *RootCmd) start() *clibase.Cmd { return err } - template, err := client.Template(inv.Context(), workspace.TemplateID) - 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, - Template: template, + Action: WorkspaceStart, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, LastBuildParameters: lastBuildParameters, @@ -58,11 +55,26 @@ func (r *RootCmd) start() *clibase.Cmd { return err } - build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + req := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, RichParameterValues: buildParameters, - }) - if err != nil { + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + } + + build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req) + // 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, + }) + if err != nil { + return xerrors.Errorf("start workspace with active template version: %w", err) + } + } else if err != nil { return err } @@ -82,8 +94,8 @@ func (r *RootCmd) start() *clibase.Cmd { } type prepStartWorkspaceArgs struct { - Action WorkspaceCLIAction - Template codersdk.Template + Action WorkspaceCLIAction + TemplateVersionID uuid.UUID LastBuildParameters []codersdk.WorkspaceBuildParameter @@ -94,7 +106,7 @@ type prepStartWorkspaceArgs struct { func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args prepStartWorkspaceArgs) ([]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) } @@ -110,3 +122,43 @@ func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args p 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) + if err != nil { + return codersdk.WorkspaceBuild{}, xerrors.Errorf("get template: %w", err) + } + + buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ + Action: WorkspaceStart, + TemplateVersionID: template.ActiveVersionID, + + LastBuildParameters: args.LastBuildParameters, + + PromptBuildOptions: args.PromptBuildOptions, + BuildOptions: args.BuildOptions, + }) + if err != nil { + return codersdk.WorkspaceBuild{}, err + } + + build, err := client.CreateWorkspaceBuild(inv.Context(), args.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + RichParameterValues: buildParameters, + TemplateVersionID: template.ActiveVersionID, + }) + if err != nil { + return codersdk.WorkspaceBuild{}, err + } + + return build, nil +} diff --git a/cli/templatecreate.go b/cli/templatecreate.go index b2e9a45cc8be8..13dd2de64e7f1 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -24,11 +24,12 @@ import ( func (r *RootCmd) templateCreate() *clibase.Cmd { var ( - provisioner string - provisionerTags []string - variablesFile string - variables []string - disableEveryone bool + provisioner string + provisionerTags []string + variablesFile string + variables []string + disableEveryone bool + requireActiveVersion bool defaultTTL time.Duration failureTTL time.Duration @@ -46,17 +47,35 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - if failureTTL != 0 || inactivityTTL != 0 || maxTTL != 0 { + isTemplateSchedulingOptionsSet := failureTTL != 0 || inactivityTTL != 0 || maxTTL != 0 + + if isTemplateSchedulingOptionsSet || requireActiveVersion { entitlements, err := client.Entitlements(inv.Context()) - var sdkErr *codersdk.Error - if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --failure-ttl or --inactivityTTL") + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusNotFound { + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") } else if err != nil { return xerrors.Errorf("get entitlements: %w", err) } - if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { - return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --failure-ttl or --inactivityTTL") + if isTemplateSchedulingOptionsSet { + if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { + return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --failure-ttl or --inactivityTTL") + } + } + + if requireActiveVersion { + if !entitlements.Features[codersdk.FeatureAccessControl].Enabled { + return xerrors.Errorf("your license is not entitled to use enterprise access control, so you cannot set --require-active-version") + } + + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentTemplateUpdatePolicies) { + return xerrors.Errorf("--require-active-version is an experimental feature, contact an administrator to enable the 'template_update_policies' experiment on your Coder server") + } } } @@ -129,6 +148,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), DisableEveryoneGroupAccess: disableEveryone, + RequireActiveVersion: requireActiveVersion, } _, err = client.CreateTemplate(inv.Context(), organization.ID, createReq) @@ -205,6 +225,13 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { Value: clibase.StringOf(&provisioner), Hidden: true, }, + { + Flag: "require-active-version", + Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature.", + Value: clibase.BoolOf(&requireActiveVersion), + Default: "false", + }, + cliui.SkipPromptOption(), } cmd.Options = append(cmd.Options, uploadFlags.options()...) diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index ba5dad7b4ac6a..ec1720ba2a6a4 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -13,6 +13,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/pty/ptytest" @@ -393,6 +394,36 @@ func TestTemplateCreate(t *testing.T) { } } }) + + t.Run("RequireActiveVersionInvalid", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentTemplateUpdatePolicies), + } + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + coderdtest.CreateFirstUser(t, client) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) + args := []string{ + "templates", + "create", + "my-template", + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--require-active-version", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") + }) } // Need this for Windows because of a known issue with Go: diff --git a/cli/templateedit.go b/cli/templateedit.go index ba079f99f7dd0..5bd1cd236f7df 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -31,6 +31,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { allowUserCancelWorkspaceJobs bool allowUserAutostart bool allowUserAutostop bool + requireActiveVersion bool ) client := new(codersdk.Client) @@ -43,7 +44,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Short: "Edit the metadata of a template by name.", Handler: func(inv *clibase.Invocation) error { unsetAutostopRequirementDaysOfWeek := len(autostopRequirementDaysOfWeek) == 1 && autostopRequirementDaysOfWeek[0] == "none" - requiresEntitlement := (len(autostopRequirementDaysOfWeek) > 0 && !unsetAutostopRequirementDaysOfWeek) || + requiresScheduling := (len(autostopRequirementDaysOfWeek) > 0 && !unsetAutostopRequirementDaysOfWeek) || autostopRequirementWeeks > 0 || !allowUserAutostart || !allowUserAutostop || @@ -52,18 +53,33 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { inactivityTTL != 0 || len(autostartRequirementDaysOfWeek) > 0 + requiresEntitlement := requiresScheduling || requireActiveVersion if requiresEntitlement { entitlements, err := client.Entitlements(inv.Context()) - var sdkErr *codersdk.Error - if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl, --failure-ttl, --inactivityTTL, --allow-user-autostart=false or --allow-user-autostop=false") + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusNotFound { + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") } else if err != nil { return xerrors.Errorf("get entitlements: %w", err) } - if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { + if requiresScheduling && !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl, --failure-ttl, --inactivityTTL, --allow-user-autostart=false or --allow-user-autostop=false") } + + if requireActiveVersion { + if !entitlements.Features[codersdk.FeatureAccessControl].Enabled { + return xerrors.Errorf("your license is not entitled to use enterprise access control, so you cannot set --require-active-version") + } + + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentTemplateUpdatePolicies) { + return xerrors.Errorf("--require-active-version is an experimental feature, contact an administrator to enable the 'template_update_policies' experiment on your Coder server") + } + } } organization, err := CurrentOrganization(inv, client) @@ -110,6 +126,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, AllowUserAutostart: allowUserAutostart, AllowUserAutostop: allowUserAutostop, + RequireActiveVersion: requireActiveVersion, } _, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req) @@ -222,6 +239,12 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Default: "true", Value: clibase.BoolOf(&allowUserAutostop), }, + { + Flag: "require-active-version", + Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature.", + Value: clibase.BoolOf(&requireActiveVersion), + Default: "false", + }, cliui.SkipPromptOption(), } diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index cf286adacf427..de2e52894a444 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -1021,4 +1021,30 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.TimeTilDormantMillis, updated.TimeTilDormantMillis) }) }) + + t.Run("RequireActiveVersion", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {}) + + // Test the cli command with --allow-user-autostart. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--require-active-version", + } + inv, root := clitest.New(t, cmdArgs...) + //nolint + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + }) } diff --git a/cli/testdata/coder_templates_create_--help.golden b/cli/testdata/coder_templates_create_--help.golden index 446c43f7e11ae..f458d3954dd62 100644 --- a/cli/testdata/coder_templates_create_--help.golden +++ b/cli/testdata/coder_templates_create_--help.golden @@ -49,6 +49,11 @@ OPTIONS: --provisioner-tag string-array Specify a set of tags to target provisioner daemons. + --require-active-version bool (default: false) + Requires workspace builds to use the active template version. This + setting does not apply to template admins. This is an enterprise-only + feature. + --var string-array Alias of --variable. diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index d86be791db616..fd5841125e708 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -59,6 +59,11 @@ OPTIONS: --name string Edit the template name. + --require-active-version bool (default: false) + Requires workspace builds to use the active template version. This + setting does not apply to template admins. This is an enterprise-only + feature. + -y, --yes bool Bypass prompts. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4151c62b40202..9d75c5385bb56 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8541,7 +8541,8 @@ const docTemplate = `{ "single_tailnet", "template_autostop_requirement", "deployment_health_page", - "dashboard_theme" + "dashboard_theme", + "template_update_policies" ], "x-enum-varnames": [ "ExperimentMoons", @@ -8549,7 +8550,8 @@ const docTemplate = `{ "ExperimentSingleTailnet", "ExperimentTemplateAutostopRequirement", "ExperimentDeploymentHealthPage", - "ExperimentDashboardTheme" + "ExperimentDashboardTheme", + "ExperimentTemplateUpdatePolicies" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index df54a31a85a54..fc42019342e28 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7653,7 +7653,8 @@ "single_tailnet", "template_autostop_requirement", "deployment_health_page", - "dashboard_theme" + "dashboard_theme", + "template_update_policies" ], "x-enum-varnames": [ "ExperimentMoons", @@ -7661,7 +7662,8 @@ "ExperimentSingleTailnet", "ExperimentTemplateAutostopRequirement", "ExperimentDeploymentHealthPage", - "ExperimentDashboardTheme" + "ExperimentDashboardTheme", + "ExperimentTemplateUpdatePolicies" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 6310a48d04e2e..85ceeba1be349 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -610,7 +610,7 @@ func CreateAnotherUserMutators(t testing.TB, client *codersdk.Client, organizati func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, retries int, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) { req := codersdk.CreateUserRequest{ Email: namesgenerator.GetRandomName(10) + "@coder.com", - Username: randomUsername(t), + Username: RandomUsername(t), Password: "SomeSecurePassword!", OrganizationID: organizationID, } @@ -744,7 +744,7 @@ func CreateWorkspaceBuild( // compatibility with testing. The name assigned is randomly generated. func CreateTemplate(t testing.TB, client *codersdk.Client, organization uuid.UUID, version uuid.UUID, mutators ...func(*codersdk.CreateTemplateRequest)) codersdk.Template { req := codersdk.CreateTemplateRequest{ - Name: randomUsername(t), + Name: RandomUsername(t), VersionID: version, } for _, mut := range mutators { @@ -906,7 +906,7 @@ func CreateWorkspace(t testing.TB, client *codersdk.Client, organization uuid.UU t.Helper() req := codersdk.CreateWorkspaceRequest{ TemplateID: templateID, - Name: randomUsername(t), + Name: RandomUsername(t), AutostartSchedule: ptr.Ref("CRON_TZ=US/Central 30 9 * * 1-5"), TTLMillis: ptr.Ref((8 * time.Hour).Milliseconds()), AutomaticUpdates: codersdk.AutomaticUpdatesNever, @@ -1170,7 +1170,7 @@ func NewAzureInstanceIdentity(t testing.TB, instanceID string) (x509.VerifyOptio } } -func randomUsername(t testing.TB) string { +func RandomUsername(t testing.TB) string { suffix, err := cryptorand.String(3) require.NoError(t, err) suffix = "-" + suffix diff --git a/codersdk/deployment.go b/codersdk/deployment.go index bfcead815cf7c..7ad2cf527ca48 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2002,6 +2002,7 @@ const ( // ExperimentDashboardTheme mutates the dashboard to use a new, dark color scheme. ExperimentDashboardTheme Experiment = "dashboard_theme" + ExperimentTemplateUpdatePolicies Experiment = "template_update_policies" // Add new experiments here! // ExperimentExample Experiment = "example" ) diff --git a/docs/api/schemas.md b/docs/api/schemas.md index da6715839647a..ff9aac1436700 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2850,6 +2850,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `template_autostop_requirement` | | `deployment_health_page` | | `dashboard_theme` | +| `template_update_policies` | ## codersdk.ExternalAuth diff --git a/docs/cli/templates_create.md b/docs/cli/templates_create.md index 2811e4a1ce021..83c3b3c5b9aff 100644 --- a/docs/cli/templates_create.md +++ b/docs/cli/templates_create.md @@ -89,6 +89,15 @@ Disable the default behavior of granting template access to the 'everyone' group Specify a set of tags to target provisioner daemons. +### --require-active-version + +| | | +| ------- | ------------------ | +| Type | bool | +| Default | false | + +Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature. + ### --var | | | diff --git a/docs/cli/templates_edit.md b/docs/cli/templates_edit.md index b58d1f61fc806..cd65ac99ef9d0 100644 --- a/docs/cli/templates_edit.md +++ b/docs/cli/templates_edit.md @@ -113,6 +113,15 @@ Edit the template maximum time before shutdown - workspaces created from this te Edit the template name. +### --require-active-version + +| | | +| ------- | ------------------ | +| Type | bool | +| Default | false | + +Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature. + ### -y, --yes | | | diff --git a/enterprise/cli/start_test.go b/enterprise/cli/start_test.go new file mode 100644 index 0000000000000..665b67d4f829f --- /dev/null +++ b/enterprise/cli/start_test.go @@ -0,0 +1,177 @@ +package cli_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +// TestStart also tests restart since the tests are virtually identical. +func TestStart(t *testing.T) { + t.Parallel() + + t.Run("RequireActiveVersion", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + + // Create an initial version. + oldVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil) + // Create a template that mandates the promoted version. + // This should be enforced for everyone except template admins. + template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, oldVersion.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, oldVersion.ID) + require.Equal(t, oldVersion.ID, template.ActiveVersionID) + template, err := ownerClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: true, + }) + require.NoError(t, err) + require.True(t, template.RequireActiveVersion) + + // Create a new version that we will promote. + activeVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, activeVersion.ID) + err = ownerClient.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: activeVersion.ID, + }) + require.NoError(t, err) + err = ownerClient.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: activeVersion.ID, + }) + require.NoError(t, err) + + templateAdminClient, templateAdmin := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + templateACLAdminClient, templateACLAdmin := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + templateGroupACLAdminClient, templateGroupACLAdmin := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + // Create a group so we can also test group template admin ownership. + group, err := ownerClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "test", + }) + require.NoError(t, err) + + // Add the user who gains template admin via group membership. + group, err = ownerClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{templateGroupACLAdmin.ID.String()}, + }) + require.NoError(t, err) + + // Update the template for both users and groups. + err = ownerClient.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + templateACLAdmin.ID.String(): codersdk.TemplateRoleAdmin, + }, + GroupPerms: map[string]codersdk.TemplateRole{ + group.ID.String(): codersdk.TemplateRoleAdmin, + }, + }) + require.NoError(t, err) + + type testcase struct { + Name string + Client *codersdk.Client + WorkspaceOwner uuid.UUID + ExpectedVersion uuid.UUID + } + + cases := []testcase{ + { + Name: "OwnerUnchanged", + Client: ownerClient, + WorkspaceOwner: owner.UserID, + ExpectedVersion: oldVersion.ID, + }, + { + Name: "TemplateAdminUnchanged", + Client: templateAdminClient, + WorkspaceOwner: templateAdmin.ID, + ExpectedVersion: oldVersion.ID, + }, + { + Name: "TemplateACLAdminUnchanged", + Client: templateACLAdminClient, + WorkspaceOwner: templateACLAdmin.ID, + ExpectedVersion: oldVersion.ID, + }, + { + Name: "TemplateGroupACLAdminUnchanged", + Client: templateGroupACLAdminClient, + WorkspaceOwner: templateGroupACLAdmin.ID, + ExpectedVersion: oldVersion.ID, + }, + { + Name: "MemberUpdates", + Client: memberClient, + WorkspaceOwner: member.ID, + ExpectedVersion: activeVersion.ID, + }, + } + + for _, cmd := range []string{"start", "restart"} { + cmd := cmd + t.Run(cmd, func(t *testing.T) { + t.Parallel() + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + // Instantiate a new context for each subtest since + // they can potentially be lengthy. + ctx := testutil.Context(t, testutil.WaitMedium) + // Create the workspace using the admin since we want + // to force the old version. + ws, err := ownerClient.CreateWorkspace(ctx, owner.OrganizationID, c.WorkspaceOwner.String(), codersdk.CreateWorkspaceRequest{ + TemplateVersionID: oldVersion.ID, + Name: coderdtest.RandomUsername(t), + AutomaticUpdates: codersdk.AutomaticUpdatesNever, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, c.Client, ws.LatestBuild.ID) + + 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 + }) + } + // Start the workspace. Every test permutation should + // pass. + inv, conf := newCLI(t, cmd, ws.Name, "-y") + 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) + }) + } + }) + } + }) +} diff --git a/enterprise/cli/templatecreate_test.go b/enterprise/cli/templatecreate_test.go new file mode 100644 index 0000000000000..7fe699d531e20 --- /dev/null +++ b/enterprise/cli/templatecreate_test.go @@ -0,0 +1,95 @@ +package cli_test + +import ( + "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/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/testutil" +) + +func TestTemplateCreate(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentTemplateUpdatePolicies), + } + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + }, + }, + Options: &coderdtest.Options{ + DeploymentValues: dv, + IncludeProvisionerDaemon: true, + }, + }) + + source := clitest.CreateTemplateVersionSource(t, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + }) + + inv, conf := newCLI(t, "templates", + "create", "new", + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--require-active-version", + "-y", + ) + + clitest.SetupConfig(t, client, conf) + + err := inv.Run() + require.NoError(t, err) + + ctx := testutil.Context(t, testutil.WaitMedium) + template, err := client.TemplateByName(ctx, user.OrganizationID, "new") + require.NoError(t, err) + require.True(t, template.RequireActiveVersion) + }) + + t.Run("NotEntitled", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentTemplateUpdatePolicies), + } + + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{}, + }, + Options: &coderdtest.Options{ + DeploymentValues: dv, + IncludeProvisionerDaemon: true, + }, + }) + + inv, conf := newCLI(t, "templates", + "create", "new", + "--require-active-version", + "-y", + ) + + clitest.SetupConfig(t, client, conf) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "your license is not entitled to use enterprise access control, so you cannot set --require-active-version") + }) +} diff --git a/enterprise/cli/templateedit_test.go b/enterprise/cli/templateedit_test.go new file mode 100644 index 0000000000000..5c26c19d820d7 --- /dev/null +++ b/enterprise/cli/templateedit_test.go @@ -0,0 +1,98 @@ +package cli_test + +import ( + "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/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestTemplateEdit(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentTemplateUpdatePolicies), + } + + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + }, + }, + Options: &coderdtest.Options{ + DeploymentValues: dv, + IncludeProvisionerDaemon: true, + }, + }) + + templateAdmin, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID) + template := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID) + require.False(t, template.RequireActiveVersion) + + inv, conf := newCLI(t, "templates", + "edit", template.Name, + "--require-active-version", + "-y", + ) + + clitest.SetupConfig(t, templateAdmin, conf) + + err := inv.Run() + require.NoError(t, err) + + ctx := testutil.Context(t, testutil.WaitMedium) + template, err = templateAdmin.Template(ctx, template.ID) + require.NoError(t, err) + require.True(t, template.RequireActiveVersion) + }) + + t.Run("NotEntitled", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentTemplateUpdatePolicies), + } + + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{}, + }, + Options: &coderdtest.Options{ + DeploymentValues: dv, + IncludeProvisionerDaemon: true, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + require.False(t, template.RequireActiveVersion) + + inv, conf := newCLI(t, "templates", + "edit", template.Name, + "--require-active-version", + "-y", + ) + + clitest.SetupConfig(t, client, conf) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "your license is not entitled to use enterprise access control, so you cannot set --require-active-version") + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 805ab0d2bacf9..9209feea44176 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1706,7 +1706,8 @@ export type Experiment = | "moons" | "single_tailnet" | "tailnet_pg_coordinator" - | "template_autostop_requirement"; + | "template_autostop_requirement" + | "template_update_policies"; export const Experiments: Experiment[] = [ "dashboard_theme", "deployment_health_page", @@ -1714,6 +1715,7 @@ export const Experiments: Experiment[] = [ "single_tailnet", "tailnet_pg_coordinator", "template_autostop_requirement", + "template_update_policies", ]; // From codersdk/deployment.go