Skip to content

Commit 734299d

Browse files
authored
fix: disallow lifecycle endpoints for prebuilt workspaces (#19264)
## Description This PR updates the API to prevent lifecycle configuration endpoints from being used on prebuilt workspaces. Since prebuilds are managed by the reconciliation loop and do not participate in the regular workspace lifecycle, they must not support per-workspace overrides for fields like deadline, TTL, autostart, or dormancy. Attempting to use these endpoints on a prebuilt workspace will now return a clear validation error (`409 Conflict`) with an appropriate explanation. This prevents accidental misconfiguration and preserves the lifecycle separation between prebuilds and regular workspaces. ## Changes The following endpoints now return an error if the target workspace is a prebuild: * `PUT /workspaces/{workspace}/extend` * `PUT /workspaces/{workspace}/ttl` * `PUT /workspaces/{workspace}/autostart` * `PUT /workspaces/{workspace}/dormant` Update endpoints logic to use the API clock in order to allow time mocking in tests. Related with: * Issue: #18898 * PR: #19252
1 parent af97b78 commit 734299d

File tree

2 files changed

+254
-17
lines changed

2 files changed

+254
-17
lines changed

coderd/workspaces.go

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,17 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
10891089
return
10901090
}
10911091

1092+
// Autostart configuration is not supported for prebuilt workspaces.
1093+
// Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior
1094+
// defined per preset at the template level, not per workspace.
1095+
if workspace.IsPrebuild() {
1096+
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
1097+
Message: "Autostart is not supported for prebuilt workspaces",
1098+
Detail: "Prebuilt workspace scheduling is configured per preset at the template level. Workspace-level overrides are not supported.",
1099+
})
1100+
return
1101+
}
1102+
10921103
dbSched, err := validWorkspaceSchedule(req.Schedule)
10931104
if err != nil {
10941105
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -1115,12 +1126,20 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
11151126
return
11161127
}
11171128

1129+
// Use injected Clock to allow time mocking in tests
1130+
now := api.Clock.Now()
1131+
11181132
nextStartAt := sql.NullTime{}
11191133
if dbSched.Valid {
1120-
next, err := schedule.NextAllowedAutostart(dbtime.Now(), dbSched.String, templateSchedule)
1121-
if err == nil {
1122-
nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
1134+
next, err := schedule.NextAllowedAutostart(now, dbSched.String, templateSchedule)
1135+
if err != nil {
1136+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1137+
Message: "Internal error calculating workspace autostart schedule.",
1138+
Detail: err.Error(),
1139+
})
1140+
return
11231141
}
1142+
nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
11241143
}
11251144

11261145
err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{
@@ -1173,6 +1192,17 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
11731192
return
11741193
}
11751194

1195+
// TTL updates are not supported for prebuilt workspaces.
1196+
// Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior
1197+
// defined per preset at the template level, not per workspace.
1198+
if workspace.IsPrebuild() {
1199+
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
1200+
Message: "TTL updates are not supported for prebuilt workspaces",
1201+
Detail: "Prebuilt workspace TTL is configured per preset at the template level. Workspace-level overrides are not supported.",
1202+
})
1203+
return
1204+
}
1205+
11761206
var dbTTL sql.NullInt64
11771207

11781208
err := api.Database.InTx(func(s database.Store) error {
@@ -1198,6 +1228,9 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
11981228
return xerrors.Errorf("update workspace time until shutdown: %w", err)
11991229
}
12001230

1231+
// Use injected Clock to allow time mocking in tests
1232+
now := api.Clock.Now()
1233+
12011234
// If autostop has been disabled, we want to remove the deadline from the
12021235
// existing workspace build (if there is one).
12031236
if !dbTTL.Valid {
@@ -1225,7 +1258,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
12251258
// more information.
12261259
Deadline: build.MaxDeadline,
12271260
MaxDeadline: build.MaxDeadline,
1228-
UpdatedAt: dbtime.Time(api.Clock.Now()),
1261+
UpdatedAt: dbtime.Time(now),
12291262
}); err != nil {
12301263
return xerrors.Errorf("update workspace build deadline: %w", err)
12311264
}
@@ -1289,17 +1322,30 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
12891322
return
12901323
}
12911324

1325+
// Dormancy configuration is not supported for prebuilt workspaces.
1326+
// Prebuilds are managed by the reconciliation loop and are not subject to dormancy.
1327+
if oldWorkspace.IsPrebuild() {
1328+
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
1329+
Message: "Dormancy updates are not supported for prebuilt workspaces",
1330+
Detail: "Prebuilt workspaces are not subject to dormancy. Dormancy behavior is only applicable to regular workspaces",
1331+
})
1332+
return
1333+
}
1334+
12921335
// If the workspace is already in the desired state do nothing!
12931336
if oldWorkspace.DormantAt.Valid == req.Dormant {
12941337
rw.WriteHeader(http.StatusNotModified)
12951338
return
12961339
}
12971340

