diff --git a/cli/create.go b/cli/create.go index 9511322d55cb6..733eb99a7103d 100644 --- a/cli/create.go +++ b/cli/create.go @@ -27,6 +27,7 @@ func (r *RootCmd) create() *clibase.Cmd { workspaceName string parameterFlags workspaceParameterFlags + autoUpdates string ) client := new(codersdk.Client) cmd := &clibase.Cmd{ @@ -169,6 +170,7 @@ func (r *RootCmd) create() *clibase.Cmd { AutostartSchedule: schedSpec, TTLMillis: ttlMillis, RichParameterValues: richParameters, + AutomaticUpdates: codersdk.AutomaticUpdates(autoUpdates), }) if err != nil { return xerrors.Errorf("create workspace: %w", err) @@ -208,6 +210,13 @@ func (r *RootCmd) create() *clibase.Cmd { Description: "Specify a duration after which the workspace should shut down (e.g. 8h).", Value: clibase.DurationOf(&stopAfter), }, + clibase.Option{ + Flag: "automatic-updates", + Env: "CODER_WORKSPACE_AUTOMATIC_UPDATES", + Description: "Specify automatic updates setting for the workspace (accepts 'always' or 'never').", + Default: string(codersdk.AutomaticUpdatesNever), + Value: clibase.StringOf(&autoUpdates), + }, cliui.SkipPromptOption(), ) cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) diff --git a/cli/create_test.go b/cli/create_test.go index f9d18f538139e..8966e7713fd59 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -38,6 +38,7 @@ func TestCreate(t *testing.T) { "--template", template.Name, "--start-at", "9:30AM Mon-Fri US/Central", "--stop-after", "8h", + "--automatic-updates", "always", } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, client, root) @@ -73,6 +74,7 @@ func TestCreate(t *testing.T) { if assert.NotNil(t, ws.TTLMillis) { assert.Equal(t, *ws.TTLMillis, 8*time.Hour.Milliseconds()) } + assert.Equal(t, codersdk.AutomaticUpdatesAlways, ws.AutomaticUpdates) } }) diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index 5437688f2394b..2d4031999c3d6 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -10,6 +10,10 @@ USAGE: $ coder create / OPTIONS: + --automatic-updates string, $CODER_WORKSPACE_AUTOMATIC_UPDATES (default: never) + Specify automatic updates setting for the workspace (accepts 'always' + or 'never'). + --parameter string-array, $CODER_RICH_PARAMETER Rich parameter value in the format "name=value". diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 2e317f996047b..4d04910796618 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -57,6 +57,7 @@ "health": { "healthy": true, "failing_agents": [] - } + }, + "automatic_updates": "never" } ] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5913dd9eab3da..80cda7e354804 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5998,6 +5998,47 @@ const docTemplate = `{ } } }, + "/workspaces/{workspace}/autoupdates": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Update workspace automatic updates by ID", + "operationId": "update-workspace-automatic-updates-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "description": "Automatic updates request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateWorkspaceAutomaticUpdatesRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaces/{workspace}/builds": { "get": { "security": [ @@ -7264,6 +7305,17 @@ const docTemplate = `{ "type": "boolean" } }, + "codersdk.AutomaticUpdates": { + "type": "string", + "enum": [ + "always", + "never" + ], + "x-enum-varnames": [ + "AutomaticUpdatesAlways", + "AutomaticUpdatesNever" + ] + }, "codersdk.BuildInfoResponse": { "type": "object", "properties": { @@ -7740,6 +7792,9 @@ const docTemplate = `{ "name" ], "properties": { + "automatic_updates": { + "$ref": "#/definitions/codersdk.AutomaticUpdates" + }, "autostart_schedule": { "type": "string" }, @@ -10309,6 +10364,14 @@ const docTemplate = `{ } } }, + "codersdk.UpdateWorkspaceAutomaticUpdatesRequest": { + "type": "object", + "properties": { + "automatic_updates": { + "$ref": "#/definitions/codersdk.AutomaticUpdates" + } + } + }, "codersdk.UpdateWorkspaceAutostartRequest": { "type": "object", "properties": { @@ -10626,6 +10689,17 @@ const docTemplate = `{ "codersdk.Workspace": { "type": "object", "properties": { + "automatic_updates": { + "enum": [ + "always", + "never" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.AutomaticUpdates" + } + ] + }, "autostart_schedule": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 37b3c5ea66b57..fe626dd2c63dd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5288,6 +5288,43 @@ } } }, + "/workspaces/{workspace}/autoupdates": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "tags": ["Workspaces"], + "summary": "Update workspace automatic updates by ID", + "operationId": "update-workspace-automatic-updates-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "description": "Automatic updates request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateWorkspaceAutomaticUpdatesRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaces/{workspace}/builds": { "get": { "security": [ @@ -6469,6 +6506,11 @@ "type": "boolean" } }, + "codersdk.AutomaticUpdates": { + "type": "string", + "enum": ["always", "never"], + "x-enum-varnames": ["AutomaticUpdatesAlways", "AutomaticUpdatesNever"] + }, "codersdk.BuildInfoResponse": { "type": "object", "properties": { @@ -6892,6 +6934,9 @@ "type": "object", "required": ["name"], "properties": { + "automatic_updates": { + "$ref": "#/definitions/codersdk.AutomaticUpdates" + }, "autostart_schedule": { "type": "string" }, @@ -9332,6 +9377,14 @@ } } }, + "codersdk.UpdateWorkspaceAutomaticUpdatesRequest": { + "type": "object", + "properties": { + "automatic_updates": { + "$ref": "#/definitions/codersdk.AutomaticUpdates" + } + } + }, "codersdk.UpdateWorkspaceAutostartRequest": { "type": "object", "properties": { @@ -9631,6 +9684,14 @@ "codersdk.Workspace": { "type": "object", "properties": { + "automatic_updates": { + "enum": ["always", "never"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.AutomaticUpdates" + } + ] + }, "autostart_schedule": { "type": "string" }, diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 0b26d95738ac1..225c5057127d4 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -177,6 +177,12 @@ func (e *Executor) runOnce(t time.Time) Stats { SetLastWorkspaceBuildInTx(&latestBuild). SetLastWorkspaceBuildJobInTx(&latestJob). Reason(reason) + log.Debug(e.ctx, "auto building workspace", slog.F("transition", nextTransition)) + if nextTransition == database.WorkspaceTransitionStart && + ws.AutomaticUpdates == database.AutomaticUpdatesAlways { + log.Debug(e.ctx, "autostarting with active version") + builder = builder.ActiveVersion() + } build, job, err = builder.Build(e.ctx, tx, nil) if err != nil { diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index a613e03b7bcf3..6d7c61bf59cf2 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" + "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/autobuild" @@ -64,50 +65,129 @@ func TestExecutorAutostartOK(t *testing.T) { func TestExecutorAutostartTemplateUpdated(t *testing.T) { t.Parallel() - var ( - sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") - ctx = context.Background() - err error - tickCh = make(chan time.Time) - statsCh = make(chan autobuild.Stats) - client = coderdtest.New(t, &coderdtest.Options{ - AutobuildTicker: tickCh, - IncludeProvisionerDaemon: true, - AutobuildStats: statsCh, - }) - // Given: we have a user with a workspace that has autostart enabled - workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.AutostartSchedule = ptr.Ref(sched.String()) - }) - ) - // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - - // Given: the workspace template has been updated - orgs, err := client.OrganizationsByUser(ctx, workspace.OwnerID.String()) - require.NoError(t, err) - require.Len(t, orgs, 1) - - newVersion := coderdtest.UpdateTemplateVersion(t, client, orgs[0].ID, nil, workspace.TemplateID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, newVersion.ID) - require.NoError(t, client.UpdateActiveTemplateVersion(ctx, workspace.TemplateID, codersdk.UpdateActiveTemplateVersion{ - ID: newVersion.ID, - })) + testCases := []struct { + name string + automaticUpdates codersdk.AutomaticUpdates + compatibleParameters bool + expectStart bool + expectUpdate bool + }{ + { + name: "Never", + automaticUpdates: codersdk.AutomaticUpdatesNever, + compatibleParameters: true, + expectStart: true, + expectUpdate: false, + }, + { + name: "Always_Compatible", + automaticUpdates: codersdk.AutomaticUpdatesAlways, + compatibleParameters: true, + expectStart: true, + expectUpdate: true, + }, + { + name: "Always_Incompatible", + automaticUpdates: codersdk.AutomaticUpdatesAlways, + compatibleParameters: false, + expectStart: false, + expectUpdate: false, + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var ( + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + ctx = context.Background() + err error + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug) + client = coderdtest.New(t, &coderdtest.Options{ + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + Logger: &logger, + }) + // Given: we have a user with a workspace that has autostart enabled + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + // Given: automatic updates from the test case + cwr.AutomaticUpdates = tc.automaticUpdates + }) + ) + // Given: workspace is stopped + workspace = coderdtest.MustTransitionWorkspace( + t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + orgs, err := client.OrganizationsByUser(ctx, workspace.OwnerID.String()) + require.NoError(t, err) + require.Len(t, orgs, 1) + + var res *echo.Responses + if !tc.compatibleParameters { + // Given, parameters of the new version are not compatible. + // Since initial version has no parameters, any parameters in the new version will be incompatible + res = &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Parameters: []*proto.RichParameter{ + { + Name: "new", + Mutable: false, + Required: true, + }, + }, + }, + }, + }}, + } + } - // When: the autobuild executor ticks after the scheduled time - go func() { - tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) - close(tickCh) - }() + // Given: the workspace template has been updated + newVersion := coderdtest.UpdateTemplateVersion(t, client, orgs[0].ID, res, workspace.TemplateID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, newVersion.ID) + require.NoError(t, client.UpdateActiveTemplateVersion( + ctx, workspace.TemplateID, codersdk.UpdateActiveTemplateVersion{ + ID: newVersion.ID, + }, + )) + + t.Log("sending autobuild tick") + // When: the autobuild executor ticks after the scheduled time + go func() { + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) + close(tickCh) + }() + + stats := <-statsCh + assert.NoError(t, stats.Error) + if !tc.expectStart { + // Then: the workspace should not be started + assert.Len(t, stats.Transitions, 0) + return + } - // Then: the workspace should be started using the previous template version, and not the updated version. - stats := <-statsCh - assert.NoError(t, stats.Error) - assert.Len(t, stats.Transitions, 1) - assert.Contains(t, stats.Transitions, workspace.ID) - assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID]) - ws := coderdtest.MustWorkspace(t, client, workspace.ID) - assert.Equal(t, workspace.LatestBuild.TemplateVersionID, ws.LatestBuild.TemplateVersionID, "expected workspace build to be using the old template version") + // Then: the workspace should be started + assert.Len(t, stats.Transitions, 1) + assert.Contains(t, stats.Transitions, workspace.ID) + assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID]) + ws := coderdtest.MustWorkspace(t, client, workspace.ID) + if tc.expectUpdate { + // Then: uses the updated version + assert.Equal(t, newVersion.ID, ws.LatestBuild.TemplateVersionID, + "expected workspace build to be using the updated template version") + } else { + // Then: uses the previous template version + assert.Equal(t, workspace.LatestBuild.TemplateVersionID, ws.LatestBuild.TemplateVersionID, + "expected workspace build to be using the old template version") + } + }) + } } func TestExecutorAutostartAlreadyRunning(t *testing.T) { diff --git a/coderd/coderd.go b/coderd/coderd.go index b8cf0957773c4..d46cb493dceee 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -867,6 +867,7 @@ func New(options *Options) *API { r.Get("/watch", api.watchWorkspace) r.Put("/extend", api.putExtendWorkspace) r.Put("/dormant", api.putWorkspaceDormant) + r.Put("/autoupdates", api.putWorkspaceAutoupdates) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a091bc11148d0..f78a5f1382fdd 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -182,6 +182,10 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options == nil { options = &Options{} } + if options.Logger == nil { + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + options.Logger = &logger + } if options.GoogleTokenValidator == nil { ctx, cancelFunc := context.WithCancel(context.Background()) t.Cleanup(cancelFunc) @@ -214,7 +218,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options.Database == nil { options.Database, options.Pubsub = dbtestutil.NewDB(t) - options.Database = dbauthz.New(options.Database, options.Authorizer, slogtest.Make(t, nil).Leveled(slog.LevelDebug)) + options.Database = dbauthz.New(options.Database, options.Authorizer, options.Logger.Leveled(slog.LevelDebug)) } // Some routes expect a deployment ID, so just make sure one exists. @@ -275,14 +279,14 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can options.Pubsub, &templateScheduleStore, &auditor, - slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug), + *options.Logger, options.AutobuildTicker, ).WithStatsChannel(options.AutobuildStats) lifecycleExecutor.Run() hangDetectorTicker := time.NewTicker(options.DeploymentValues.JobHangDetectorInterval.Value()) defer hangDetectorTicker.Stop() - hangDetector := unhanger.New(ctx, options.Database, options.Pubsub, slogtest.Make(t, nil).Named("unhanger.detector"), hangDetectorTicker.C) + hangDetector := unhanger.New(ctx, options.Database, options.Pubsub, options.Logger.Named("unhanger.detector"), hangDetectorTicker.C) hangDetector.Start() t.Cleanup(hangDetector.Close) @@ -341,7 +345,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can stunAddresses = options.DeploymentValues.DERP.Server.STUNAddresses.Value() } - derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(slogtest.Make(t, nil).Named("derp").Leveled(slog.LevelDebug))) + derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger.Named("derp").Leveled(slog.LevelDebug))) derpServer.SetMeshKey("test-key") // match default with cli default @@ -356,10 +360,6 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can require.NoError(t, err) } - if options.Logger == nil { - logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - options.Logger = &logger - } region := &tailcfg.DERPRegion{ EmbeddedRelay: true, RegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), @@ -893,6 +893,7 @@ func CreateWorkspace(t *testing.T, client *codersdk.Client, organization uuid.UU Name: randomUsername(t), AutostartSchedule: ptr.Ref("CRON_TZ=US/Central 30 9 * * 1-5"), TTLMillis: ptr.Ref((8 * time.Hour).Milliseconds()), + AutomaticUpdates: codersdk.AutomaticUpdatesNever, } for _, mutator := range mutators { mutator(&req) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c0a5cfcecf3cf..5771c6ab00ede 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2715,6 +2715,19 @@ func (q *querier) UpdateWorkspaceAppHealthByID(ctx context.Context, arg database return q.db.UpdateWorkspaceAppHealthByID(ctx, arg) } +func (q *querier) UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg database.UpdateWorkspaceAutomaticUpdatesParams) error { + workspace, err := q.db.GetWorkspaceByID(ctx, arg.ID) + if err != nil { + return err + } + + err = q.authorizeContext(ctx, rbac.ActionUpdate, workspace.RBACObject()) + if err != nil { + return err + } + return q.db.UpdateWorkspaceAutomaticUpdates(ctx, arg) +} + func (q *querier) UpdateWorkspaceAutostart(ctx context.Context, arg database.UpdateWorkspaceAutostartParams) error { fetch := func(ctx context.Context, arg database.UpdateWorkspaceAutostartParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a23781bb17e73..a12f721a4b5e5 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1171,9 +1171,10 @@ func (s *MethodTestSuite) TestWorkspace() { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) check.Args(database.InsertWorkspaceParams{ - ID: uuid.New(), - OwnerID: u.ID, - OrganizationID: o.ID, + ID: uuid.New(), + OwnerID: u.ID, + OrganizationID: o.ID, + AutomaticUpdates: database.AutomaticUpdatesNever, }).Asserts(rbac.ResourceWorkspace.WithOwner(u.ID.String()).InOrg(o.ID), rbac.ActionCreate) })) s.Run("Start/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 1edfffb11c728..7b1eb86c135df 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -354,6 +354,7 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac DormantAt: w.DormantAt, DeletingAt: w.DeletingAt, Count: count, + AutomaticUpdates: w.AutomaticUpdates, } for _, t := range q.templates { @@ -4765,6 +4766,7 @@ func (q *FakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWork AutostartSchedule: arg.AutostartSchedule, Ttl: arg.Ttl, LastUsedAt: arg.LastUsedAt, + AutomaticUpdates: arg.AutomaticUpdates, } q.workspaces = append(q.workspaces, workspace) return workspace, nil @@ -6089,6 +6091,26 @@ func (q *FakeQuerier) UpdateWorkspaceAppHealthByID(_ context.Context, arg databa return sql.ErrNoRows } +func (q *FakeQuerier) UpdateWorkspaceAutomaticUpdates(_ context.Context, arg database.UpdateWorkspaceAutomaticUpdatesParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspace := range q.workspaces { + if workspace.ID != arg.ID { + continue + } + workspace.AutomaticUpdates = arg.AutomaticUpdates + q.workspaces[index] = workspace + return nil + } + + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.UpdateWorkspaceAutostartParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 5397ca1e3d6c5..b2c462cfec79d 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -172,6 +172,7 @@ func Workspace(t testing.TB, db database.Store, orig database.Workspace) databas Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), AutostartSchedule: orig.AutostartSchedule, Ttl: orig.Ttl, + AutomaticUpdates: takeFirst(orig.AutomaticUpdates, database.AutomaticUpdatesNever), }) require.NoError(t, err, "insert workspace") return workspace diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 3811cb9561801..d33ae002f3258 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1684,6 +1684,13 @@ func (m metricsStore) UpdateWorkspaceAppHealthByID(ctx context.Context, arg data return err } +func (m metricsStore) UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg database.UpdateWorkspaceAutomaticUpdatesParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspaceAutomaticUpdates(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceAutomaticUpdates").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateWorkspaceAutostart(ctx context.Context, arg database.UpdateWorkspaceAutostartParams) error { start := time.Now() err := m.s.UpdateWorkspaceAutostart(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b2001b8b19640..dfc0f6dad5694 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3543,6 +3543,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAppHealthByID(arg0, arg1 interfa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAppHealthByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAppHealthByID), arg0, arg1) } +// UpdateWorkspaceAutomaticUpdates mocks base method. +func (m *MockStore) UpdateWorkspaceAutomaticUpdates(arg0 context.Context, arg1 database.UpdateWorkspaceAutomaticUpdatesParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceAutomaticUpdates", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspaceAutomaticUpdates indicates an expected call of UpdateWorkspaceAutomaticUpdates. +func (mr *MockStoreMockRecorder) UpdateWorkspaceAutomaticUpdates(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAutomaticUpdates", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAutomaticUpdates), arg0, arg1) +} + // UpdateWorkspaceAutostart mocks base method. func (m *MockStore) UpdateWorkspaceAutostart(arg0 context.Context, arg1 database.UpdateWorkspaceAutostartParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c01eb20cf05d5..4e5f6ed5d62f1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -22,6 +22,11 @@ CREATE TYPE audit_action AS ENUM ( 'register' ); +CREATE TYPE automatic_updates AS ENUM ( + 'always', + 'never' +); + CREATE TYPE build_reason AS ENUM ( 'initiator', 'autostart', @@ -1127,7 +1132,8 @@ CREATE TABLE workspaces ( ttl bigint, last_used_at timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, dormant_at timestamp with time zone, - deleting_at timestamp with time zone + deleting_at timestamp with time zone, + automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL ); ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass); diff --git a/coderd/database/migrations/000162_workspace_automatic_updates.down.sql b/coderd/database/migrations/000162_workspace_automatic_updates.down.sql new file mode 100644 index 0000000000000..d2f050b4afb75 --- /dev/null +++ b/coderd/database/migrations/000162_workspace_automatic_updates.down.sql @@ -0,0 +1,4 @@ +BEGIN; +ALTER TABLE workspaces DROP COLUMN IF EXISTS automatic_updates; +DROP TYPE IF EXISTS automatic_updates; +COMMIT; diff --git a/coderd/database/migrations/000162_workspace_automatic_updates.up.sql b/coderd/database/migrations/000162_workspace_automatic_updates.up.sql new file mode 100644 index 0000000000000..b034437007b54 --- /dev/null +++ b/coderd/database/migrations/000162_workspace_automatic_updates.up.sql @@ -0,0 +1,8 @@ +BEGIN; +-- making this an enum in case we want to later add other options, like 'if_compatible_vars' +CREATE TYPE automatic_updates AS ENUM ( + 'always', + 'never' +); +ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS automatic_updates automatic_updates NOT NULL DEFAULT 'never'::automatic_updates; +COMMIT; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 057d81a1a0dd7..3ce58ba38eefc 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -358,6 +358,7 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { LastUsedAt: r.LastUsedAt, DormantAt: r.DormantAt, DeletingAt: r.DeletingAt, + AutomaticUpdates: r.AutomaticUpdates, } } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 7685a881373b3..b695d6682f3f2 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -244,6 +244,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, diff --git a/coderd/database/models.go b/coderd/database/models.go index bc9ba550ef93f..218f9020c57b5 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -211,6 +211,64 @@ func AllAuditActionValues() []AuditAction { } } +type AutomaticUpdates string + +const ( + AutomaticUpdatesAlways AutomaticUpdates = "always" + AutomaticUpdatesNever AutomaticUpdates = "never" +) + +func (e *AutomaticUpdates) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AutomaticUpdates(s) + case string: + *e = AutomaticUpdates(s) + default: + return fmt.Errorf("unsupported scan type for AutomaticUpdates: %T", src) + } + return nil +} + +type NullAutomaticUpdates struct { + AutomaticUpdates AutomaticUpdates `json:"automatic_updates"` + Valid bool `json:"valid"` // Valid is true if AutomaticUpdates is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAutomaticUpdates) Scan(value interface{}) error { + if value == nil { + ns.AutomaticUpdates, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AutomaticUpdates.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAutomaticUpdates) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AutomaticUpdates), nil +} + +func (e AutomaticUpdates) Valid() bool { + switch e { + case AutomaticUpdatesAlways, + AutomaticUpdatesNever: + return true + } + return false +} + +func AllAutomaticUpdatesValues() []AutomaticUpdates { + return []AutomaticUpdates{ + AutomaticUpdatesAlways, + AutomaticUpdatesNever, + } +} + type BuildReason string const ( @@ -1995,19 +2053,20 @@ type VisibleUser struct { } type Workspace struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` - DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` + DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` } type WorkspaceAgent struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 04b7a4a4eedad..c6318184185e7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -317,6 +317,7 @@ type sqlcQuerier interface { UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error + UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a2949bb542f3d..2e165b006778d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9647,7 +9647,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates FROM workspaces WHERE @@ -9692,13 +9692,14 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, ) return i, err } const getWorkspaceByID = `-- name: GetWorkspaceByID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates FROM workspaces WHERE @@ -9724,13 +9725,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, ) return i, err } const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates FROM workspaces WHERE @@ -9763,13 +9765,14 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, ) return i, err } const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates FROM workspaces WHERE @@ -9821,13 +9824,14 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, ) return i, err } const getWorkspaces = `-- name: GetWorkspaces :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, @@ -10046,23 +10050,24 @@ type GetWorkspacesParams struct { } type GetWorkspacesRow struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` - DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` - TemplateName string `db:"template_name" json:"template_name"` - TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` + DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` + Count int64 `db:"count" json:"count"` } func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) { @@ -10103,6 +10108,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, @@ -10123,7 +10129,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates FROM workspaces LEFT JOIN @@ -10210,6 +10216,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, ); err != nil { return nil, err } @@ -10236,23 +10243,25 @@ INSERT INTO name, autostart_schedule, ttl, - last_used_at + last_used_at, + automatic_updates ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates ` type InsertWorkspaceParams struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` } func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error) { @@ -10267,6 +10276,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar arg.AutostartSchedule, arg.Ttl, arg.LastUsedAt, + arg.AutomaticUpdates, ) var i Workspace err := row.Scan( @@ -10283,6 +10293,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, ) return i, err } @@ -10313,7 +10324,7 @@ SET WHERE id = $1 AND deleted = false -RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at +RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates ` type UpdateWorkspaceParams struct { @@ -10338,10 +10349,30 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, ) return i, err } +const updateWorkspaceAutomaticUpdates = `-- name: UpdateWorkspaceAutomaticUpdates :exec +UPDATE + workspaces +SET + automatic_updates = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceAutomaticUpdatesParams struct { + ID uuid.UUID `db:"id" json:"id"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` +} + +func (q *sqlQuerier) UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAutomaticUpdates, arg.ID, arg.AutomaticUpdates) + return err +} + const updateWorkspaceAutostart = `-- name: UpdateWorkspaceAutostart :exec UPDATE workspaces @@ -10397,7 +10428,7 @@ WHERE workspaces.template_id = templates.id AND workspaces.id = $1 -RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at +RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates ` type UpdateWorkspaceDormantDeletingAtParams struct { @@ -10422,6 +10453,7 @@ func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg U &i.LastUsedAt, &i.DormantAt, &i.DeletingAt, + &i.AutomaticUpdates, ) return i, err } diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index df5c46ff2a506..849964bef14e6 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -299,10 +299,11 @@ INSERT INTO name, autostart_schedule, ttl, - last_used_at + last_used_at, + automatic_updates ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *; -- name: UpdateWorkspaceDeletedByID :exec UPDATE @@ -512,3 +513,11 @@ SET last_used_at = @last_used_at::timestamptz WHERE template_id = @template_id; + +-- name: UpdateWorkspaceAutomaticUpdates :exec +UPDATE + workspaces +SET + automatic_updates = $2 +WHERE + id = $1; diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 2ce079d3b3438..7e3be16b89a34 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -121,9 +121,10 @@ func TestWorkspaceParam(t *testing.T) { }) r, user := setup(db) workspace, err := db.InsertWorkspace(context.Background(), database.InsertWorkspaceParams{ - ID: uuid.New(), - OwnerID: user.ID, - Name: "hello", + ID: uuid.New(), + OwnerID: user.ID, + Name: "hello", + AutomaticUpdates: database.AutomaticUpdatesNever, }) require.NoError(t, err) chi.RouteContext(r.Context()).URLParams.Add("workspace", workspace.ID.String()) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 4b9ccd4e9abcd..34f3b8377c5d1 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -783,7 +783,8 @@ func TestFailJob(t *testing.T) { srvID := uuid.New() srv, db, ps := setup(t, ignoreLogErrors, &overrides{id: &srvID}) workspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{ - ID: uuid.New(), + ID: uuid.New(), + AutomaticUpdates: database.AutomaticUpdatesNever, }) require.NoError(t, err) buildID := uuid.New() diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index e37a948a0c60c..4cbdc46e28134 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -506,6 +506,7 @@ func ConvertWorkspace(workspace database.Workspace) Workspace { Deleted: workspace.Deleted, Name: workspace.Name, AutostartSchedule: workspace.AutostartSchedule.String, + AutomaticUpdates: string(workspace.AutomaticUpdates), } } @@ -840,6 +841,7 @@ type Workspace struct { Deleted bool `json:"deleted"` Name string `json:"name"` AutostartSchedule string `json:"autostart_schedule"` + AutomaticUpdates string `json:"automatic_updates"` } type Template struct { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index a9b5f337c68fc..a95bf2afe7af3 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -425,6 +425,19 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } + // back-compatibility: default to "never" if not included. + dbAU := database.AutomaticUpdatesNever + if createWorkspace.AutomaticUpdates != "" { + dbAU, err = validWorkspaceAutomaticUpdates(createWorkspace.AutomaticUpdates) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid Workspace Automatic Updates setting.", + Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: err.Error()}}, + }) + return + } + } + // TODO: This should be a system call as the actor might not be able to // read other workspaces. Ideally we check the error on create and look for // a postgres conflict error. @@ -470,7 +483,8 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req Ttl: dbTTL, // The workspaces page will sort by last used at, and it's useful to // have the newly created workspace at the top of the list! - LastUsedAt: dbtime.Now(), + LastUsedAt: dbtime.Now(), + AutomaticUpdates: dbAU, }) if err != nil { return xerrors.Errorf("insert workspace: %w", err) @@ -977,6 +991,66 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, code, resp) } +// @Summary Update workspace automatic updates by ID +// @ID update-workspace-automatic-updates-by-id +// @Security CoderSessionToken +// @Accept json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Param request body codersdk.UpdateWorkspaceAutomaticUpdatesRequest true "Automatic updates request" +// @Success 204 +// @Router /workspaces/{workspace}/autoupdates [put] +func (api *API) putWorkspaceAutoupdates(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspace = httpmw.WorkspaceParam(r) + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.Workspace](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() + aReq.Old = workspace + + var req codersdk.UpdateWorkspaceAutomaticUpdatesRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if !database.AutomaticUpdates(req.AutomaticUpdates).Valid() { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: "must be always or never"}}, + }) + return + } + + err := api.Database.UpdateWorkspaceAutomaticUpdates(ctx, database.UpdateWorkspaceAutomaticUpdatesParams{ + ID: workspace.ID, + AutomaticUpdates: database.AutomaticUpdates(req.AutomaticUpdates), + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating workspace automatic updates setting", + Detail: err.Error(), + }) + return + } + + newWorkspace := workspace + newWorkspace.AutomaticUpdates = database.AutomaticUpdates(req.AutomaticUpdates) + aReq.New = newWorkspace + + rw.WriteHeader(http.StatusNoContent) +} + // @Summary Watch workspace by ID // @ID watch-workspace-by-id // @Security CoderSessionToken @@ -1256,6 +1330,7 @@ func convertWorkspace( Healthy: len(failingAgents) == 0, FailingAgents: failingAgents, }, + AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates), } } @@ -1311,6 +1386,17 @@ func validWorkspaceTTLMillis(millis *int64, templateDefault, templateMax time.Du }, nil } +func validWorkspaceAutomaticUpdates(updates codersdk.AutomaticUpdates) (database.AutomaticUpdates, error) { + if updates == "" { + return database.AutomaticUpdatesNever, nil + } + dbAU := database.AutomaticUpdates(updates) + if !dbAU.Valid() { + return "", xerrors.New("Automatic updates must be always or never") + } + return dbAU, nil +} + func validWorkspaceDeadline(startedAt, newDeadline time.Time) error { soon := time.Now().Add(29 * time.Minute) if newDeadline.Before(soon) { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 5cc4d5b9f0eec..85b0f22b29af0 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -729,6 +729,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { Name: workspace.Name, AutostartSchedule: workspace.AutostartSchedule, TTLMillis: workspace.TTLMillis, + AutomaticUpdates: workspace.AutomaticUpdates, }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) @@ -2173,6 +2174,72 @@ func TestWorkspaceExtend(t *testing.T) { require.WithinDuration(t, oldDeadline.Add(-time.Hour), updated.LatestBuild.Deadline.Time, time.Minute) } +func TestWorkspaceUpdateAutomaticUpdates_OK(t *testing.T) { + t.Parallel() + + var ( + auditor = audit.NewMock() + adminClient = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) + admin = coderdtest.CreateFirstUser(t, adminClient) + client, user = coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID) + version = coderdtest.CreateTemplateVersion(t, adminClient, admin.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID) + project = coderdtest.CreateTemplate(t, adminClient, admin.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, admin.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + cwr.TTLMillis = nil + cwr.AutomaticUpdates = codersdk.AutomaticUpdatesNever + }) + ) + + // await job to ensure audit logs for workspace_build start are created + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // ensure test invariant: new workspaces have automatic updates set to never + require.Equal(t, codersdk.AutomaticUpdatesNever, workspace.AutomaticUpdates, "expected newly-minted workspace to automatic updates set to never") + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.UpdateWorkspaceAutomaticUpdates(ctx, workspace.ID, codersdk.UpdateWorkspaceAutomaticUpdatesRequest{ + AutomaticUpdates: codersdk.AutomaticUpdatesAlways, + }) + require.NoError(t, err) + + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, codersdk.AutomaticUpdatesAlways, updated.AutomaticUpdates) + + require.Eventually(t, func() bool { + return len(auditor.AuditLogs()) >= 9 + }, testutil.WaitShort, testutil.IntervalFast) + l := auditor.AuditLogs()[8] + require.Equal(t, database.AuditActionWrite, l.Action) + require.Equal(t, user.ID, l.UserID) + require.Equal(t, workspace.ID, l.ResourceID) +} + +func TestUpdateWorkspaceAutomaticUpdates_NotFound(t *testing.T) { + t.Parallel() + var ( + client = coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + wsid = uuid.New() + req = codersdk.UpdateWorkspaceAutomaticUpdatesRequest{ + AutomaticUpdates: codersdk.AutomaticUpdatesNever, + } + ) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.UpdateWorkspaceAutomaticUpdates(ctx, wsid, req) + require.IsType(t, err, &codersdk.Error{}, "expected codersdk.Error") + coderSDKErr, _ := err.(*codersdk.Error) //nolint:errorlint + require.Equal(t, coderSDKErr.StatusCode(), 404, "expected status code 404") + require.Contains(t, coderSDKErr.Message, "Resource not found", "unexpected response code") +} + func TestWorkspaceWatcher(t *testing.T) { t.Parallel() client, closeFunc := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f88d526c512e2..195681d019ec4 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -136,6 +136,7 @@ type CreateWorkspaceRequest struct { // RichParameterValues allows for additional parameters to be provided // during the initial provision. RichParameterValues []WorkspaceBuildParameter `json:"rich_parameter_values,omitempty"` + AutomaticUpdates AutomaticUpdates `json:"automatic_updates,omitempty"` } func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, error) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index fe858603bff0f..ef7640417a5ca 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -14,6 +14,13 @@ import ( "github.com/coder/coder/v2/coderd/tracing" ) +type AutomaticUpdates string + +const ( + AutomaticUpdatesAlways AutomaticUpdates = "always" + AutomaticUpdatesNever AutomaticUpdates = "never" +) + // Workspace is a deployment of a template. It references a specific // version and can be updated. type Workspace struct { @@ -47,7 +54,8 @@ type Workspace struct { DormantAt *time.Time `json:"dormant_at" format:"date-time"` // Health shows the health of the workspace and information about // what is causing an unhealthy status. - Health WorkspaceHealth `json:"health"` + Health WorkspaceHealth `json:"health"` + AutomaticUpdates AutomaticUpdates `json:"automatic_updates" enums:"always,never"` } func (w Workspace) FullName() string { @@ -316,6 +324,25 @@ func (c *Client) UpdateWorkspaceDormancy(ctx context.Context, id uuid.UUID, req return nil } +// UpdateWorkspaceAutomaticUpdatesRequest is a request to updates a workspace's automatic updates setting. +type UpdateWorkspaceAutomaticUpdatesRequest struct { + AutomaticUpdates AutomaticUpdates `json:"automatic_updates"` +} + +// UpdateWorkspaceAutomaticUpdates sets the automatic updates setting for workspace by id. +func (c *Client) UpdateWorkspaceAutomaticUpdates(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutomaticUpdatesRequest) error { + path := fmt.Sprintf("/api/v2/workspaces/%s/autoupdates", id.String()) + res, err := c.Request(ctx, http.MethodPut, path, req) + if err != nil { + return xerrors.Errorf("update workspace automatic updates: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + type WorkspaceFilter struct { // Owner can be "me" or a username Owner string `json:"owner,omitempty" typescript:"-"` diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 864d20cc756be..a7fdeb6d0a2b0 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -18,7 +18,7 @@ We track the following resources: | Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| | TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| | User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 93b14beab75c2..0c08cc9fef8ae 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1342,6 +1342,21 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ---------------- | ------- | -------- | ------------ | ----------- | | `[any property]` | boolean | false | | | +## codersdk.AutomaticUpdates + +```json +"always" +``` + +### Properties + +#### Enumerated Values + +| Value | +| -------- | +| `always` | +| `never` | + ## codersdk.BuildInfoResponse ```json @@ -1757,6 +1772,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "automatic_updates": "always", "autostart_schedule": "string", "name": "string", "rich_parameter_values": [ @@ -1775,6 +1791,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | ----------------------- | ----------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------- | +| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | | `autostart_schedule` | string | false | | | | `name` | string | true | | | | `rich_parameter_values` | array of [codersdk.WorkspaceBuildParameter](#codersdkworkspacebuildparameter) | false | | Rich parameter values allows for additional parameters to be provided during the initial provision. | @@ -5071,6 +5088,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in The schedule must be daily with a single time, and should have a timezone specified via a CRON_TZ prefix (otherwise UTC will be used). If the schedule is empty, the user will be updated to use the default schedule.| +## codersdk.UpdateWorkspaceAutomaticUpdatesRequest + +```json +{ + "automatic_updates": "always" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------- | ------------------------------------------------------ | -------- | ------------ | ----------- | +| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | + ## codersdk.UpdateWorkspaceAutostartRequest ```json @@ -5465,6 +5496,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { + "automatic_updates": "always", "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", @@ -5639,29 +5671,37 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------------------------------- | ---------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `autostart_schedule` | string | false | | | -| `created_at` | string | false | | | -| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | -| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time*til* field on its template. | -| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | -| `id` | string | false | | | -| `last_used_at` | string | false | | | -| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `outdated` | boolean | false | | | -| `owner_id` | string | false | | | -| `owner_name` | string | false | | | -| `template_active_version_id` | string | false | | | -| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | -| `template_display_name` | string | false | | | -| `template_icon` | string | false | | | -| `template_id` | string | false | | | -| `template_name` | string | false | | | -| `ttl_ms` | integer | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------------------------------- | ------------------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | +| `autostart_schedule` | string | false | | | +| `created_at` | string | false | | | +| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | +| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time*til* field on its template. | +| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | +| `id` | string | false | | | +| `last_used_at` | string | false | | | +| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `outdated` | boolean | false | | | +| `owner_id` | string | false | | | +| `owner_name` | string | false | | | +| `template_active_version_id` | string | false | | | +| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | +| `template_display_name` | string | false | | | +| `template_icon` | string | false | | | +| `template_id` | string | false | | | +| `template_name` | string | false | | | +| `ttl_ms` | integer | false | | | +| `updated_at` | string | false | | | + +#### Enumerated Values + +| Property | Value | +| ------------------- | -------- | +| `automatic_updates` | `always` | +| `automatic_updates` | `never` | ## codersdk.WorkspaceAgent @@ -6708,6 +6748,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "count": 0, "workspaces": [ { + "automatic_updates": "always", "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 56aaabee03025..a7dafb266043b 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -18,6 +18,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member ```json { + "automatic_updates": "always", "autostart_schedule": "string", "name": "string", "rich_parameter_values": [ @@ -46,6 +47,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member ```json { + "automatic_updates": "always", "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", @@ -253,6 +255,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam ```json { + "automatic_updates": "always", "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", @@ -463,6 +466,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "count": 0, "workspaces": [ { + "automatic_updates": "always", "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", @@ -667,6 +671,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ ```json { + "automatic_updates": "always", "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", @@ -919,6 +924,42 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/autostart \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Update workspace automatic updates by ID + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/autoupdates \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /workspaces/{workspace}/autoupdates` + +> Body parameter + +```json +{ + "automatic_updates": "always" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ---- | ------------------------------------------------------------------------------------------------------------ | -------- | ------------------------- | +| `workspace` | path | string(uuid) | true | Workspace ID | +| `body` | body | [codersdk.UpdateWorkspaceAutomaticUpdatesRequest](schemas.md#codersdkupdateworkspaceautomaticupdatesrequest) | true | Automatic updates request | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update workspace dormancy status by id. ### Code samples @@ -954,6 +995,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ ```json { + "automatic_updates": "always", "autostart_schedule": "string", "created_at": "2019-08-24T14:15:22Z", "deleting_at": "2019-08-24T14:15:22Z", diff --git a/docs/cli/create.md b/docs/cli/create.md index 8cb182528d5ea..f7036ac84d9a3 100644 --- a/docs/cli/create.md +++ b/docs/cli/create.md @@ -20,6 +20,16 @@ coder create [flags] [name] ## Options +### --automatic-updates + +| | | +| ----------- | ----------------------------------------------- | +| Type | string | +| Environment | $CODER_WORKSPACE_AUTOMATIC_UPDATES | +| Default | never | + +Specify automatic updates setting for the workspace (accepts 'always' or 'never'). + ### --parameter | | | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 611c35db1456b..4a6bc96740b2c 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -129,6 +129,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "last_used_at": ActionIgnore, "dormant_at": ActionTrack, "deleting_at": ActionTrack, + "automatic_updates": ActionTrack, }, &database.WorkspaceBuild{}: { "id": ActionIgnore, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 060739eaab463..43db1766c56e0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -279,6 +279,7 @@ export interface CreateWorkspaceRequest { readonly autostart_schedule?: string; readonly ttl_ms?: number; readonly rich_parameter_values?: WorkspaceBuildParameter[]; + readonly automatic_updates?: AutomaticUpdates; } // From codersdk/deployment.go @@ -1155,6 +1156,11 @@ export interface UpdateUserQuietHoursScheduleRequest { readonly schedule: string; } +// From codersdk/workspaces.go +export interface UpdateWorkspaceAutomaticUpdatesRequest { + readonly automatic_updates: AutomaticUpdates; +} + // From codersdk/workspaces.go export interface UpdateWorkspaceAutostartRequest { readonly schedule?: string; @@ -1323,6 +1329,7 @@ export interface Workspace { readonly deleting_at?: string; readonly dormant_at?: string; readonly health: WorkspaceHealth; + readonly automatic_updates: AutomaticUpdates; } // From codersdk/workspaceagents.go @@ -1611,6 +1618,10 @@ export const AuditActions: AuditAction[] = [ "write", ]; +// From codersdk/workspaces.go +export type AutomaticUpdates = "always" | "never"; +export const AutomaticUpdateses: AutomaticUpdates[] = ["always", "never"]; + // From codersdk/workspacebuilds.go export type BuildReason = "autostart" | "autostop" | "initiator"; export const BuildReasons: BuildReason[] = [ diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f9341889a9c54..67bbff360b6e6 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -956,6 +956,7 @@ export const MockWorkspace: TypesGen.Workspace = { healthy: true, failing_agents: [], }, + automatic_updates: "never", }; export const MockStoppedWorkspace: TypesGen.Workspace = {