diff --git a/coderd/workspaces.go b/coderd/workspaces.go index ac5c2d92d628e..99ca6e03a5201 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1089,6 +1089,17 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { return } + // 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() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Autostart is not supported for prebuilt workspaces", + Detail: "Prebuilt workspace scheduling is configured per preset at the template level. Workspace-level overrides are not supported.", + }) + return + } + dbSched, err := validWorkspaceSchedule(req.Schedule) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -1115,12 +1126,20 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { return } + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + nextStartAt := sql.NullTime{} if dbSched.Valid { - next, err := schedule.NextAllowedAutostart(dbtime.Now(), dbSched.String, templateSchedule) - if err == nil { - nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())} + next, err := schedule.NextAllowedAutostart(now, dbSched.String, templateSchedule) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error calculating workspace autostart schedule.", + Detail: err.Error(), + }) + return } + nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())} } err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ @@ -1173,6 +1192,17 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } + // TTL updates 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() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "TTL updates are not supported for prebuilt workspaces", + Detail: "Prebuilt workspace TTL is configured per preset at the template level. Workspace-level overrides are not supported.", + }) + return + } + var dbTTL sql.NullInt64 err := api.Database.InTx(func(s database.Store) error { @@ -1198,6 +1228,9 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("update workspace time until shutdown: %w", err) } + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + // If autostop has been disabled, we want to remove the deadline from the // existing workspace build (if there is one). if !dbTTL.Valid { @@ -1225,7 +1258,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { // more information. Deadline: build.MaxDeadline, MaxDeadline: build.MaxDeadline, - UpdatedAt: dbtime.Time(api.Clock.Now()), + UpdatedAt: dbtime.Time(now), }); err != nil { return xerrors.Errorf("update workspace build deadline: %w", err) } @@ -1289,17 +1322,30 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { return } + // Dormancy configuration is not supported for prebuilt workspaces. + // Prebuilds are managed by the reconciliation loop and are not subject to dormancy. + if oldWorkspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Dormancy updates are not supported for prebuilt workspaces", + Detail: "Prebuilt workspaces are not subject to dormancy. Dormancy behavior is only applicable to regular workspaces", + }) + return + } + // If the workspace is already in the desired state do nothing! if oldWorkspace.DormantAt.Valid == req.Dormant { rw.WriteHeader(http.StatusNotModified) return } + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + dormantAt := sql.NullTime{ Valid: req.Dormant, } if req.Dormant { - dormantAt.Time = dbtime.Now() + dormantAt.Time = dbtime.Time(now) } newWorkspace, err := api.Database.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ @@ -1339,7 +1385,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { } if initiatorErr == nil && tmplErr == nil { - dormantTime := dbtime.Now().Add(time.Duration(tmpl.TimeTilDormant)) + dormantTime := dbtime.Time(now).Add(time.Duration(tmpl.TimeTilDormant)) _, err = api.NotificationsEnqueuer.Enqueue( // nolint:gocritic // Need notifier actor to enqueue notifications dbauthz.AsNotifier(ctx), @@ -1433,6 +1479,17 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return } + // Deadline extensions are not supported for prebuilt workspaces. + // Prebuilds are managed by the reconciliation loop and must always have + // Deadline and MaxDeadline unset. + if workspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Deadline extension is not supported for prebuilt workspaces", + Detail: "Prebuilt workspaces do not support user deadline modifications. Deadline extension is only applicable to regular workspaces", + }) + return + } + code := http.StatusOK resp := codersdk.Response{} @@ -1469,8 +1526,11 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("workspace shutdown is manual") } + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + newDeadline := req.Deadline.UTC() - if err := validWorkspaceDeadline(job.CompletedAt.Time, newDeadline); err != nil { + if err := validWorkspaceDeadline(now, job.CompletedAt.Time, newDeadline); err != nil { // NOTE(Cian): Putting the error in the Message field on request from the FE folks. // Normally, we would put the validation error in Validations, but this endpoint is // 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) { if err := s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ ID: build.ID, - UpdatedAt: dbtime.Now(), + UpdatedAt: dbtime.Time(now), Deadline: newDeadline, MaxDeadline: build.MaxDeadline, }); err != nil { @@ -2441,8 +2501,8 @@ func validWorkspaceAutomaticUpdates(updates codersdk.AutomaticUpdates) (database return dbAU, nil } -func validWorkspaceDeadline(startedAt, newDeadline time.Time) error { - soon := time.Now().Add(29 * time.Minute) +func validWorkspaceDeadline(now, startedAt, newDeadline time.Time) error { + soon := now.Add(29 * time.Minute) if newDeadline.Before(soon) { return errDeadlineTooSoon } diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index a260de9506e82..1f9a9a4897629 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -15,18 +15,12 @@ import ( "testing" "time" - "github.com/prometheus/client_golang/prometheus" - - "github.com/coder/coder/v2/coderd/files" - agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" - "github.com/coder/coder/v2/enterprise/coderd/prebuilds" - "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/audit" @@ -35,10 +29,13 @@ import ( "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/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" + agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -50,6 +47,7 @@ import ( "github.com/coder/coder/v2/enterprise/audit/backends" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/enterprise/coderd/prebuilds" "github.com/coder/coder/v2/enterprise/coderd/schedule" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk" @@ -2519,6 +2517,185 @@ func templateWithFailedResponseAndPresetsWithPrebuilds(desiredInstances int32) * } } +func TestPrebuildUpdateLifecycleParams(t *testing.T) { + t.Parallel() + + // Autostart schedule configuration set to weekly at 9:30 AM UTC + autostartSchedule, err := cron.Weekly("CRON_TZ=UTC 30 9 * * 1-5") + require.NoError(t, err) + + // TTL configuration set to 8 hours + ttlMillis := ptr.Ref((8 * time.Hour).Milliseconds()) + + // Deadline configuration set to January 1st, 2024 at 10:00 AM UTC + deadline := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + + cases := []struct { + name string + endpoint func(*testing.T, context.Context, *codersdk.Client, uuid.UUID) error + apiErrorMsg string + assertUpdate func(*testing.T, *quartz.Mock, *codersdk.Client, uuid.UUID) + }{ + { + name: "AutostartUpdatePrebuildAfterClaim", + endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error { + err = client.UpdateWorkspaceAutostart(ctx, workspaceID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: ptr.Ref(autostartSchedule.String()), + }) + return err + }, + apiErrorMsg: "Autostart is not supported for prebuilt workspaces", + assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) { + // The workspace's autostart schedule should be updated to the given schedule, + // and its next start time should be set to 2024-01-01 09:30 AM UTC + updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID) + require.Equal(t, autostartSchedule.String(), *updatedWorkspace.AutostartSchedule) + require.Equal(t, autostartSchedule.Next(clock.Now()), updatedWorkspace.NextStartAt.UTC()) + expectedNext := time.Date(2024, 1, 1, 9, 30, 0, 0, time.UTC) + require.Equal(t, expectedNext, updatedWorkspace.NextStartAt.UTC()) + }, + }, + { + name: "TTLUpdatePrebuildAfterClaim", + endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error { + err := client.UpdateWorkspaceTTL(ctx, workspaceID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: ttlMillis, + }) + return err + }, + apiErrorMsg: "TTL updates are not supported for prebuilt workspaces", + assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) { + // The workspace's TTL should be updated accordingly + updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID) + require.Equal(t, ttlMillis, updatedWorkspace.TTLMillis) + }, + }, + { + name: "DormantUpdatePrebuildAfterClaim", + endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error { + err := client.UpdateWorkspaceDormancy(ctx, workspaceID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, + }) + return err + }, + apiErrorMsg: "Dormancy updates are not supported for prebuilt workspaces", + assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) { + // The workspace's dormantAt should be updated accordingly + updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID) + require.Equal(t, clock.Now(), updatedWorkspace.DormantAt.UTC()) + }, + }, + { + name: "DeadlineUpdatePrebuildAfterClaim", + endpoint: func(t *testing.T, ctx context.Context, client *codersdk.Client, workspaceID uuid.UUID) error { + err := client.PutExtendWorkspace(ctx, workspaceID, codersdk.PutExtendWorkspaceRequest{ + Deadline: deadline, + }) + return err + }, + apiErrorMsg: "Deadline extension is not supported for prebuilt workspaces", + assertUpdate: func(t *testing.T, clock *quartz.Mock, client *codersdk.Client, workspaceID uuid.UUID) { + // The workspace build's deadline should be updated accordingly + updatedWorkspace := coderdtest.MustWorkspace(t, client, workspaceID) + require.Equal(t, deadline, updatedWorkspace.LatestBuild.Deadline.Time.UTC()) + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic + clock := quartz.NewMock(t) + clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) + + // 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: the lifecycle-update endpoint is called for the prebuilt workspace + err = tc.endpoint(t, ctx, client, prebuild.ID) + + // Then: a 409 Conflict should be returned, with an error message specific to the lifecycle parameter + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + require.Equal(t, tc.apiErrorMsg, apiErr.Response.Message) + + // 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' endpoint 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: the same lifecycle-update endpoint is called for the claimed workspace + err = tc.endpoint(t, ctx, client, workspace.ID) + require.NoError(t, err) + + // Then: the workspace's lifecycle parameter should be updated accordingly + tc.assertUpdate(t, clock, client, claimedWorkspace.ID) + }) + } +} + // TestWorkspaceTemplateParamsChange tests a workspace with a parameter that // validation changes on apply. The params used in create workspace are invalid // according to the static params on import.