1341+
// Use injected Clock to allow time mocking in tests
1342+
now := api.Clock.Now()
1343+
12981344
dormantAt := sql.NullTime{
12991345
Valid: req.Dormant,
13001346
}
13011347
if req.Dormant {
1302-
dormantAt.Time = dbtime.Now()
1348+
dormantAt.Time = dbtime.Time(now)
13031349
}
13041350

13051351
newWorkspace, err := api.Database.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
@@ -1339,7 +1385,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
13391385
}
13401386

13411387
if initiatorErr == nil && tmplErr == nil {
1342-
dormantTime := dbtime.Now().Add(time.Duration(tmpl.TimeTilDormant))
1388+
dormantTime := dbtime.Time(now).Add(time.Duration(tmpl.TimeTilDormant))
13431389
_, err = api.NotificationsEnqueuer.Enqueue(
13441390
// nolint:gocritic // Need notifier actor to enqueue notifications
13451391
dbauthz.AsNotifier(ctx),
@@ -1433,6 +1479,17 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
14331479
return
14341480
}
14351481

1482+
// Deadline extensions are not supported for prebuilt workspaces.
1483+
// Prebuilds are managed by the reconciliation loop and must always have
1484+
// Deadline and MaxDeadline unset.
1485+
if workspace.IsPrebuild() {
1486+
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
1487+
Message: "Deadline extension is not supported for prebuilt workspaces",
1488+
Detail: "Prebuilt workspaces do not support user deadline modifications. Deadline extension is only applicable to regular workspaces",
1489+
})
1490+
return
1491+
}
1492+
14361493
code := http.StatusOK
14371494
resp := codersdk.Response{}
14381495

@@ -1469,8 +1526,11 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
14691526
return xerrors.Errorf("workspace shutdown is manual")
14701527
}
14711528

