mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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: https://github.com/coder/coder/issues/18898 * **Depends on PR**: https://github.com/coder/coder/pull/19252
This commit is contained in:
+22
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -16,7 +16,7 @@ coder schedule extend <workspace-name> <duration from now>
|
||||
## 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.
|
||||
|
||||
@@ -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/dbgen"
|
||||
"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,139 @@ 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()
|
||||
|
||||
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()
|
||||
|
||||
clock := quartz.NewMock(t)
|
||||
clock.Set(dbtime.Now())
|
||||
|
||||
// Setup
|
||||
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()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
dbgen.Preset(t, db, database.InsertPresetParams{
|
||||
ID: presetID,
|
||||
TemplateVersionID: version.ID,
|
||||
DesiredInstances: sql.NullInt32{Int32: 1, Valid: true},
|
||||
})
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OwnerID: database.PrebuildsSystemUserID,
|
||||
TemplateID: template.ID,
|
||||
}).Seed(database.WorkspaceBuild{
|
||||
TemplateVersionID: version.ID,
|
||||
TemplateVersionPresetID: uuid.NullUUID{
|
||||
UUID: presetID,
|
||||
Valid: true,
|
||||
},
|
||||
}).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
|
||||
return agent
|
||||
}).Do()
|
||||
|
||||
// Mark the prebuilt workspace's agent as ready so the prebuild can be claimed
|
||||
// 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)
|
||||
|
||||
// Given: a prebuilt workspace
|
||||
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: an error should be returned, with an error message specific to the lifecycle parameter
|
||||
require.Error(t, runErr)
|
||||
require.Contains(t, runErr.Error(), tc.cliErrorMsg)
|
||||
|
||||
// Given: the prebuilt workspace is claimed by a user
|
||||
user, err := client.User(ctx, "testUser")
|
||||
require.NoError(t, err)
|
||||
claimedWorkspace, err := client.CreateUserWorkspace(ctx, user.ID.String(), codersdk.CreateWorkspaceRequest{
|
||||
TemplateVersionID: version.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](time.Hour.Milliseconds()),
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user