From 5c092be88002a09671e60454619167a5f3c84f47 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 17:16:53 +0100 Subject: [PATCH 01/12] Dockerfile: add tzdata --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8bbcbe848837f..1584cf5bec6cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM alpine +RUN apk add tzdata + # Generated by goreleaser on `goreleaser release` ADD coder /opt/coder From 391f5278ea53138b73bc2d98f141e9dadbf4ae71 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 17:18:04 +0100 Subject: [PATCH 02/12] database: add autostart_schedule and ttl to InsertWorkspace; make gen --- coderd/database/databasefake/databasefake.go | 16 +++++++------ coderd/database/queries.sql.go | 24 ++++++++++++-------- coderd/database/queries/workspaces.sql | 6 +++-- codersdk/organizations.go | 6 +++-- site/src/api/typesGenerated.ts | 3 +++ 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 1a714b90b33a4..0e16107a43858 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1456,13 +1456,15 @@ func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWork //nolint:gosimple workspace := database.Workspace{ - ID: arg.ID, - CreatedAt: arg.CreatedAt, - UpdatedAt: arg.UpdatedAt, - OwnerID: arg.OwnerID, - OrganizationID: arg.OrganizationID, - TemplateID: arg.TemplateID, - Name: arg.Name, + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OwnerID: arg.OwnerID, + OrganizationID: arg.OrganizationID, + TemplateID: arg.TemplateID, + Name: arg.Name, + AutostartSchedule: arg.AutostartSchedule, + Ttl: arg.Ttl, } q.workspaces = append(q.workspaces, workspace) return workspace, nil diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 607ae0af8aa78..57eeb67ac5814 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3507,20 +3507,24 @@ INSERT INTO owner_id, organization_id, template_id, - name + name, + autostart_schedule, + ttl ) VALUES - ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl ` 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"` + 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"` } func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspaceParams) (Workspace, error) { @@ -3532,6 +3536,8 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar arg.OrganizationID, arg.TemplateID, arg.Name, + arg.AutostartSchedule, + arg.Ttl, ) var i Workspace err := row.Scan( diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 5e8cdaa107895..000e4e92ce5a9 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -86,10 +86,12 @@ INSERT INTO owner_id, organization_id, template_id, - name + name, + autostart_schedule, + ttl ) VALUES - ($1, $2, $3, $4, $5, $6, $7) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; -- name: UpdateWorkspaceDeletedByID :exec UPDATE diff --git a/codersdk/organizations.go b/codersdk/organizations.go index d5b8d2f3ba2ee..a246fb1ccb12f 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -65,8 +65,10 @@ type CreateTemplateRequest struct { // CreateWorkspaceRequest provides options for creating a new workspace. type CreateWorkspaceRequest struct { - TemplateID uuid.UUID `json:"template_id" validate:"required"` - Name string `json:"name" validate:"username,required"` + TemplateID uuid.UUID `json:"template_id" validate:"required"` + Name string `json:"name" validate:"username,required"` + AutostartSchedule *string `json:"autostart_schedule" validate:""` + TTL *time.Duration `json:"ttl" validate:""` // ParameterValues allows for additional parameters to be provided // during the initial provision. ParameterValues []CreateParameterRequest `json:"parameter_values,omitempty"` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 30c0367a1fdd4..de1d984461c5b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -94,6 +94,9 @@ export interface CreateWorkspaceBuildRequest { export interface CreateWorkspaceRequest { readonly template_id: string readonly name: string + readonly autostart_schedule?: string + // This is likely an enum in an external package ("time.Duration") + readonly ttl?: number readonly parameter_values?: CreateParameterRequest[] } From 98561bf9d73d156d177ed71ae4dccfcb075790a8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 17:18:54 +0100 Subject: [PATCH 03/12] coderd: workspaces: consume additional fields of CreateWorkspaceRequest --- coderd/workspaces.go | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index b9e7be07da96e..aa48d67b19e50 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -357,6 +357,25 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } + var dbAutostartSchedule sql.NullString + if createWorkspace.AutostartSchedule != nil { + _, err := schedule.Weekly(*createWorkspace.AutostartSchedule) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("parse autostart schedule: %s", err.Error()), + }) + return + } + dbAutostartSchedule.Valid = true + dbAutostartSchedule.String = *createWorkspace.AutostartSchedule + } + + var dbTTL sql.NullInt64 + if createWorkspace.TTL != nil && *createWorkspace.TTL > 0 { + dbTTL.Valid = true + dbTTL.Int64 = int64(*createWorkspace.TTL) + } + workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(r.Context(), database.GetWorkspaceByOwnerIDAndNameParams{ OwnerID: apiKey.UserID, Name: createWorkspace.Name, @@ -426,13 +445,15 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req workspaceBuildID := uuid.New() // Workspaces are created without any versions. workspace, err = db.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{ - ID: uuid.New(), - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - OwnerID: apiKey.UserID, - OrganizationID: template.OrganizationID, - TemplateID: template.ID, - Name: createWorkspace.Name, + ID: uuid.New(), + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + OwnerID: apiKey.UserID, + OrganizationID: template.OrganizationID, + TemplateID: template.ID, + Name: createWorkspace.Name, + AutostartSchedule: dbAutostartSchedule, + Ttl: dbTTL, }) if err != nil { return xerrors.Errorf("insert workspace: %w", err) From 01693154afbc9d1581c00e8687e8450a0f42de11 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 17:19:17 +0100 Subject: [PATCH 04/12] fixup! coderd: workspaces: consume additional fields of CreateWorkspaceRequest --- coderd/workspaces_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index cd44a1895d450..62472a9c05015 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -480,7 +480,10 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + cwr.TTL = nil + }) ) // ensure test invariant: new workspaces have no autostart schedule. @@ -565,7 +568,10 @@ func TestWorkspaceUpdateAutostop(t *testing.T) { version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + cwr.TTL = nil + }) ) // ensure test invariant: new workspaces have no autostop schedule. From dea541f11a81b974a469f522f58b04a7b7e9b1cc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 17:19:30 +0100 Subject: [PATCH 05/12] fixup! fixup! coderd: workspaces: consume additional fields of CreateWorkspaceRequest --- coderd/coderdtest/coderdtest.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 4392ab81d55ea..232a38b559201 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -389,11 +389,19 @@ func AwaitWorkspaceAgents(t *testing.T, client *codersdk.Client, build uuid.UUID // CreateWorkspace creates a workspace for the user and template provided. // A random name is generated for it. -func CreateWorkspace(t *testing.T, client *codersdk.Client, organization uuid.UUID, templateID uuid.UUID) codersdk.Workspace { - workspace, err := client.CreateWorkspace(context.Background(), organization, codersdk.CreateWorkspaceRequest{ - TemplateID: templateID, - Name: randomUsername(), - }) +// To customize the defaults, pass a mutator func. +func CreateWorkspace(t *testing.T, client *codersdk.Client, organization uuid.UUID, templateID uuid.UUID, mutators ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { + t.Helper() + req := codersdk.CreateWorkspaceRequest{ + TemplateID: templateID, + Name: randomUsername(), + AutostartSchedule: ptr("CRON_TZ=US/Central * * * * *"), + TTL: ptr(8 * time.Hour), + } + for _, mutator := range mutators { + mutator(&req) + } + workspace, err := client.CreateWorkspace(context.Background(), organization, req) require.NoError(t, err) return workspace } @@ -590,3 +598,7 @@ type roundTripper func(req *http.Request) (*http.Response, error) func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return r(req) } + +func ptr[T any](x T) *T { + return &x +} From d621272f1660bc84da69addab4a6ff8669aeb76f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 17:19:52 +0100 Subject: [PATCH 06/12] autobuild: fix unit tests --- .../executor/lifecycle_executor_test.go | 101 ++++++------------ coderd/autobuild/schedule/schedule.go | 4 + coderd/autobuild/schedule/schedule_test.go | 7 ++ 3 files changed, 42 insertions(+), 70 deletions(-) diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index 5b1bdfafe6e73..621a04f802dbd 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -36,9 +36,6 @@ func TestExecutorAutostartOK(t *testing.T) { // Given: workspace is stopped workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // Given: the workspace initially has autostart disabled - require.Empty(t, workspace.AutostartSchedule) - // When: we enable workspace autostart sched, err := schedule.Weekly("* * * * *") require.NoError(t, err) @@ -77,9 +74,6 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { // Given: workspace is stopped workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // Given: the workspace initially has autostart disabled - require.Empty(t, workspace.AutostartSchedule) - // Given: the workspace template has been updated orgs, err := client.OrganizationsByUser(ctx, workspace.OwnerID.String()) require.NoError(t, err) @@ -131,9 +125,6 @@ func TestExecutorAutostartAlreadyRunning(t *testing.T) { // Given: we ensure the workspace is running require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) - // Given: the workspace initially has autostart disabled - require.Empty(t, workspace.AutostartSchedule) - // When: we enable workspace autostart sched, err := schedule.Weekly("* * * * *") require.NoError(t, err) @@ -164,15 +155,17 @@ func TestExecutorAutostartNotEnabled(t *testing.T) { IncludeProvisionerD: true, }) // Given: we have a user with a workspace - workspace = mustProvisionWorkspace(t, client) + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + }) ) + // Given: workspace does not have autostart enabled + require.Empty(t, workspace.AutostartSchedule) + // Given: workspace is stopped workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // Given: the workspace has autostart disabled - require.Empty(t, workspace.AutostartSchedule) - // When: the autobuild executor ticks go func() { tickCh <- time.Now().UTC().Add(time.Minute) @@ -190,8 +183,6 @@ func TestExecutorAutostopOK(t *testing.T) { t.Parallel() var ( - ctx = context.Background() - err error tickCh = make(chan time.Time) client = coderdtest.New(t, &coderdtest.Options{ AutobuildTicker: tickCh, @@ -199,20 +190,11 @@ func TestExecutorAutostopOK(t *testing.T) { }) // Given: we have a user with a workspace workspace = mustProvisionWorkspace(t, client) - ttl = time.Minute + ttl = *workspace.TTL ) // Given: workspace is running require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) - // Given: the workspace initially has autostop disabled - require.Nil(t, workspace.TTL) - - // When: we enable workspace autostop - require.NoError(t, err) - require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTL: &ttl, - })) - // When: the autobuild executor ticks *after* the TTL: go func() { tickCh <- time.Now().UTC().Add(ttl + time.Minute) @@ -231,41 +213,32 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) { t.Parallel() var ( - ctx = context.Background() - err error tickCh = make(chan time.Time) client = coderdtest.New(t, &coderdtest.Options{ AutobuildTicker: tickCh, IncludeProvisionerD: true, }) - // Given: we have a user with a workspace - workspace = mustProvisionWorkspace(t, client) - ttl = time.Minute + // Given: we have a user with a workspace (disabling autostart) + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + }) + ttl = *workspace.TTL ) // Given: workspace is stopped workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // Given: the workspace initially has autostop disabled - require.Nil(t, workspace.TTL) - - // When: we set the TTL on the workspace - require.NoError(t, err) - require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTL: &ttl, - })) - // When: the autobuild executor ticks past the TTL go func() { - tickCh <- time.Now().UTC().Add(ttl) + tickCh <- time.Now().UTC().Add(ttl + time.Minute) close(tickCh) }() // Then: the workspace should not be stopped. <-time.After(5 * time.Second) ws := mustWorkspace(t, client, workspace.ID) - require.Equal(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected no further workspace builds to occur") require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running") + require.Equal(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected no further workspace builds to occur") } func TestExecutorAutostopNotEnabled(t *testing.T) { @@ -277,17 +250,19 @@ func TestExecutorAutostopNotEnabled(t *testing.T) { AutobuildTicker: tickCh, IncludeProvisionerD: true, }) - // Given: we have a user with a workspace - workspace = mustProvisionWorkspace(t, client) + // Given: we have a user with a workspace that has no TTL set + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTL = nil + }) ) + // Given: workspace has no TTL set + require.Nil(t, workspace.TTL) + // Given: workspace is running require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) - // Given: the workspace has autostop disabled - require.Empty(t, workspace.TTL) - - // When: the autobuild executor ticks + // When: the autobuild executor ticks past the TTL go func() { tickCh <- time.Now().UTC().Add(time.Minute) close(tickCh) @@ -315,9 +290,6 @@ func TestExecutorWorkspaceDeleted(t *testing.T) { workspace = mustProvisionWorkspace(t, client) ) - // Given: the workspace initially has autostart disabled - require.Empty(t, workspace.AutostartSchedule) - // When: we enable workspace autostart sched, err := schedule.Weekly("* * * * *") require.NoError(t, err) @@ -352,16 +324,15 @@ func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) { AutobuildTicker: tickCh, IncludeProvisionerD: true, }) - // Given: we have a user with a workspace - workspace = mustProvisionWorkspace(t, client) + futureTime = time.Now().Add(time.Hour) + futureTimeCron = fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour()) + // Given: we have a user with a workspace configured to autostart some time in the future + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = &futureTimeCron + }) ) - // Given: the workspace initially has autostart disabled - require.Empty(t, workspace.AutostartSchedule) - // When: we enable workspace autostart with some time in the future - futureTime := time.Now().Add(time.Hour) - futureTimeCron := fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour()) sched, err := schedule.Weekly(futureTimeCron) require.NoError(t, err) require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ @@ -385,7 +356,6 @@ func TestExecutorWorkspaceTTLTooEarly(t *testing.T) { t.Parallel() var ( - ctx = context.Background() tickCh = make(chan time.Time) client = coderdtest.New(t, &coderdtest.Options{ AutobuildTicker: tickCh, @@ -393,18 +363,9 @@ func TestExecutorWorkspaceTTLTooEarly(t *testing.T) { }) // Given: we have a user with a workspace workspace = mustProvisionWorkspace(t, client) - ttl = time.Hour ) - // Given: the workspace initially has TTL unset - require.Nil(t, workspace.TTL) - - // When: we set the TTL to some time in the distant future - require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTL: &ttl, - })) - - // When: the autobuild executor ticks + // When: the autobuild executor ticks before the TTL go func() { tickCh <- time.Now().UTC() close(tickCh) @@ -479,13 +440,13 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { require.True(t, builds[1].CreatedAt.After(builds[2].CreatedAt)) } -func mustProvisionWorkspace(t *testing.T, client *codersdk.Client) codersdk.Workspace { +func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { t.Helper() user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, mut...) coderdtest.AwaitWorkspaceBuildJob(t, client, ws.LatestBuild.ID) return mustWorkspace(t, client, ws.ID) } diff --git a/coderd/autobuild/schedule/schedule.go b/coderd/autobuild/schedule/schedule.go index 7fbef2f21fbf0..03981acac0489 100644 --- a/coderd/autobuild/schedule/schedule.go +++ b/coderd/autobuild/schedule/schedule.go @@ -55,6 +55,10 @@ func Weekly(raw string) (*Schedule, error) { return nil, xerrors.Errorf("expected *cron.SpecSchedule but got %T", specSched) } + if schedule.Location == time.Local { + return nil, xerrors.Errorf("schedules scoped to time.Local are not supported") + } + // Strip the leading CRON_TZ prefix so we just store the cron string. // The timezone info is available in SpecSchedule. cronStr := raw diff --git a/coderd/autobuild/schedule/schedule_test.go b/coderd/autobuild/schedule/schedule_test.go index 0af54e04a3e0c..0c847a28e819c 100644 --- a/coderd/autobuild/schedule/schedule_test.go +++ b/coderd/autobuild/schedule/schedule_test.go @@ -41,6 +41,13 @@ func Test_Weekly(t *testing.T) { expectedTz: "UTC", expectedString: "CRON_TZ=UTC 30 9 * * 1-5", }, + { + name: "time.Local will bite you", + spec: "CRON_TZ=Local 30 9 * * 1-5", + at: time.Time{}, + expectedNext: time.Time{}, + expectedError: "schedules scoped to time.Local are not supported", + }, { name: "invalid schedule", spec: "asdfasdfasdfsd", From 2f7b939912771f0972b5cf79c90c2b2d15871f87 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 17:39:47 +0100 Subject: [PATCH 07/12] cli: update: add support for TTL and autostart_schedule --- cli/autostart.go | 2 ++ cli/create.go | 39 +++++++++++++++++++++++++++++++++------ cli/parameter.go | 3 ++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/cli/autostart.go b/cli/autostart.go index 870eafa94c0dd..5d78abe0b143d 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -11,6 +11,8 @@ import ( "github.com/coder/coder/codersdk" ) +const defaultAutostartSchedule = "0 9 * * MON-FRI" + const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. When enabling autostart, provide the minute, hour, and day(s) of week. The default schedule is at 09:00 in your local timezone (TZ env, UTC by default). diff --git a/cli/create.go b/cli/create.go index 1f762a55cca45..16bc32d692683 100644 --- a/cli/create.go +++ b/cli/create.go @@ -10,14 +10,20 @@ import ( "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/codersdk" ) func create() *cobra.Command { var ( - workspaceName string - templateName string - parameterFile string + autostartMinute string + autostartHour string + autostartDow string + parameterFile string + templateName string + ttl time.Duration + tzName string + workspaceName string ) cmd := &cobra.Command{ Annotations: workspaceCommand, @@ -54,6 +60,20 @@ func create() *cobra.Command { } } + tz, err := time.LoadLocation(tzName) + if err != nil { + return xerrors.Errorf("Invalid workspace autostart timezone: %w", err) + } + schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tz.String(), autostartMinute, autostartHour, autostartDow) + _, err = schedule.Weekly(schedSpec) + if err != nil { + return xerrors.Errorf("Invalid workspace autostart schedule: %w", err) + } + + if ttl == 0 { + return xerrors.Errorf("TTL must be at least 1 minute") + } + _, err = client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, workspaceName) if err == nil { return xerrors.Errorf("A workspace already exists named %q!", workspaceName) @@ -174,9 +194,11 @@ func create() *cobra.Command { before := time.Now() workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{ - TemplateID: template.ID, - Name: workspaceName, - ParameterValues: parameters, + TemplateID: template.ID, + Name: workspaceName, + AutostartSchedule: &schedSpec, + TTL: &ttl, + ParameterValues: parameters, }) if err != nil { return err @@ -207,5 +229,10 @@ func create() *cobra.Command { cliui.AllowSkipPrompt(cmd) cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.") cliflag.StringVarP(cmd.Flags(), ¶meterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.") + cliflag.StringVarP(cmd.Flags(), &autostartMinute, "autostart-minute", "", "CODER_WORKSPACE_AUTOSTART", "0", "Specify the minute(s) at which the workspace should autostart.") + cliflag.StringVarP(cmd.Flags(), &autostartHour, "autostart-hour", "", "CODER_WORKSPACE_AUTOSTART", "9", "Specify the hour(s) at which the workspace should autostart.") + cliflag.StringVarP(cmd.Flags(), &autostartDow, "autostart-day-of-week", "", "CODER_WORKSPACE_AUTOSTART", "MON-FRI", "Specify the days(s) on which the workspace should autostart.") + cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "", "Specify your timezone location for workspace autostart.") + cliflag.DurationVarP(cmd.Flags(), &ttl, "ttl", "", "CODER_WORKSPACE_TTL", 8*time.Hour, "Specify a TTL for the workspace.") return cmd } diff --git a/cli/parameter.go b/cli/parameter.go index 5efb81f9fd405..904148ae13005 100644 --- a/cli/parameter.go +++ b/cli/parameter.go @@ -6,9 +6,10 @@ import ( "golang.org/x/xerrors" "gopkg.in/yaml.v3" + "github.com/spf13/cobra" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" - "github.com/spf13/cobra" ) // Reads a YAML file and populates a string -> string map. From 813a64f7e374af1424d5a858da8e80d94f959cb4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 18:45:02 +0100 Subject: [PATCH 08/12] cli: create: add unit tests --- cli/create_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/cli/create_test.go b/cli/create_test.go index 3e29e3bdabad7..b492e8c67f7e6 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -25,7 +25,17 @@ func TestCreate(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - cmd, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) + args := []string{ + "create", + "my-workspace", + "--template", template.Name, + "--tz", "US/Central", + "--autostart-minute", "0", + "--autostart-hour", "*/2", + "--autostart-day-of-week", "MON-FRI", + "--ttl", "8h", + } + cmd, root := clitest.New(t, args...) clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) @@ -48,6 +58,60 @@ func TestCreate(t *testing.T) { <-doneChan }) + t.Run("CreateErrInvalidTz", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + args := []string{ + "create", + "my-workspace", + "--template", template.Name, + "--tz", "invalid", + } + cmd, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.EqualError(t, err, "Invalid workspace autostart timezone: unknown time zone invalid") + }() + <-doneChan + }) + + t.Run("CreateErrInvalidTTL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + args := []string{ + "create", + "my-workspace", + "--template", template.Name, + "--ttl", "0s", + } + cmd, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.EqualError(t, err, "TTL must be at least 1 minute") + }() + <-doneChan + }) + t.Run("CreateFromListWithSkip", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) From 1fbc0f2b3b0a5846e1e0a04c33e2110c2a0b033e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 18:50:19 +0100 Subject: [PATCH 09/12] lint --- cli/autostart.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/autostart.go b/cli/autostart.go index 5d78abe0b143d..870eafa94c0dd 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -11,8 +11,6 @@ import ( "github.com/coder/coder/codersdk" ) -const defaultAutostartSchedule = "0 9 * * MON-FRI" - const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. When enabling autostart, provide the minute, hour, and day(s) of week. The default schedule is at 09:00 in your local timezone (TZ env, UTC by default). From 142ad370820a56933b0b24d18095804ea92adeb1 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 20 May 2022 19:05:16 +0100 Subject: [PATCH 10/12] coder: use embedded tzdata instead --- Dockerfile | 2 -- cmd/coder/main.go | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1584cf5bec6cd..8bbcbe848837f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,5 @@ FROM alpine -RUN apk add tzdata - # Generated by goreleaser on `goreleaser release` ADD coder /opt/coder diff --git a/cmd/coder/main.go b/cmd/coder/main.go index f6c2b511ab2d0..8fb7ee45f48c7 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "strings" + _ "time/tzdata" "github.com/coder/coder/cli" "github.com/coder/coder/cli/cliui" From 0f348cd27e8c3404f02ac68b5b285c34be5ca502 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sat, 21 May 2022 00:52:54 +0100 Subject: [PATCH 11/12] autobuild: fix unit test that only runs with a real db --- .../autobuild/executor/lifecycle_executor_test.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index 621a04f802dbd..203355d53efff 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -395,24 +395,15 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { IncludeProvisionerD: true, }) _ = coderdtest.New(t, &coderdtest.Options{ - AutobuildTicker: tickCh2, + AutobuildTicker: tickCh2, + IncludeProvisionerD: true, }) - // Given: we have a user with a workspace + // Given: we have a user with a workspace that has autostart enabled (default) workspace = mustProvisionWorkspace(t, client) ) // Given: workspace is stopped workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // Given: the workspace initially has autostart disabled - require.Empty(t, workspace.AutostartSchedule) - - // When: we enable workspace autostart - sched, err := schedule.Weekly("* * * * *") - require.NoError(t, err) - require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: sched.String(), - })) - // When: the autobuild executor ticks go func() { tickCh <- time.Now().UTC().Add(time.Minute) From 32a86f11d71ce6320ae6f4f1d8483e61200d2643 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 23 May 2022 22:58:32 +0100 Subject: [PATCH 12/12] address PR comments --- cli/create.go | 12 ++++++------ cli/create_test.go | 5 +++-- coderd/workspaces.go | 17 +++++++++-------- codersdk/organizations.go | 4 ++-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/cli/create.go b/cli/create.go index 16bc32d692683..7bd7a7532a282 100644 --- a/cli/create.go +++ b/cli/create.go @@ -67,7 +67,7 @@ func create() *cobra.Command { schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tz.String(), autostartMinute, autostartHour, autostartDow) _, err = schedule.Weekly(schedSpec) if err != nil { - return xerrors.Errorf("Invalid workspace autostart schedule: %w", err) + return xerrors.Errorf("invalid workspace autostart schedule: %w", err) } if ttl == 0 { @@ -229,10 +229,10 @@ func create() *cobra.Command { cliui.AllowSkipPrompt(cmd) cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.") cliflag.StringVarP(cmd.Flags(), ¶meterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.") - cliflag.StringVarP(cmd.Flags(), &autostartMinute, "autostart-minute", "", "CODER_WORKSPACE_AUTOSTART", "0", "Specify the minute(s) at which the workspace should autostart.") - cliflag.StringVarP(cmd.Flags(), &autostartHour, "autostart-hour", "", "CODER_WORKSPACE_AUTOSTART", "9", "Specify the hour(s) at which the workspace should autostart.") - cliflag.StringVarP(cmd.Flags(), &autostartDow, "autostart-day-of-week", "", "CODER_WORKSPACE_AUTOSTART", "MON-FRI", "Specify the days(s) on which the workspace should autostart.") - cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "", "Specify your timezone location for workspace autostart.") - cliflag.DurationVarP(cmd.Flags(), &ttl, "ttl", "", "CODER_WORKSPACE_TTL", 8*time.Hour, "Specify a TTL for the workspace.") + cliflag.StringVarP(cmd.Flags(), &autostartMinute, "autostart-minute", "", "CODER_WORKSPACE_AUTOSTART_MINUTE", "0", "Specify the minute(s) at which the workspace should autostart (e.g. 0).") + cliflag.StringVarP(cmd.Flags(), &autostartHour, "autostart-hour", "", "CODER_WORKSPACE_AUTOSTART_HOUR", "9", "Specify the hour(s) at which the workspace should autostart (e.g. 9).") + cliflag.StringVarP(cmd.Flags(), &autostartDow, "autostart-day-of-week", "", "CODER_WORKSPACE_AUTOSTART_DOW", "MON-FRI", "Specify the days(s) on which the workspace should autostart (e.g. MON,TUE,WED,THU,FRI)") + cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "", "Specify your timezone location for workspace autostart (e.g. US/Central).") + cliflag.DurationVarP(cmd.Flags(), &ttl, "ttl", "", "CODER_WORKSPACE_TTL", 8*time.Hour, "Specify a time-to-live (TTL) for the workspace (e.g. 8h).") return cmd } diff --git a/cli/create_test.go b/cli/create_test.go index b492e8c67f7e6..5324befeaa659 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/cli/clitest" @@ -80,7 +81,7 @@ func TestCreate(t *testing.T) { go func() { defer close(doneChan) err := cmd.Execute() - require.EqualError(t, err, "Invalid workspace autostart timezone: unknown time zone invalid") + assert.EqualError(t, err, "Invalid workspace autostart timezone: unknown time zone invalid") }() <-doneChan }) @@ -107,7 +108,7 @@ func TestCreate(t *testing.T) { go func() { defer close(doneChan) err := cmd.Execute() - require.EqualError(t, err, "TTL must be at least 1 minute") + assert.EqualError(t, err, "TTL must be at least 1 minute") }() <-doneChan }) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index aa48d67b19e50..5ec1cea75ae70 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -442,12 +442,13 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req var provisionerJob database.ProvisionerJob var workspaceBuild database.WorkspaceBuild err = api.Database.InTx(func(db database.Store) error { + now := database.Now() workspaceBuildID := uuid.New() // Workspaces are created without any versions. workspace, err = db.InsertWorkspace(r.Context(), database.InsertWorkspaceParams{ ID: uuid.New(), - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: now, + UpdatedAt: now, OwnerID: apiKey.UserID, OrganizationID: template.OrganizationID, TemplateID: template.ID, @@ -462,8 +463,8 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req _, err = db.InsertParameterValue(r.Context(), database.InsertParameterValueParams{ ID: uuid.New(), Name: parameterValue.Name, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: now, + UpdatedAt: now, Scope: database.ParameterScopeWorkspace, ScopeID: workspace.ID, SourceScheme: database.ParameterSourceScheme(parameterValue.SourceScheme), @@ -483,8 +484,8 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req } provisionerJob, err = db.InsertProvisionerJob(r.Context(), database.InsertProvisionerJobParams{ ID: uuid.New(), - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: now, + UpdatedAt: now, InitiatorID: apiKey.UserID, OrganizationID: template.OrganizationID, Provisioner: template.Provisioner, @@ -498,8 +499,8 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req } workspaceBuild, err = db.InsertWorkspaceBuild(r.Context(), database.InsertWorkspaceBuildParams{ ID: workspaceBuildID, - CreatedAt: database.Now(), - UpdatedAt: database.Now(), + CreatedAt: now, + UpdatedAt: now, WorkspaceID: workspace.ID, TemplateVersionID: templateVersion.ID, Name: namesgenerator.GetRandomName(1), diff --git a/codersdk/organizations.go b/codersdk/organizations.go index a246fb1ccb12f..82a0e648e75ee 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -67,8 +67,8 @@ type CreateTemplateRequest struct { type CreateWorkspaceRequest struct { TemplateID uuid.UUID `json:"template_id" validate:"required"` Name string `json:"name" validate:"username,required"` - AutostartSchedule *string `json:"autostart_schedule" validate:""` - TTL *time.Duration `json:"ttl" validate:""` + AutostartSchedule *string `json:"autostart_schedule"` + TTL *time.Duration `json:"ttl"` // ParameterValues allows for additional parameters to be provided // during the initial provision. ParameterValues []CreateParameterRequest `json:"parameter_values,omitempty"`