From 61778e710114f50187662b8b2c0f2e5be1483e6d Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 8 Aug 2025 16:54:47 +0000 Subject: [PATCH 1/5] feat(cli): prevent coder schedule command on prebuilt workspaces --- cli/schedule.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cli/schedule.go b/cli/schedule.go index 9ade82b9c4a36..f74d603d97282 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -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]) From c5853a0bb96eb09d1f5ddfa5447476d4559d4648 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Tue, 12 Aug 2025 17:59:38 +0000 Subject: [PATCH 2/5] feat: update 'schedule extend' command to error in case of prebuild --- cli/schedule.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/schedule.go b/cli/schedule.go index f74d603d97282..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. @@ -269,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 From ff6cc7b7e3da5dcd8d9d5521590736c0710c3ba9 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Tue, 12 Aug 2025 18:00:27 +0000 Subject: [PATCH 3/5] test: add tests for schedule command with prebuilds --- .../coder_schedule_extend_--help.golden | 3 +- docs/reference/cli/schedule_extend.md | 2 +- enterprise/cli/prebuilds_test.go | 154 ++++++++++++++++++ 3 files changed, 157 insertions(+), 2 deletions(-) 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..1fa1c6bcf8b5f 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,144 @@ 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") + } + + // Setup + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) + client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + IncludeProvisionerDaemon: true, + Clock: clock, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspacePrebuilds: 1, + }, + }, + }) + + cases := []struct { + name string + cliErrorMsg string + cmdArgs func(string) []string + }{ + { + name: "Autostart", + 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: "Autostop", + cliErrorMsg: "autostop configuration is not supported for prebuilt workspaces", + cmdArgs: func(workspaceName string) []string { + return []string{"schedule", "stop", workspaceName, "8h30m"} + }, + }, + //{ + // name: "Extend", + // 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() + + // 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) + }) + } +} From cc872b98e406e95a259a299607e250d8b7284f6b Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Tue, 12 Aug 2025 18:05:35 +0000 Subject: [PATCH 4/5] test: improve test cases names --- enterprise/cli/prebuilds_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/enterprise/cli/prebuilds_test.go b/enterprise/cli/prebuilds_test.go index 1fa1c6bcf8b5f..48d7289dc2212 100644 --- a/enterprise/cli/prebuilds_test.go +++ b/enterprise/cli/prebuilds_test.go @@ -387,21 +387,21 @@ func TestSchedulePrebuilds(t *testing.T) { cmdArgs func(string) []string }{ { - name: "Autostart", + 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: "Autostop", + name: "AutostopPrebuildError", cliErrorMsg: "autostop configuration is not supported for prebuilt workspaces", cmdArgs: func(workspaceName string) []string { return []string{"schedule", "stop", workspaceName, "8h30m"} }, }, //{ - // name: "Extend", + // name: "ExtendPrebuildError", // cliErrorMsg: "extend configuration is not supported for prebuilt workspaces", // cmdArgs: func(workspaceName string) []string { // return []string{"schedule", "extend", workspaceName, "90m"} From 50133fcc9f95b6e1071aabcb5b0c5a81b14f63ff Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Wed, 13 Aug 2025 09:08:40 +0000 Subject: [PATCH 5/5] fix: prebuilds schedule tests --- enterprise/cli/prebuilds_test.go | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/enterprise/cli/prebuilds_test.go b/enterprise/cli/prebuilds_test.go index 48d7289dc2212..c559141d8c6d2 100644 --- a/enterprise/cli/prebuilds_test.go +++ b/enterprise/cli/prebuilds_test.go @@ -365,22 +365,6 @@ func TestSchedulePrebuilds(t *testing.T) { t.Skip("this test requires postgres") } - // Setup - clock := quartz.NewMock(t) - clock.Set(dbtime.Now()) - client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - DeploymentValues: coderdtest.DeploymentValues(t), - IncludeProvisionerDaemon: true, - Clock: clock, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureWorkspacePrebuilds: 1, - }, - }, - }) - cases := []struct { name string cliErrorMsg string @@ -414,6 +398,21 @@ func TestSchedulePrebuilds(t *testing.T) { 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{