1529+
// Use injected Clock to allow time mocking in tests
1530+
now := api.Clock.Now()
1531+
14721532
newDeadline := req.Deadline.UTC()
1473-
if err := validWorkspaceDeadline(job.CompletedAt.Time, newDeadline); err != nil {
1533+
if err := validWorkspaceDeadline(now, job.CompletedAt.Time, newDeadline); err != nil {
14741534
// NOTE(Cian): Putting the error in the Message field on request from the FE folks.
14751535
// Normally, we would put the validation error in Validations, but this endpoint is
14761536
// not tied to a form or specific named user input on the FE.
@@ -1486,7 +1546,7 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
14861546

14871547
if err := s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
14881548
ID: build.ID,
1489-
UpdatedAt: dbtime.Now(),
1549+
UpdatedAt: dbtime.Time(now),
14901550
Deadline: newDeadline,
14911551
MaxDeadline: build.MaxDeadline,
14921552
}); err != nil {
@@ -2441,8 +2501,8 @@ func validWorkspaceAutomaticUpdates(updates codersdk.AutomaticUpdates) (database
24412501
return dbAU, nil
24422502
}
24432503

2444-
func validWorkspaceDeadline(startedAt, newDeadline time.Time) error {
2445-
soon := time.Now().Add(29 * time.Minute)
2504+
func validWorkspaceDeadline(now, startedAt, newDeadline time.Time) error {
2505+
soon := now.Add(29 * time.Minute)
24462506
if newDeadline.Before(soon) {
24472507
return errDeadlineTooSoon
24482508
}

enterprise/coderd/workspaces_test.go

Lines changed: 184 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,12 @@ import (
1515
"testing"
1616
"time"
1717

18-
"github.com/prometheus/client_golang/prometheus"
19-
20-
"github.com/coder/coder/v2/coderd/files"
21-
agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
22-
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
23-
2418
"github.com/google/uuid"
19+
"github.com/prometheus/client_golang/prometheus"
2520
"github.com/stretchr/testify/assert"
2621
"github.com/stretchr/testify/require"
2722

2823
"cdr.dev/slog"
29-
3024
"cdr.dev/slog/sloggers/slogtest"
3125

3226
"github.com/coder/coder/v2/coderd/audit"
@@ -35,10 +29,13 @@ import (
3529
"github.com/coder/coder/v2/coderd/database"
3630
"github.com/coder/coder/v2/coderd/database/dbauthz"
3731
"github.com/coder/coder/v2/coderd/database/dbfake"
32+
"github.com/coder/coder/v2/coderd/database/dbgen"
3833
"github.com/coder/coder/v2/coderd/database/dbtestutil"
3934
"github.com/coder/coder/v2/coderd/database/dbtime"
35+
"github.com/coder/coder/v2/coderd/files"
4036
"github.com/coder/coder/v2/coderd/httpmw"
4137
"github.com/coder/coder/v2/coderd/notifications"
38+
agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
4239
"github.com/coder/coder/v2/coderd/provisionerdserver"
4340
"github.com/coder/coder/v2/coderd/rbac"
4441
"github.com/coder/coder/v2/coderd/rbac/policy"
@@ -50,6 +47,7 @@ import (
5047
"github.com/coder/coder/v2/enterprise/audit/backends"
5148
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
5249
"github.com/coder/coder/v2/enterprise/coderd/license"
50+
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
5351
"github.com/coder/coder/v2/enterprise/coderd/schedule"
5452
"github.com/coder/coder/v2/provisioner/echo"
5553
"github.com/coder/coder/v2/provisionersdk"
@@ -2519,6 +2517,185 @@ func templateWithFailedResponseAndPresetsWithPrebuilds(desiredInstances int32) *
25192517
}
25202518
}
25212519

2520+
func TestPrebuildUpdateLifecycleParams(t *testing.T) {
2521+
t.Parallel()
2522+
2523+
// Autostart schedule configuration set to weekly at 9:30 AM UTC
2524+
autostartSchedule, err := cron.Weekly("CRON_TZ=UTC 30 9 * * 1-5")
2525+
require.NoError(t, err)
2526+
2527+
// TTL configuration set to 8 hours
2528+
ttlMillis := ptr.Ref((8 * time.Hour).Milliseconds())
2529+
2530+
// Deadline configuration set to January 1st, 2024 at 10:00 AM UTC
2531+
deadline := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
2532+
2533+
cases := []struct {
2534+
name string
2535+
endpoint func(*testing.T, context.Context, *codersdk.Client, uuid.UUID) error
2536+
apiErrorMsg string
2537+
assertUpdate func(*testing.T, *quartz.Mock, *codersdk.Client, uuid.UUID)
2538+
}{
2539+
{
2540+
name: "AutostartUpdatePrebuildAfterClaim",
2541+
endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error {
2542+
err = client.UpdateWorkspaceAutostart(ctx, workspaceID, codersdk.UpdateWorkspaceAutostartRequest{
2543+
Schedule: ptr.Ref(autostartSchedule.String()),
2544+
})
2545+
return err
2546+
},
2547+
apiErrorMsg: "Autostart is not supported for prebuilt workspaces",
2548+
assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) {
2549+
// The workspace's autostart schedule should be updated to the given schedule,
2550+
// and its next start time should be set to 2024-01-01 09:30 AM UTC
2551+
updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID)
2552+
require.Equal(t, autostartSchedule.String(), *updatedWorkspace.AutostartSchedule)
2553+
require.Equal(t, autostartSchedule.Next(clock.Now()), updatedWorkspace.NextStartAt.UTC())
2554+
expectedNext := time.Date(2024, 1, 1, 9, 30, 0, 0, time.UTC)
2555+
require.Equal(t, expectedNext, updatedWorkspace.NextStartAt.UTC())
2556+
},
2557+
},
2558+
{
2559+
name: "TTLUpdatePrebuildAfterClaim",
2560+
endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error {
2561+
err := client.UpdateWorkspaceTTL(ctx, workspaceID, codersdk.UpdateWorkspaceTTLRequest{
2562+
TTLMillis: ttlMillis,
2563+
})
2564+
return err
2565+
},
2566+
apiErrorMsg: "TTL updates are not supported for prebuilt workspaces",
2567+
assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) {
2568+
// The workspace's TTL should be updated accordingly
2569+
updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID)
2570+
require.Equal(t, ttlMillis, updatedWorkspace.TTLMillis)
2571+
},
2572+
},
2573+
{
2574+
name: "DormantUpdatePrebuildAfterClaim",
2575+
endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error {
2576+
err := client.UpdateWorkspaceDormancy(ctx, workspaceID, codersdk.UpdateWorkspaceDormancy{
2577+
Dormant: true,
2578+
})
2579+
return err
2580+
},
2581+
apiErrorMsg: "Dormancy updates are not supported for prebuilt workspaces",
2582+
assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) {
2583+
// The workspace's dormantAt should be updated accordingly
2584+
updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID)
2585+
require.Equal(t, clock.Now(), updatedWorkspace.DormantAt.UTC())
2586+
},
2587+
},
2588+
{
2589+
name: "DeadlineUpdatePrebuildAfterClaim",
2590+
endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error {
2591+
err := client.PutExtendWorkspace(ctx, workspaceID, codersdk.PutExtendWorkspaceRequest{
2592+
Deadline: deadline,
2593+
})
2594+
return err
2595+
},
2596+
apiErrorMsg: "Deadline extension is not supported for prebuilt workspaces",
2597+
assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) {
2598+
// The workspace build's deadline should be updated accordingly
2599+
updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID)
2600+
require.Equal(t, deadline, updatedWorkspace.LatestBuild.Deadline.Time.UTC())
2601+
},
2602+
},
2603+
}
2604+
2605+
for _, tc := range cases {
2606+
tc := tc
2607+
t.Run(tc.name, func(t *testing.T) {
2608+
t.Parallel()
2609+
2610+
// Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic
2611+
clock := quartz.NewMock(t)
2612+
clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC))
2613+
2614+
// Setup
2615+
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
2616+
Options: &coderdtest.Options{
2617+
IncludeProvisionerDaemon: true,
2618+
Clock: clock,
2619+
},
2620+
LicenseOptions: &coderdenttest.LicenseOptions{
2621+
Features: license.Features{
2622+
codersdk.FeatureWorkspacePrebuilds: 1,
2623+
},
2624+
},
2625+
})
2626+
2627+
// Given: a template and a template version with preset and a prebuilt workspace
2628+
presetID := uuid.New()
2629+
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
2630+
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
2631+
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
2632+
dbgen.Preset(t, db, database.InsertPresetParams{
2633+
ID: presetID,
2634+
TemplateVersionID: version.ID,
2635+
DesiredInstances: sql.NullInt32{Int32: 1, Valid: true},
2636+
})
2637+
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
2638+
OwnerID: database.PrebuildsSystemUserID,
2639+
TemplateID: template.ID,
2640+
}).Seed(database.WorkspaceBuild{
2641+
TemplateVersionID: version.ID,
2642+
TemplateVersionPresetID: uuid.NullUUID{
2643+
UUID: presetID,
2644+
Valid: true,
2645+
},
2646+
}).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
2647+
return agent
2648+
}).Do()
2649+
2650+
// Mark the prebuilt workspace's agent as ready so the prebuild can be claimed
2651+
// nolint:gocritic
2652+
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong))
2653+
agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(ctx, uuid.MustParse(workspaceBuild.AgentToken))
2654+
require.NoError(t, err)
2655+
err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
2656+
ID: agent.WorkspaceAgent.ID,
2657+
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
2658+
})
2659+
require.NoError(t, err)
2660+
2661+
// Given: a prebuilt workspace
2662+
prebuild := coderdtest.MustWorkspace(t, client, workspaceBuild.Workspace.ID)
2663+
2664+
// When: the lifecycle-update endpoint is called for the prebuilt workspace
2665+
err = tc.endpoint(t, ctx, client, prebuild.ID)
2666+
2667+
// Then: a 409 Conflict should be returned, with an error message specific to the lifecycle parameter
2668+
var apiErr *codersdk.Error
2669+
require.ErrorAs(t, err, &apiErr)
2670+
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
2671+
require.Equal(t, tc.apiErrorMsg, apiErr.Response.Message)
2672+
2673+
// Given: the prebuilt workspace is claimed by a user
2674+
user, err := client.User(ctx, "testUser")
2675+
require.NoError(t, err)
2676+
claimedWorkspace, err := client.CreateUserWorkspace(ctx, user.ID.String(), codersdk.CreateWorkspaceRequest{
2677+
TemplateVersionID: version.ID,
2678+
TemplateVersionPresetID: presetID,
2679+
Name: coderdtest.RandomUsername(t),
2680+
// The 'extend' endpoint requires the workspace to have an existing deadline.
2681+
// To ensure this, we set the workspace's TTL to 1 hour.
2682+
TTLMillis: ptr.Ref[int64](time.Hour.Milliseconds()),
2683+
})
2684+
require.NoError(t, err)
2685+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, claimedWorkspace.LatestBuild.ID)
2686+
workspace := coderdtest.MustWorkspace(t, client, claimedWorkspace.ID)
2687+
require.Equal(t, prebuild.ID, workspace.ID)
2688+
2689+
// When: the same lifecycle-update endpoint is called for the claimed workspace
2690+
err = tc.endpoint(t, ctx, client, workspace.ID)
2691+
require.NoError(t, err)
2692+
2693+
// Then: the workspace's lifecycle parameter should be updated accordingly
2694+
tc.assertUpdate(t, clock, client, claimedWorkspace.ID)
2695+
})
2696+
}
2697+
}
2698+
25222699
// TestWorkspaceTemplateParamsChange tests a workspace with a parameter that
25232700
// validation changes on apply. The params used in create workspace are invalid
25242701
// according to the static params on import.

0 commit comments

Comments
 (0)