diff --git a/cli/schedule.go b/cli/schedule.go index 9ade82b9c4a36..c09d275eb7bb3 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -46,7 +46,7 @@ When enabling scheduled stop, enter a duration in one of the following formats: * 2m (2 minutes) * 2 (2 minutes) ` - scheduleExtendDescriptionLong = ` + scheduleExtendDescriptionLong = `Extends the workspace deadline. * The new stop time is calculated from *now*. * The new stop time must be at least 30 minutes in the future. * The workspace template may restrict the maximum workspace runtime. @@ -157,6 +157,13 @@ func (r *RootCmd) scheduleStart() *serpent.Command { return err } + // Autostart configuration is not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild { + return xerrors.Errorf("autostart configuration is not supported for prebuilt workspaces") + } + var schedStr *string if inv.Args[1] != "manual" { sched, err := parseCLISchedule(inv.Args[1:]...) @@ -205,6 +212,13 @@ func (r *RootCmd) scheduleStop() *serpent.Command { return err } + // Autostop configuration is not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild { + return xerrors.Errorf("autostop configuration is not supported for prebuilt workspaces") + } + var durMillis *int64 if inv.Args[1] != "manual" { dur, err := parseDuration(inv.Args[1]) @@ -255,6 +269,13 @@ func (r *RootCmd) scheduleExtend() *serpent.Command { return xerrors.Errorf("get workspace: %w", err) } + // Deadline extensions are not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild { + return xerrors.Errorf("extend configuration is not supported for prebuilt workspaces") + } + loc, err := tz.TimezoneIANA() if err != nil { loc = time.UTC // best effort diff --git a/cli/testdata/coder_schedule_extend_--help.golden b/cli/testdata/coder_schedule_extend_--help.golden index 2135b09dc7cc3..57992108cb7c0 100644 --- a/cli/testdata/coder_schedule_extend_--help.golden +++ b/cli/testdata/coder_schedule_extend_--help.golden @@ -7,7 +7,8 @@ USAGE: Aliases: override-stop - * The new stop time is calculated from *now*. + Extends the workspace deadline. + * The new stop time is calculated from *now*. * The new stop time must be at least 30 minutes in the future. * The workspace template may restrict the maximum workspace runtime. diff --git a/docs/reference/cli/schedule_extend.md b/docs/reference/cli/schedule_extend.md index e4b696ad5c4a7..aa4540b4d7d31 100644 --- a/docs/reference/cli/schedule_extend.md +++ b/docs/reference/cli/schedule_extend.md @@ -16,7 +16,7 @@ coder schedule extend ## Description ```console - +Extends the workspace deadline. * The new stop time is calculated from *now*. * The new stop time must be at least 30 minutes in the future. * The workspace template may restrict the maximum workspace runtime. diff --git a/enterprise/cli/prebuilds_test.go b/enterprise/cli/prebuilds_test.go index b5960436edcfb..c559141d8c6d2 100644 --- a/enterprise/cli/prebuilds_test.go +++ b/enterprise/cli/prebuilds_test.go @@ -2,17 +2,30 @@ package cli_test import ( "bytes" + "database/sql" "net/http" "testing" + "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "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/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/util/ptr" "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/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" ) func TestPrebuildsPause(t *testing.T) { @@ -341,3 +354,143 @@ func TestPrebuildsSettingsAPI(t *testing.T) { assert.False(t, settings.ReconciliationPaused) }) } + +// TestSchedulePrebuilds verifies the CLI schedule command when used with prebuilds. +// Running the command on an unclaimed prebuild fails, but after the prebuild is +// claimed (becoming a regular workspace) it succeeds as expected. +func TestSchedulePrebuilds(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + + cases := []struct { + name string + cliErrorMsg string + cmdArgs func(string) []string + }{ + { + name: "AutostartPrebuildError", + cliErrorMsg: "autostart configuration is not supported for prebuilt workspaces", + cmdArgs: func(workspaceName string) []string { + return []string{"schedule", "start", workspaceName, "7:30AM", "Mon-Fri", "Europe/Lisbon"} + }, + }, + { + name: "AutostopPrebuildError", + cliErrorMsg: "autostop configuration is not supported for prebuilt workspaces", + cmdArgs: func(workspaceName string) []string { + return []string{"schedule", "stop", workspaceName, "8h30m"} + }, + }, + //{ + // name: "ExtendPrebuildError", + // cliErrorMsg: "extend configuration is not supported for prebuilt workspaces", + // cmdArgs: func(workspaceName string) []string { + // return []string{"schedule", "extend", workspaceName, "90m"} + // }, + // }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) + client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Clock: clock, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspacePrebuilds: 1, + }, + }, + }) + + // Given: a template and a template version with preset and a prebuilt workspace + presetID := uuid.New() + tv := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + }).Preset(database.TemplateVersionPreset{ + ID: presetID, + DesiredInstances: sql.NullInt32{ + Int32: 1, + Valid: true, + }, + }).Do() + + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: database.PrebuildsSystemUserID, + TemplateID: tv.Template.ID, + }).Seed(database.WorkspaceBuild{ + TemplateVersionID: tv.TemplateVersion.ID, + TemplateVersionPresetID: uuid.NullUUID{ + UUID: presetID, + Valid: true, + }, + Deadline: clock.Now().Add(time.Hour), + }).WithAgent(func(agent []*proto.Agent) []*proto.Agent { + return agent + }).Do() + + // nolint:gocritic + ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong)) + agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(workspaceBuild.AgentToken)) + require.NoError(t, err) + err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.WorkspaceAgent.ID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + }) + require.NoError(t, err) + prebuild := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID) + + // When: running the schedule command over a prebuilt workspace + inv, root := clitest.New(t, tc.cmdArgs(prebuild.OwnerName+"/"+prebuild.Name)...) + clitest.SetupConfig(t, client, root) + ptytest.New(t).Attach(inv) + doneChan := make(chan struct{}) + var runErr error + go func() { + defer close(doneChan) + runErr = inv.Run() + }() + <-doneChan + + // Then: return an error + require.Error(t, runErr) + require.Contains(t, runErr.Error(), tc.cliErrorMsg) + + // Given: a user claims the prebuilt workspace + user, err := client.User(ctx, "testUser") + require.NoError(t, err) + claimedWorkspace, err := client.CreateUserWorkspace(ctx, user.ID.String(), codersdk.CreateWorkspaceRequest{ + TemplateVersionID: tv.TemplateVersion.ID, + TemplateVersionPresetID: presetID, + Name: coderdtest.RandomUsername(t), + // The 'extend' command requires the workspace to have an existing deadline. + // To ensure this, we set the workspace's TTL to 1 hour. + TTLMillis: ptr.Ref[int64](3600000), + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, claimedWorkspace.LatestBuild.ID) + workspace := coderdtest.MustWorkspace(t, client, claimedWorkspace.ID) + require.Equal(t, prebuild.ID, workspace.ID) + + // When: running the schedule command over the claimed workspace + inv, root = clitest.New(t, tc.cmdArgs(workspace.OwnerName+"/"+workspace.Name)...) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t).Attach(inv) + require.NoError(t, inv.Run()) + + // Then: the updated schedule should be shown + pty.ExpectMatch(workspace.OwnerName + "/" + workspace.Name) + }) + } +}