Skip to content

Commit 92d505c

Browse files
authored
feat(cli): prevent coder schedule command on prebuilt workspaces (#19259)
## Description This PR adds CLI-side validation to prevent the use of the `coder schedule` command (including both `start` and `stop` subcommands) on prebuilt workspaces. Prebuilt workspaces are scheduled independently by the reconciliation loop, based on template and preset-level configuration. They do not participate in the regular user workspace lifecycle, and cannot be configured via the `coder schedule` CLI command. This change ensures that attempting to configure scheduling on a prebuilt workspace results in a clear CLI error. ## Changes - `coder schedule start` — now returns an error if the target workspace is a prebuild - `coder schedule stop` — now returns an error if the target workspace is a prebuild Related with: * Issue: #18898 * **Depends on PR**: #19252
1 parent e10f29c commit 92d505c

File tree

4 files changed

+174
-3
lines changed

4 files changed

+174
-3
lines changed

cli/schedule.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ When enabling scheduled stop, enter a duration in one of the following formats:
4646
* 2m (2 minutes)
4747
* 2 (2 minutes)
4848
`
49-
scheduleExtendDescriptionLong = `
49+
scheduleExtendDescriptionLong = `Extends the workspace deadline.
5050
* The new stop time is calculated from *now*.
5151
* The new stop time must be at least 30 minutes in the future.
5252
* The workspace template may restrict the maximum workspace runtime.
@@ -157,6 +157,13 @@ func (r *RootCmd) scheduleStart() *serpent.Command {
157157
return err
158158
}
159159

160+
// Autostart configuration is not supported for prebuilt workspaces.
161+
// Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior
162+
// defined per preset at the template level, not per workspace.
163+
if workspace.IsPrebuild {
164+
return xerrors.Errorf("autostart configuration is not supported for prebuilt workspaces")
165+
}
166+
160167
var schedStr *string
161168
if inv.Args[1] != "manual" {
162169
sched, err := parseCLISchedule(inv.Args[1:]...)
@@ -205,6 +212,13 @@ func (r *RootCmd) scheduleStop() *serpent.Command {
205212
return err
206213
}
207214

215+
// Autostop configuration is not supported for prebuilt workspaces.
216+
// Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior
217+
// defined per preset at the template level, not per workspace.
218+
if workspace.IsPrebuild {
219+
return xerrors.Errorf("autostop configuration is not supported for prebuilt workspaces")
220+
}
221+
208222
var durMillis *int64
209223
if inv.Args[1] != "manual" {
210224
dur, err := parseDuration(inv.Args[1])
@@ -255,6 +269,13 @@ func (r *RootCmd) scheduleExtend() *serpent.Command {
255269
return xerrors.Errorf("get workspace: %w", err)
256270
}
257271

272+
// Deadline extensions are not supported for prebuilt workspaces.
273+
// Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior
274+
// defined per preset at the template level, not per workspace.
275+
if workspace.IsPrebuild {
276+
return xerrors.Errorf("extend configuration is not supported for prebuilt workspaces")
277+
}
278+
258279
loc, err := tz.TimezoneIANA()
259280
if err != nil {
260281
loc = time.UTC // best effort

cli/testdata/coder_schedule_extend_--help.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ USAGE:
77

88
Aliases: override-stop
99

10-
* The new stop time is calculated from *now*.
10+
Extends the workspace deadline.
11+
* The new stop time is calculated from *now*.
1112
* The new stop time must be at least 30 minutes in the future.
1213
* The workspace template may restrict the maximum workspace runtime.
1314

docs/reference/cli/schedule_extend.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

enterprise/cli/prebuilds_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,30 @@ package cli_test
22

33
import (
44
"bytes"
5+
"database/sql"
56
"net/http"
67
"testing"
8+
"time"
79

10+
"github.com/google/uuid"
811
"github.com/stretchr/testify/assert"
912
"github.com/stretchr/testify/require"
1013

1114
"github.com/coder/coder/v2/cli/clitest"
1215
"github.com/coder/coder/v2/coderd/coderdtest"
16+
"github.com/coder/coder/v2/coderd/database"
17+
"github.com/coder/coder/v2/coderd/database/dbauthz"
18+
"github.com/coder/coder/v2/coderd/database/dbfake"
19+
"github.com/coder/coder/v2/coderd/database/dbgen"
20+
"github.com/coder/coder/v2/coderd/database/dbtime"
21+
"github.com/coder/coder/v2/coderd/util/ptr"
1322
"github.com/coder/coder/v2/codersdk"
1423
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
1524
"github.com/coder/coder/v2/enterprise/coderd/license"
25+
"github.com/coder/coder/v2/provisionersdk/proto"
26+
"github.com/coder/coder/v2/pty/ptytest"
27+
"github.com/coder/coder/v2/testutil"
28+
"github.com/coder/quartz"
1629
)
1730

1831
func TestPrebuildsPause(t *testing.T) {
@@ -341,3 +354,139 @@ func TestPrebuildsSettingsAPI(t *testing.T) {
341354
assert.False(t, settings.ReconciliationPaused)
342355
})
343356
}
357+
358+
// TestSchedulePrebuilds verifies the CLI schedule command when used with prebuilds.
359+
// Running the command on an unclaimed prebuild fails, but after the prebuild is
360+
// claimed (becoming a regular workspace) it succeeds as expected.
361+
func TestSchedulePrebuilds(t *testing.T) {
362+
t.Parallel()
363+
364+
cases := []struct {
365+
name string
366+
cliErrorMsg string
367+
cmdArgs func(string) []string
368+
}{
369+
{
370+
name: "AutostartPrebuildError",
371+
cliErrorMsg: "autostart configuration is not supported for prebuilt workspaces",
372+
cmdArgs: func(workspaceName string) []string {
373+
return []string{"schedule", "start", workspaceName, "7:30AM", "Mon-Fri", "Europe/Lisbon"}
374+
},
375+
},
376+
{
377+
name: "AutostopPrebuildError",
378+
cliErrorMsg: "autostop configuration is not supported for prebuilt workspaces",
379+
cmdArgs: func(workspaceName string) []string {
380+
return []string{"schedule", "stop", workspaceName, "8h30m"}
381+
},
382+
},
383+
{
384+
name: "ExtendPrebuildError",
385+
cliErrorMsg: "extend configuration is not supported for prebuilt workspaces",
386+
cmdArgs: func(workspaceName string) []string {
387+
return []string{"schedule", "extend", workspaceName, "90m"}
388+
},
389+
},
390+
}
391+
392+
for _, tc := range cases {
393+
tc := tc
394+
t.Run(tc.name, func(t *testing.T) {
395+
t.Parallel()
396+
397+
clock := quartz.NewMock(t)
398+
clock.Set(dbtime.Now())
399+
400+
// Setup
401+
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
402+
Options: &coderdtest.Options{
403+
IncludeProvisionerDaemon: true,
404+
Clock: clock,
405+
},
406+
LicenseOptions: &coderdenttest.LicenseOptions{
407+
Features: license.Features{
408+
codersdk.FeatureWorkspacePrebuilds: 1,
409+
},
410+
},
411+
})
412+
413+
// Given: a template and a template version with preset and a prebuilt workspace
414+
presetID := uuid.New()
415+
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
416+
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
417+
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
418+
dbgen.Preset(t, db, database.InsertPresetParams{
419+
ID: presetID,
420+
TemplateVersionID: version.ID,
421+
DesiredInstances: sql.NullInt32{Int32: 1, Valid: true},
422+
})
423+
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
424+
OwnerID: database.PrebuildsSystemUserID,
425+
TemplateID: template.ID,
426+
}).Seed(database.WorkspaceBuild{
427+
TemplateVersionID: version.ID,
428+
TemplateVersionPresetID: uuid.NullUUID{
429+
UUID: presetID,
430+
Valid: true,
431+
},
432+
}).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
433+
return agent
434+
}).Do()
435+
436+
// Mark the prebuilt workspace's agent as ready so the prebuild can be claimed
437+
// nolint:gocritic
438+
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong))
439+
agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(workspaceBuild.AgentToken))
440+
require.NoError(t, err)
441+
err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
442+
ID: agent.WorkspaceAgent.ID,
443+
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
444+
})
445+
require.NoError(t, err)
446+
447+
// Given: a prebuilt workspace
448+
prebuild := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID)
449+
450+
// When: running the schedule command over a prebuilt workspace
451+
inv, root := clitest.New(t, tc.cmdArgs(prebuild.OwnerName+"/"+prebuild.Name)...)
452+
clitest.SetupConfig(t, client, root)
453+
ptytest.New(t).Attach(inv)
454+
doneChan := make(chan struct{})
455+
var runErr error
456+
go func() {
457+
defer close(doneChan)
458+
runErr = inv.Run()
459+
}()
460+
<-doneChan
461+
462+
// Then: an error should be returned, with an error message specific to the lifecycle parameter
463+
require.Error(t, runErr)
464+
require.Contains(t, runErr.Error(), tc.cliErrorMsg)
465+
466+
// Given: the prebuilt workspace is claimed by a user
467+
user, err := client.User(ctx, "testUser")
468+
require.NoError(t, err)
469+
claimedWorkspace, err := client.CreateUserWorkspace(ctx, user.ID.String(), codersdk.CreateWorkspaceRequest{
470+
TemplateVersionID: version.ID,
471+
TemplateVersionPresetID: presetID,
472+
Name: coderdtest.RandomUsername(t),
473+
// The 'extend' command requires the workspace to have an existing deadline.
474+
// To ensure this, we set the workspace's TTL to 1 hour.
475+
TTLMillis: ptr.Ref[int64](time.Hour.Milliseconds()),
476+
})
477+
require.NoError(t, err)
478+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, claimedWorkspace.LatestBuild.ID)
479+
workspace := coderdtest.MustWorkspace(t, client, claimedWorkspace.ID)
480+
require.Equal(t, prebuild.ID, workspace.ID)
481+
482+
// When: running the schedule command over the claimed workspace
483+
inv, root = clitest.New(t, tc.cmdArgs(workspace.OwnerName+"/"+workspace.Name)...)
484+
clitest.SetupConfig(t, client, root)
485+
pty := ptytest.New(t).Attach(inv)
486+
require.NoError(t, inv.Run())
487+
488+
// Then: the updated schedule should be shown
489+
pty.ExpectMatch(workspace.OwnerName + "/" + workspace.Name)
490+
})
491+
}
492+
}

0 commit comments

Comments
 (0)