From 550982430f357233837076701be1b4014e745895 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 8 Feb 2023 20:00:21 +0000 Subject: [PATCH 01/24] feat: add template max_ttl --- coderd/activitybump.go | 4 ++ coderd/database/dump.sql | 6 +- ...0097_workspace_build_max_deadline.down.sql | 3 + ...000097_workspace_build_max_deadline.up.sql | 3 + coderd/database/models.go | 4 +- coderd/database/queries.sql.go | 62 +++++++++++++------ coderd/database/queries/workspacebuilds.sql | 6 +- .../provisionerdserver/provisionerdserver.go | 56 ++++++++++++++--- coderd/workspaces.go | 6 ++ 9 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 coderd/database/migrations/000097_workspace_build_max_deadline.down.sql create mode 100644 coderd/database/migrations/000097_workspace_build_max_deadline.up.sql diff --git a/coderd/activitybump.go b/coderd/activitybump.go index 059655ed8f33e..744393e8b01de 100644 --- a/coderd/activitybump.go +++ b/coderd/activitybump.go @@ -70,12 +70,16 @@ func activityBumpWorkspace(log slog.Logger, db database.Store, workspaceID uuid. } newDeadline := database.Now().Add(bumpAmount) + if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) { + newDeadline = build.MaxDeadline + } if _, err := s.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: build.ID, UpdatedAt: database.Now(), ProvisionerState: build.ProvisionerState, Deadline: newDeadline, + MaxDeadline: build.MaxDeadline, }); err != nil { return xerrors.Errorf("update workspace build: %w", err) } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e8bf641dd59e6..502ff5acd8cc1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -410,7 +410,8 @@ CREATE TABLE templates ( user_acl jsonb DEFAULT '{}'::jsonb NOT NULL, group_acl jsonb DEFAULT '{}'::jsonb NOT NULL, display_name character varying(64) DEFAULT ''::character varying NOT NULL, - allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL + allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL, + max_ttl bigint DEFAULT '0'::bigint NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.'; @@ -529,7 +530,8 @@ CREATE TABLE workspace_builds ( job_id uuid NOT NULL, deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, reason build_reason DEFAULT 'initiator'::build_reason NOT NULL, - daily_cost integer DEFAULT 0 NOT NULL + daily_cost integer DEFAULT 0 NOT NULL, + max_deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL ); CREATE TABLE workspace_resource_metadata ( diff --git a/coderd/database/migrations/000097_workspace_build_max_deadline.down.sql b/coderd/database/migrations/000097_workspace_build_max_deadline.down.sql new file mode 100644 index 0000000000000..2fecc6595bbc1 --- /dev/null +++ b/coderd/database/migrations/000097_workspace_build_max_deadline.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE "workspace_builds" DROP COLUMN "max_deadline"; + +ALTER TABLE "templates" DROP COLUMN "max_ttl"; diff --git a/coderd/database/migrations/000097_workspace_build_max_deadline.up.sql b/coderd/database/migrations/000097_workspace_build_max_deadline.up.sql new file mode 100644 index 0000000000000..4a382fd9f107c --- /dev/null +++ b/coderd/database/migrations/000097_workspace_build_max_deadline.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE "templates" ADD COLUMN "max_ttl" bigint DEFAULT '0'::bigint NOT NULL; + +ALTER TABLE "workspace_builds" ADD COLUMN "max_deadline" timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL; diff --git a/coderd/database/models.go b/coderd/database/models.go index ce5d2204a7266..d8826a06bac2b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1417,7 +1417,8 @@ type Template struct { // Display name is a custom, human-friendly template name that user can set. DisplayName string `db:"display_name" json:"display_name"` // Allow users to cancel in-progress workspace jobs. - AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` + AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` + MaxTtl int64 `db:"max_ttl" json:"max_ttl"` } type TemplateVersion struct { @@ -1567,6 +1568,7 @@ type WorkspaceBuild struct { Deadline time.Time `db:"deadline" json:"deadline"` Reason BuildReason `db:"reason" json:"reason"` DailyCost int32 `db:"daily_cost" json:"daily_cost"` + MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` } type WorkspaceBuildParameter struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9ed8530997320..845fd933dd251 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3088,7 +3088,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl FROM templates WHERE @@ -3117,13 +3117,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTtl, ) return i, err } const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl FROM templates WHERE @@ -3160,12 +3161,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTtl, ) return i, err } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs FROM templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl FROM templates ORDER BY (name, id) ASC ` @@ -3195,6 +3197,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTtl, ); err != nil { return nil, err } @@ -3211,7 +3214,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl FROM templates WHERE @@ -3278,6 +3281,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTtl, ); err != nil { return nil, err } @@ -3312,7 +3316,7 @@ INSERT INTO allow_user_cancel_workspace_jobs ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl ` type InsertTemplateParams struct { @@ -3369,6 +3373,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTtl, ) return i, err } @@ -3382,7 +3387,7 @@ SET WHERE id = $3 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl ` type UpdateTemplateACLByIDParams struct { @@ -3411,6 +3416,7 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTtl, ) return i, err } @@ -3471,7 +3477,7 @@ SET WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl ` type UpdateTemplateMetaByIDParams struct { @@ -3514,6 +3520,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTtl, ) return i, err } @@ -5561,7 +5568,7 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -5589,12 +5596,13 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -5631,6 +5639,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ); err != nil { return nil, err } @@ -5646,7 +5655,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB } const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -5685,6 +5694,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ); err != nil { return nil, err } @@ -5701,7 +5711,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -5727,13 +5737,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -5759,13 +5770,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -5795,13 +5807,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE @@ -5870,6 +5883,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ); err != nil { return nil, err } @@ -5885,7 +5899,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge } const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many -SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost FROM workspace_builds WHERE created_at > $1 +SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline FROM workspace_builds WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { @@ -5911,6 +5925,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ); err != nil { return nil, err } @@ -5939,10 +5954,11 @@ INSERT INTO job_id, provisioner_state, deadline, + max_deadline, reason ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline ` type InsertWorkspaceBuildParams struct { @@ -5957,6 +5973,7 @@ type InsertWorkspaceBuildParams struct { JobID uuid.UUID `db:"job_id" json:"job_id"` ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` Deadline time.Time `db:"deadline" json:"deadline"` + MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` Reason BuildReason `db:"reason" json:"reason"` } @@ -5973,6 +5990,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa arg.JobID, arg.ProvisionerState, arg.Deadline, + arg.MaxDeadline, arg.Reason, ) var i WorkspaceBuild @@ -5990,6 +6008,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } @@ -6000,9 +6019,10 @@ UPDATE SET updated_at = $2, provisioner_state = $3, - deadline = $4 + deadline = $4, + max_deadline = $5 WHERE - id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline ` type UpdateWorkspaceBuildByIDParams struct { @@ -6010,6 +6030,7 @@ type UpdateWorkspaceBuildByIDParams struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"` Deadline time.Time `db:"deadline" json:"deadline"` + MaxDeadline time.Time `db:"max_deadline" json:"max_deadline"` } func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) (WorkspaceBuild, error) { @@ -6018,6 +6039,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor arg.UpdatedAt, arg.ProvisionerState, arg.Deadline, + arg.MaxDeadline, ) var i WorkspaceBuild err := row.Scan( @@ -6034,6 +6056,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } @@ -6044,7 +6067,7 @@ UPDATE SET daily_cost = $2 WHERE - id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost + id = $1 RETURNING id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline ` type UpdateWorkspaceBuildCostByIDParams struct { @@ -6069,6 +6092,7 @@ func (q *sqlQuerier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg Updat &i.Deadline, &i.Reason, &i.DailyCost, + &i.MaxDeadline, ) return i, err } diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 30658634da4e0..b56be8f1d1de5 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -119,10 +119,11 @@ INSERT INTO job_id, provisioner_state, deadline, + max_deadline, reason ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateWorkspaceBuildByID :one UPDATE @@ -130,7 +131,8 @@ UPDATE SET updated_at = $2, provisioner_state = $3, - deadline = $4 + deadline = $4, + max_deadline = $5 WHERE id = $1 RETURNING *; diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 08c60d8b50f89..96d90c2dd85ac 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -517,15 +517,31 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p if err != nil { return nil, xerrors.Errorf("unmarshal workspace provision input: %w", err) } - build, err := server.Database.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ - ID: input.WorkspaceBuildID, - UpdatedAt: database.Now(), - ProvisionerState: jobType.WorkspaceBuild.State, - // We are explicitly not updating deadline here. - }) + + var build database.WorkspaceBuild + err := server.Database.InTx(func(db database.Store) error { + workspaceBuild, err := server.Database.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID) + if err != nil { + return xerrors.Errorf("get workspace build: %w", err) + } + + build, err = server.Database.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + ID: input.WorkspaceBuildID, + UpdatedAt: database.Now(), + ProvisionerState: jobType.WorkspaceBuild.State, + Deadline: workspaceBuild.Deadline, + MaxDeadline: workspaceBuild.MaxDeadline, + }) + if err != nil { + return xerrors.Errorf("update workspace build state: %w", err) + } + + return nil + }, nil) if err != nil { - return nil, xerrors.Errorf("update workspace build state: %w", err) + return nil, err } + err = server.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), []byte{}) if err != nil { return nil, xerrors.Errorf("update workspace: %w", err) @@ -709,8 +725,31 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete } else { // Huh? Did the workspace get deleted? // In any case, since this is just for the TTL, try and continue anyway. - server.Logger.Error(ctx, "fetch workspace for build", slog.F("workspace_build_id", workspaceBuild.ID), slog.F("workspace_id", workspaceBuild.WorkspaceID)) + server.Logger.Error(ctx, + "fetch workspace for build", + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("workspace_id", workspaceBuild.WorkspaceID), + ) } + + var workspaceMaxDeadline time.Time + template, err := db.GetTemplateByID(ctx, workspace.TemplateID) + if err == nil { + if template.MaxTtl > 0 { + workspaceMaxDeadline = now.Add(time.Duration(template.MaxTtl)) + if !workspaceDeadline.IsZero() && workspaceMaxDeadline.Before(workspaceDeadline) { + workspaceDeadline = workspaceMaxDeadline + } + } + } else { + server.Logger.Error(ctx, + "fetch template for build", + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("workspace_id", workspaceBuild.WorkspaceID), + slog.F("template_id", workspace.TemplateID), + ) + } + err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ ID: jobID, UpdatedAt: database.Now(), @@ -725,6 +764,7 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete _, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: workspaceBuild.ID, Deadline: workspaceDeadline, + MaxDeadline: workspaceMaxDeadline, ProvisionerState: jobType.WorkspaceBuild.State, UpdatedAt: now, }) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 972647a73df34..06190e5c4b5ed 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -902,12 +902,18 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { resp.Message = "Cannot extend workspace: " + err.Error() return err } + if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) { + code = http.StatusBadRequest + resp.Message = "Cannot extend workspace beyond max deadline." + return xerrors.New("Cannot extend workspace: deadline is beyond max deadline imposed by template") + } if _, err := s.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: build.ID, UpdatedAt: build.UpdatedAt, ProvisionerState: build.ProvisionerState, Deadline: newDeadline, + MaxDeadline: build.MaxDeadline, }); err != nil { code = http.StatusInternalServerError resp.Message = "Failed to extend workspace deadline." From 4c6a50169b329cf15d559be10870ea27886cb6f2 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 15 Feb 2023 11:31:38 +0000 Subject: [PATCH 02/24] chore: split enterprise code for max_ttl into interface --- coderd/coderd.go | 48 +++++----- coderd/database/models.go | 2 +- coderd/database/queries.sql.go | 16 ++-- coderd/database/sqlc.yaml | 1 + .../provisionerdserver/provisionerdserver.go | 89 +++++++++++-------- .../provisionerdserver_test.go | 78 ++++++++++------ .../templatescheduleoptions.go | 47 ++++++++++ coderd/templates.go | 4 +- coderd/templates_test.go | 8 +- codersdk/deployment.go | 2 + enterprise/audit/table.go | 1 + enterprise/coderd/coderd.go | 13 +++ enterprise/coderd/coderd_test.go | 1 + enterprise/coderd/provisionerdaemons.go | 39 ++++++-- 14 files changed, 240 insertions(+), 109 deletions(-) create mode 100644 coderd/provisionerdserver/templatescheduleoptions.go diff --git a/coderd/coderd.go b/coderd/coderd.go index bfce5a5fb1a88..a9542f65e4ca1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -110,12 +110,13 @@ type Options struct { RealIPConfig *httpmw.RealIPConfig TrialGenerator func(ctx context.Context, email string) error // TLSCertificates is used to mesh DERP servers securely. - TLSCertificates []tls.Certificate - TailnetCoordinator tailnet.Coordinator - DERPServer *derp.Server - DERPMap *tailcfg.DERPMap - SwaggerEndpoint bool - SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error + TLSCertificates []tls.Certificate + TailnetCoordinator tailnet.Coordinator + DERPServer *derp.Server + DERPMap *tailcfg.DERPMap + SwaggerEndpoint bool + SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error + TemplateScheduleStore provisionerdserver.TemplateScheduleStore // APIRateLimit is the minutely throughput rate limit per user or ip. // Setting a rate limit <0 will disable the rate limiter across the entire @@ -206,6 +207,9 @@ func New(options *Options) *API { if options.SetUserGroups == nil { options.SetUserGroups = func(context.Context, database.Store, uuid.UUID, []string) error { return nil } } + if options.TemplateScheduleStore == nil { + options.TemplateScheduleStore = provisionerdserver.NewAGPLTemplateScheduleStore() + } siteCacheDir := options.CacheDir if siteCacheDir != "" { @@ -232,9 +236,10 @@ func New(options *Options) *API { Authorizer: options.Authorizer, Logger: options.Logger, }, - metricsCache: metricsCache, - Auditor: atomic.Pointer[audit.Auditor]{}, - Experiments: experiments, + metricsCache: metricsCache, + Auditor: atomic.Pointer[audit.Auditor]{}, + TemplateScheduleStore: atomic.Pointer[provisionerdserver.TemplateScheduleStore]{}, + Experiments: experiments, } if options.UpdateCheckOptions != nil { api.updateChecker = updatecheck.New( @@ -244,6 +249,7 @@ func New(options *Options) *API { ) } api.Auditor.Store(&options.Auditor) + api.TemplateScheduleStore.Store(&options.TemplateScheduleStore) api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0) api.TailnetCoordinator.Store(&options.TailnetCoordinator) oauthConfigs := &httpmw.OAuth2Configs{ @@ -679,6 +685,7 @@ type API struct { WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] TailnetCoordinator atomic.Pointer[tailnet.Coordinator] QuotaCommitter atomic.Pointer[proto.QuotaCommitter] + TemplateScheduleStore atomic.Pointer[provisionerdserver.TemplateScheduleStore] HTTPAuth *HTTPAuthorizer @@ -770,17 +777,18 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti mux := drpcmux.New() err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{ - AccessURL: api.AccessURL, - ID: daemon.ID, - Database: api.Database, - Pubsub: api.Pubsub, - Provisioners: daemon.Provisioners, - Telemetry: api.Telemetry, - Tags: tags, - QuotaCommitter: &api.QuotaCommitter, - Auditor: &api.Auditor, - AcquireJobDebounce: debounce, - Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), + AccessURL: api.AccessURL, + ID: daemon.ID, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, + Telemetry: api.Telemetry, + Tags: tags, + QuotaCommitter: &api.QuotaCommitter, + Auditor: &api.Auditor, + TemplateScheduleStore: &api.TemplateScheduleStore, + AcquireJobDebounce: debounce, + Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), }) if err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index d8826a06bac2b..d11366e9036e7 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1418,7 +1418,7 @@ type Template struct { DisplayName string `db:"display_name" json:"display_name"` // Allow users to cancel in-progress workspace jobs. AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"` - MaxTtl int64 `db:"max_ttl" json:"max_ttl"` + MaxTTL int64 `db:"max_ttl" json:"max_ttl"` } type TemplateVersion struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 845fd933dd251..81ae8d2ca33ba 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2401,7 +2401,7 @@ WHERE -- Ensure the caller has the correct provisioner. AND nested.provisioner = ANY($3 :: provisioner_type [ ]) -- Ensure the caller satisfies all job tags. - AND nested.tags <@ $4 :: jsonb + AND nested.tags <@ $4 :: jsonb ORDER BY nested.created_at FOR UPDATE @@ -3117,7 +3117,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTtl, + &i.MaxTTL, ) return i, err } @@ -3161,7 +3161,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTtl, + &i.MaxTTL, ) return i, err } @@ -3197,7 +3197,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTtl, + &i.MaxTTL, ); err != nil { return nil, err } @@ -3281,7 +3281,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTtl, + &i.MaxTTL, ); err != nil { return nil, err } @@ -3373,7 +3373,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTtl, + &i.MaxTTL, ) return i, err } @@ -3416,7 +3416,7 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTtl, + &i.MaxTTL, ) return i, err } @@ -3520,7 +3520,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, - &i.MaxTtl, + &i.MaxTTL, ) return i, err } diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index e53fead06d0de..26f40fbd4d5e9 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -42,6 +42,7 @@ overrides: group_acl: GroupACL troubleshooting_url: TroubleshootingURL default_ttl: DefaultTTL + max_ttl: MaxTTL motd_file: MOTDFile sql: diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 96d90c2dd85ac..1fb3b4f277e02 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -40,16 +40,17 @@ var ( ) type Server struct { - AccessURL *url.URL - ID uuid.UUID - Logger slog.Logger - Provisioners []database.ProvisionerType - Tags json.RawMessage - Database database.Store - Pubsub database.Pubsub - Telemetry telemetry.Reporter - QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] - Auditor *atomic.Pointer[audit.Auditor] + AccessURL *url.URL + ID uuid.UUID + Logger slog.Logger + Provisioners []database.ProvisionerType + Tags json.RawMessage + Database database.Store + Pubsub database.Pubsub + Telemetry telemetry.Reporter + QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] + Auditor *atomic.Pointer[audit.Auditor] + TemplateScheduleStore *atomic.Pointer[TemplateScheduleStore] AcquireJobDebounce time.Duration } @@ -520,12 +521,12 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p var build database.WorkspaceBuild err := server.Database.InTx(func(db database.Store) error { - workspaceBuild, err := server.Database.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID) + workspaceBuild, err := db.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID) if err != nil { return xerrors.Errorf("get workspace build: %w", err) } - build, err = server.Database.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + build, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: input.WorkspaceBuildID, UpdatedAt: database.Now(), ProvisionerState: jobType.WorkspaceBuild.State, @@ -611,6 +612,8 @@ func (server *Server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*p } // CompleteJob is triggered by a provision daemon to mark a provisioner job as completed. +// +//nolint:gocyclo func (server *Server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) (*proto.Empty, error) { jobID, err := uuid.Parse(completed.JobId) if err != nil { @@ -715,39 +718,49 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete var getWorkspaceError error err = server.Database.InTx(func(db database.Store) error { - now := database.Now() - var workspaceDeadline time.Time + var ( + now = database.Now() + // deadline is the time when the workspace will be stopped. The + // value can be bumped by user activity or manually by the user + // via the UI. + deadline time.Time + // maxDeadline is the maximum value for deadline. + maxDeadline time.Time + ) + workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID) - if getWorkspaceError == nil { - if workspace.Ttl.Valid { - workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64)) - } - } else { - // Huh? Did the workspace get deleted? - // In any case, since this is just for the TTL, try and continue anyway. + if getWorkspaceError != nil { server.Logger.Error(ctx, "fetch workspace for build", slog.F("workspace_build_id", workspaceBuild.ID), slog.F("workspace_id", workspaceBuild.WorkspaceID), ) + return getWorkspaceError + } + if workspace.Ttl.Valid { + deadline = now.Add(time.Duration(workspace.Ttl.Int64)) } - var workspaceMaxDeadline time.Time - template, err := db.GetTemplateByID(ctx, workspace.TemplateID) - if err == nil { - if template.MaxTtl > 0 { - workspaceMaxDeadline = now.Add(time.Duration(template.MaxTtl)) - if !workspaceDeadline.IsZero() && workspaceMaxDeadline.Before(workspaceDeadline) { - workspaceDeadline = workspaceMaxDeadline - } + templateSchedule, err := (*server.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, db, workspace.TemplateID) + if err != nil { + return xerrors.Errorf("get template schedule options: %w", err) + } + if !templateSchedule.UserSchedulingEnabled { + // The user is not permitted to set their own TTL. + deadline = time.Time{} + } + if deadline.IsZero() && templateSchedule.DefaultTTL > 0 { + deadline = now.Add(templateSchedule.DefaultTTL) + } + if templateSchedule.MaxTTL > 0 { + maxDeadline = now.Add(templateSchedule.MaxTTL) + + if deadline.IsZero() || maxDeadline.Before(deadline) { + // If the workspace doesn't have a deadline or the max + // deadline is sooner than the workspace deadline, use the + // max deadline as the actual deadline. + deadline = maxDeadline } - } else { - server.Logger.Error(ctx, - "fetch template for build", - slog.F("workspace_build_id", workspaceBuild.ID), - slog.F("workspace_id", workspaceBuild.WorkspaceID), - slog.F("template_id", workspace.TemplateID), - ) } err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ @@ -763,8 +776,8 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete } _, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: workspaceBuild.ID, - Deadline: workspaceDeadline, - MaxDeadline: workspaceMaxDeadline, + Deadline: deadline, + MaxDeadline: maxDeadline, ProvisionerState: jobType.WorkspaceBuild.State, UpdatedAt: now, }) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 613e1fef2d8ba..49a54dcdcfa3b 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -31,6 +31,13 @@ func mockAuditor() *atomic.Pointer[audit.Auditor] { return ptr } +func testTemplateScheduleStore() *atomic.Pointer[provisionerdserver.TemplateScheduleStore] { + ptr := &atomic.Pointer[provisionerdserver.TemplateScheduleStore]{} + store := provisionerdserver.NewAGPLTemplateScheduleStore() + ptr.Store(&store) + return ptr +} + func TestAcquireJob(t *testing.T) { t.Parallel() t.Run("Debounce", func(t *testing.T) { @@ -38,15 +45,16 @@ func TestAcquireJob(t *testing.T) { db := dbfake.New() pubsub := database.NewPubsubInMemory() srv := &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, nil), - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: pubsub, - Telemetry: telemetry.NewNoop(), - AcquireJobDebounce: time.Hour, - Auditor: mockAuditor(), + ID: uuid.New(), + Logger: slogtest.Make(t, nil), + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: pubsub, + Telemetry: telemetry.NewNoop(), + AcquireJobDebounce: time.Hour, + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), } job, err := srv.AcquireJob(context.Background(), nil) require.NoError(t, err) @@ -602,27 +610,42 @@ func TestCompleteJob(t *testing.T) { t.Run("WorkspaceBuild", func(t *testing.T) { t.Parallel() srv := setup(t, false) + + user := dbgen.User(t, srv.Database, database.User{}) + template := dbgen.Template(t, srv.Database, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + }) + file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID}) workspace, err := srv.Database.InsertWorkspace(ctx, database.InsertWorkspaceParams{ - ID: uuid.New(), + ID: uuid.New(), + TemplateID: template.ID, }) - require.NoError(t, err) - build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ - ID: uuid.New(), - WorkspaceID: workspace.ID, - Transition: database.WorkspaceTransitionDelete, - Reason: database.BuildReasonInitiator, + version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: uuid.New(), }) require.NoError(t, err) - input, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, + build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + ID: uuid.New(), + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + Transition: database.WorkspaceTransitionDelete, + Reason: database.BuildReasonInitiator, }) require.NoError(t, err) job, err := srv.Database.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: uuid.New(), + FileID: file.ID, Provisioner: database.ProvisionerTypeEcho, - Input: input, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + })), }) require.NoError(t, err) _, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ @@ -802,14 +825,15 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server { pubsub := database.NewPubsubInMemory() return &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: pubsub, - Telemetry: telemetry.NewNoop(), - Auditor: mockAuditor(), + ID: uuid.New(), + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: pubsub, + Telemetry: telemetry.NewNoop(), + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), } } diff --git a/coderd/provisionerdserver/templatescheduleoptions.go b/coderd/provisionerdserver/templatescheduleoptions.go new file mode 100644 index 0000000000000..c6eaf45c3a018 --- /dev/null +++ b/coderd/provisionerdserver/templatescheduleoptions.go @@ -0,0 +1,47 @@ +package provisionerdserver + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/coder/coder/coderd/database" +) + +type TemplateScheduleOptions struct { + UserSchedulingEnabled bool `json:"user_scheduling_enabled"` + DefaultTTL time.Duration `json:"default_ttl"` + // If MaxTTL is set, the workspace must be stopped before this time or it + // will be stopped automatically. + // + // If set, users cannot disable automatic workspace shutdown. + MaxTTL time.Duration `json:"max_ttl"` +} + +// TemplateScheduleStore provides an interface for retrieving template +// scheduling options set by the template/site admin. +type TemplateScheduleStore interface { + GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) +} + +type agplTemplateScheduleStore struct{} + +var _ TemplateScheduleStore = &agplTemplateScheduleStore{} + +func NewAGPLTemplateScheduleStore() TemplateScheduleStore { + return &agplTemplateScheduleStore{} +} + +func (s *agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { + tpl, err := db.GetTemplateByID(ctx, templateID) + if err != nil { + return TemplateScheduleOptions{}, err + } + + return TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: time.Duration(tpl.DefaultTTL), + MaxTTL: 0, + }, nil +} diff --git a/coderd/templates.go b/coderd/templates.go index febe8ab84c93b..b6d1fdd22ffc5 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -475,7 +475,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { displayName := req.DisplayName desc := req.Description icon := req.Icon - maxTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond + defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond allowUserCancelWorkspaceJobs := req.AllowUserCancelWorkspaceJobs if name == "" { @@ -493,7 +493,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { DisplayName: displayName, Description: desc, Icon: icon, - DefaultTTL: int64(maxTTL), + DefaultTTL: int64(defaultTTL), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, }) if err != nil { diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 482bef10cda72..6cb68e5cde3e8 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -87,7 +87,7 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Equal(t, http.StatusConflict, apiErr.StatusCode()) }) - t.Run("MaxTTLTooLow", func(t *testing.T) { + t.Run("DefaultTTLTooLow", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -107,7 +107,7 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Contains(t, err.Error(), "default_ttl_ms: Must be a positive integer") }) - t.Run("NoMaxTTL", func(t *testing.T) { + t.Run("NoDefaultTTL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) @@ -290,7 +290,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[4].Action) }) - t.Run("NoMaxTTL", func(t *testing.T) { + t.Run("NoDefaultTTL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -319,7 +319,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) }) - t.Run("MaxTTLTooLow", func(t *testing.T) { + t.Run("DefaultTTLTooLow", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index da4a68e6753a8..e01f17ed77f6c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -35,6 +35,7 @@ const ( FeatureMultipleGitAuth FeatureName = "multiple_git_auth" FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons" FeatureAppearance FeatureName = "appearance" + FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -48,6 +49,7 @@ var FeatureNames = []FeatureName{ FeatureMultipleGitAuth, FeatureExternalProvisionerDaemons, FeatureAppearance, + FeatureAdvancedTemplateScheduling, } // Humanize returns the feature name in a human-readable format. diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index ffc0f303bd25a..256af036cb00c 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -71,6 +71,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "group_acl": ActionTrack, "user_acl": ActionTrack, "allow_user_cancel_workspace_jobs": ActionTrack, + "max_ttl": ActionTrack, }, &database.TemplateVersion{}: { "id": ActionTrack, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 67d5b543aef31..03fefd59e8596 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -20,6 +20,7 @@ import ( agplaudit "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/license" @@ -248,6 +249,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1, codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalProvisionerDaemons: true, + codersdk.FeatureAdvancedTemplateScheduling: true, }) if err != nil { return err @@ -292,6 +294,17 @@ func (api *API) updateEntitlements(ctx context.Context) error { } } + if changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); changed { + if enabled { + store := &enterpriseTemplateScheduleStore{} + ptr := provisionerdserver.TemplateScheduleStore(store) + api.AGPL.TemplateScheduleStore.Store(&ptr) + } else { + store := provisionerdserver.NewAGPLTemplateScheduleStore() + api.AGPL.TemplateScheduleStore.Store(&store) + } + } + if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed { coordinator := agpltailnet.NewCoordinator() if enabled { diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 4d67d97029830..a15b1ba71f7f1 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -47,6 +47,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureAuditLog: 1, codersdk.FeatureTemplateRBAC: 1, codersdk.FeatureExternalProvisionerDaemons: 1, + codersdk.FeatureAdvancedTemplateScheduling: 1, }, }) res, err := client.Entitlements(context.Background()) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 7fbc5b42b17c1..59512eaa385ff 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "encoding/json" "errors" @@ -8,6 +9,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/google/uuid" "github.com/hashicorp/yamux" @@ -210,15 +212,16 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) } mux := drpcmux.New() err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{ - AccessURL: api.AccessURL, - ID: daemon.ID, - Database: api.Database, - Pubsub: api.Pubsub, - Provisioners: daemon.Provisioners, - Telemetry: api.Telemetry, - Auditor: &api.AGPL.Auditor, - Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), - Tags: rawTags, + AccessURL: api.AccessURL, + ID: daemon.ID, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, + Telemetry: api.Telemetry, + Auditor: &api.AGPL.Auditor, + TemplateScheduleStore: &api.AGPL.TemplateScheduleStore, + Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), + Tags: rawTags, }) if err != nil { _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err)) @@ -254,3 +257,21 @@ func convertProvisionerDaemon(daemon database.ProvisionerDaemon) codersdk.Provis } return result } + +type enterpriseTemplateScheduleStore struct{} + +var _ provisionerdserver.TemplateScheduleStore = &enterpriseTemplateScheduleStore{} + +func (s *enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { + tpl, err := db.GetTemplateByID(ctx, templateID) + if err != nil { + return provisionerdserver.TemplateScheduleOptions{}, err + } + + return provisionerdserver.TemplateScheduleOptions{ + // TODO: make configurable at template level + UserSchedulingEnabled: true, + DefaultTTL: time.Duration(tpl.DefaultTTL), + MaxTTL: time.Duration(tpl.MaxTTL), + }, nil +} From 9e37cc73cf7238e73002b17f7c142af075e61937 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 20 Feb 2023 14:04:07 +0000 Subject: [PATCH 03/24] feat: add API for setting template max_ttl --- coderd/apidoc/docs.go | 4 ++ coderd/apidoc/swagger.json | 4 ++ coderd/database/dbauthz/querier.go | 7 +++ coderd/database/dbfake/databasefake.go | 25 +++++++- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 61 ++++++++++++++++--- coderd/database/queries/provisionerjobs.sql | 2 +- coderd/database/queries/templates.sql | 21 +++++-- .../templatescheduleoptions.go | 18 +++++- coderd/templates.go | 18 +++++- coderd/templates_test.go | 24 ++++++++ codersdk/templates.go | 23 ++++--- docs/admin/audit-logs.md | 22 +++---- docs/api/schemas.md | 38 ++++++------ docs/api/templates.md | 50 ++++++++------- enterprise/coderd/provisionerdaemons.go | 11 +++- site/src/api/typesGenerated.ts | 4 ++ 17 files changed, 253 insertions(+), 80 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d4e1236849534..b2f224135c8b2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7463,6 +7463,10 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "max_ttl_ms": { + "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", + "type": "integer" + }, "name": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 894a2d0b6cafd..c777743e6d011 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6709,6 +6709,10 @@ "type": "string", "format": "uuid" }, + "max_ttl_ms": { + "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", + "type": "integer" + }, "name": { "type": "string" }, diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 91e912cb90271..51d87e12365f6 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -852,6 +852,13 @@ func (q *querier) UpdateTemplateMetaByID(ctx context.Context, arg database.Updat return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateTemplateMetaByID)(ctx, arg) } +func (q *querier) UpdateTemplateScheduleByID(ctx context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) { + fetch := func(ctx context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateTemplateScheduleByID)(ctx, arg) +} + func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { template, err := q.db.GetTemplateByID(ctx, arg.TemplateID.UUID) if err != nil { diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 1426eb494833f..bce496e205582 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -1644,8 +1644,8 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd return database.Template{}, err } - q.mutex.RLock() - defer q.mutex.RUnlock() + q.mutex.Lock() + defer q.mutex.Unlock() for idx, tpl := range q.templates { if tpl.ID != arg.ID { @@ -1656,7 +1656,28 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd tpl.DisplayName = arg.DisplayName tpl.Description = arg.Description tpl.Icon = arg.Icon + q.templates[idx] = tpl + return tpl, nil + } + + return database.Template{}, sql.ErrNoRows +} + +func (q *fakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database.UpdateTemplateScheduleByIDParams) (database.Template, error) { + if err := validateDatabaseType(arg); err != nil { + return database.Template{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for idx, tpl := range q.templates { + if tpl.ID != arg.ID { + continue + } + tpl.UpdatedAt = database.Now() tpl.DefaultTTL = arg.DefaultTTL + tpl.MaxTTL = arg.MaxTTL q.templates[idx] = tpl return tpl, nil } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4f7bf73b4de3e..bdf82c85d1d2d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -187,6 +187,7 @@ type sqlcQuerier interface { UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error) + UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a31e50bff9070..2278594709f16 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3493,11 +3493,10 @@ UPDATE SET updated_at = $2, description = $3, - default_ttl = $4, - name = $5, - icon = $6, - display_name = $7, - allow_user_cancel_workspace_jobs = $8 + name = $4, + icon = $5, + display_name = $6, + allow_user_cancel_workspace_jobs = $7 WHERE id = $1 RETURNING @@ -3508,7 +3507,6 @@ type UpdateTemplateMetaByIDParams struct { ID uuid.UUID `db:"id" json:"id"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Description string `db:"description" json:"description"` - DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` Name string `db:"name" json:"name"` Icon string `db:"icon" json:"icon"` DisplayName string `db:"display_name" json:"display_name"` @@ -3520,7 +3518,6 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.ID, arg.UpdatedAt, arg.Description, - arg.DefaultTTL, arg.Name, arg.Icon, arg.DisplayName, @@ -3549,6 +3546,56 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl return i, err } +const updateTemplateScheduleByID = `-- name: UpdateTemplateScheduleByID :one +UPDATE + templates +SET + updated_at = $2, + default_ttl = $3, + max_ttl = $4 +WHERE + id = $1 +RETURNING + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl +` + +type UpdateTemplateScheduleByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` + MaxTTL int64 `db:"max_ttl" json:"max_ttl"` +} + +func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) { + row := q.db.QueryRowContext(ctx, updateTemplateScheduleByID, + arg.ID, + arg.UpdatedAt, + arg.DefaultTTL, + arg.MaxTTL, + ) + var i Template + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OrganizationID, + &i.Deleted, + &i.Name, + &i.Provisioner, + &i.ActiveVersionID, + &i.Description, + &i.DefaultTTL, + &i.CreatedBy, + &i.Icon, + &i.UserACL, + &i.GroupACL, + &i.DisplayName, + &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, + ) + return i, err +} + const getTemplateVersionParameters = `-- name: GetTemplateVersionParameters :many SELECT template_version_id, name, description, type, mutable, default_value, icon, options, validation_regex, validation_min, validation_max, validation_error, validation_monotonic FROM template_version_parameters WHERE template_version_id = $1 ` diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index 8f68546a58ef3..7626c52251280 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -22,7 +22,7 @@ WHERE -- Ensure the caller has the correct provisioner. AND nested.provisioner = ANY(@types :: provisioner_type [ ]) -- Ensure the caller satisfies all job tags. - AND nested.tags <@ @tags :: jsonb + AND nested.tags <@ @tags :: jsonb ORDER BY nested.created_at FOR UPDATE diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 5fa8270b5ae33..30cccd40b033d 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -102,11 +102,22 @@ UPDATE SET updated_at = $2, description = $3, - default_ttl = $4, - name = $5, - icon = $6, - display_name = $7, - allow_user_cancel_workspace_jobs = $8 + name = $4, + icon = $5, + display_name = $6, + allow_user_cancel_workspace_jobs = $7 +WHERE + id = $1 +RETURNING + *; + +-- name: UpdateTemplateScheduleByID :one +UPDATE + templates +SET + updated_at = $2, + default_ttl = $3, + max_ttl = $4 WHERE id = $1 RETURNING diff --git a/coderd/provisionerdserver/templatescheduleoptions.go b/coderd/provisionerdserver/templatescheduleoptions.go index c6eaf45c3a018..2387115c4f0b5 100644 --- a/coderd/provisionerdserver/templatescheduleoptions.go +++ b/coderd/provisionerdserver/templatescheduleoptions.go @@ -23,6 +23,7 @@ type TemplateScheduleOptions struct { // scheduling options set by the template/site admin. type TemplateScheduleStore interface { GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) + SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error) } type agplTemplateScheduleStore struct{} @@ -33,7 +34,7 @@ func NewAGPLTemplateScheduleStore() TemplateScheduleStore { return &agplTemplateScheduleStore{} } -func (s *agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { +func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { tpl, err := db.GetTemplateByID(ctx, templateID) if err != nil { return TemplateScheduleOptions{}, err @@ -42,6 +43,19 @@ func (s *agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Conte return TemplateScheduleOptions{ UserSchedulingEnabled: true, DefaultTTL: time.Duration(tpl.DefaultTTL), - MaxTTL: 0, + // Disregard the value in the database, since MaxTTL is an enterprise + // feature. + MaxTTL: 0, }, nil } + +func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) { + return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: tpl.ID, + UpdatedAt: database.Now(), + DefaultTTL: int64(opts.DefaultTTL), + // Don't allow changing it, but keep the value in the DB (to avoid + // clearing settings if the license has an issue). + MaxTTL: tpl.MaxTTL, + }) +} diff --git a/coderd/templates.go b/coderd/templates.go index 3e9bc345e24f1..21d47b138ac16 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -17,6 +17,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/codersdk" @@ -479,7 +480,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { displayName := req.DisplayName desc := req.Description icon := req.Icon - defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond allowUserCancelWorkspaceJobs := req.AllowUserCancelWorkspaceJobs if name == "" { @@ -497,11 +497,23 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { DisplayName: displayName, Description: desc, Icon: icon, - DefaultTTL: int64(defaultTTL), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, }) if err != nil { - return err + return xerrors.Errorf("update template metadata: %w", err) + } + + defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond + maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond + if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) { + updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, provisionerdserver.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: defaultTTL, + MaxTTL: maxTTL, + }) + if err != nil { + return xerrors.Errorf("set template schedule options: %w", err) + } } return nil diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 4a5f181e5ea56..9ebb12c6a944d 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -430,6 +430,30 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.Equal(t, updated.Icon, "") }) + + t.Run("MaxTTLEnterpriseOnly", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.EqualValues(t, 0, template.MaxTTLMillis) + req := codersdk.UpdateTemplateMeta{ + MaxTTLMillis: time.Hour.Milliseconds(), + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + require.EqualValues(t, 0, updated.MaxTTLMillis) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.EqualValues(t, 0, template.MaxTTLMillis) + }) } func TestDeleteTemplate(t *testing.T) { diff --git a/codersdk/templates.go b/codersdk/templates.go index fed87a08cec56..f9f46e542c03a 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -28,8 +28,11 @@ type Template struct { Description string `json:"description"` Icon string `json:"icon"` DefaultTTLMillis int64 `json:"default_ttl_ms"` - CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` - CreatedByName string `json:"created_by_name"` + // MaxTTLMillis is an enterprise feature. It's value is only used if your + // license is entitled to use the advanced template scheduling feature. + MaxTTLMillis int64 `json:"max_ttl_ms"` + CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` + CreatedByName string `json:"created_by_name"` AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"` } @@ -75,12 +78,16 @@ type UpdateTemplateACL struct { } type UpdateTemplateMeta struct { - Name string `json:"name,omitempty" validate:"omitempty,template_name"` - DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` - AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,template_name"` + DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` + // MaxTTLMillis can only be set if your license includes the advanced + // template scheduling feature. If you attempt to set this value while + // unlicensed, it will be ignored. + MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` + AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` } type TemplateExample struct { diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 762b2f3762ca7..a43ba302f8f96 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -9,17 +9,17 @@ We track the following resources: -| Resource | | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
updated_atfalse
user_idfalse
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
is_privatetrue
min_autostart_intervaltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
idtrue
job_idfalse
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_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
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_idfalse
job_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| Resource | | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
updated_atfalse
user_idfalse
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
is_privatetrue
max_ttltrue
min_autostart_intervaltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
idtrue
job_idfalse
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_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
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_idfalse
job_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 948a0e6304197..3c671f08b8dda 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4349,6 +4349,7 @@ Parameter represents a set value for the scope. "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -4358,24 +4359,25 @@ Parameter represents a set value for the scope. ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | -------------------------------------------- | -| `active_user_count` | integer | false | | Active user count is set to -1 when loading. | -| `active_version_id` | string | false | | | -| `allow_user_cancel_workspace_jobs` | boolean | false | | | -| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | -| `created_at` | string | false | | | -| `created_by_id` | string | false | | | -| `created_by_name` | string | false | | | -| `default_ttl_ms` | integer | false | | | -| `description` | string | false | | | -| `display_name` | string | false | | | -| `icon` | string | false | | | -| `id` | string | false | | | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `provisioner` | string | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `active_user_count` | integer | false | | Active user count is set to -1 when loading. | +| `active_version_id` | string | false | | | +| `allow_user_cancel_workspace_jobs` | boolean | false | | | +| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | +| `created_at` | string | false | | | +| `created_by_id` | string | false | | | +| `created_by_name` | string | false | | | +| `default_ttl_ms` | integer | false | | | +| `description` | string | false | | | +| `display_name` | string | false | | | +| `icon` | string | false | | | +| `id` | string | false | | | +| `max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `provisioner` | string | false | | | +| `updated_at` | string | false | | | #### Enumerated Values diff --git a/docs/api/templates.md b/docs/api/templates.md index b48b805d7c58e..5986990f4cea5 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -118,6 +118,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -136,28 +137,29 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ------------------------------------ | ---------------------------------------------------------------------------- | -------- | ------------ | -------------------------------------------- | -| `[array item]` | array | false | | | -| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | -| `» active_version_id` | string(uuid) | false | | | -| `» allow_user_cancel_workspace_jobs` | boolean | false | | | -| `» build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | | -| `»» [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | | -| `»»» p50` | integer | false | | | -| `»»» p95` | integer | false | | | -| `» created_at` | string(date-time) | false | | | -| `» created_by_id` | string(uuid) | false | | | -| `» created_by_name` | string | false | | | -| `» default_ttl_ms` | integer | false | | | -| `» description` | string | false | | | -| `» display_name` | string | false | | | -| `» icon` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» provisioner` | string | false | | | -| `» updated_at` | string(date-time) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------------------------ | ---------------------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | +| `» active_version_id` | string(uuid) | false | | | +| `» allow_user_cancel_workspace_jobs` | boolean | false | | | +| `» build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | | +| `»» [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | | +| `»»» p50` | integer | false | | | +| `»»» p95` | integer | false | | | +| `» created_at` | string(date-time) | false | | | +| `» created_by_id` | string(uuid) | false | | | +| `» created_by_name` | string | false | | | +| `» default_ttl_ms` | integer | false | | | +| `» description` | string | false | | | +| `» display_name` | string | false | | | +| `» icon` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» provisioner` | string | false | | | +| `» updated_at` | string(date-time) | false | | | #### Enumerated Values @@ -238,6 +240,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -360,6 +363,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -678,6 +682,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", @@ -783,6 +788,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "display_name": "string", "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 38ee1a0b783fe..9cfaa2c9e3f3a 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -308,7 +308,7 @@ type enterpriseTemplateScheduleStore struct{} var _ provisionerdserver.TemplateScheduleStore = &enterpriseTemplateScheduleStore{} -func (s *enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { +func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { tpl, err := db.GetTemplateByID(ctx, templateID) if err != nil { return provisionerdserver.TemplateScheduleOptions{}, err @@ -321,3 +321,12 @@ func (s *enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context MaxTTL: time.Duration(tpl.MaxTTL), }, nil } + +func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts provisionerdserver.TemplateScheduleOptions) (database.Template, error) { + return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: tpl.ID, + UpdatedAt: database.Now(), + DefaultTTL: int64(opts.DefaultTTL), + MaxTTL: int64(opts.MaxTTL), + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c7159fb50a8f8..b17146287b780 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -698,6 +698,7 @@ export interface Template { readonly description: string readonly icon: string readonly default_ttl_ms: number + readonly max_ttl_ms: number readonly created_by_id: string readonly created_by_name: string readonly allow_user_cancel_workspace_jobs: boolean @@ -838,6 +839,7 @@ export interface UpdateTemplateMeta { readonly description?: string readonly icon?: string readonly default_ttl_ms?: number + readonly max_ttl_ms?: number readonly allow_user_cancel_workspace_jobs?: boolean } @@ -1112,6 +1114,7 @@ export const Experiments: Experiment[] = ["authz_querier", "template_editor"] // From codersdk/deployment.go export type FeatureName = + | "advanced_template_scheduling" | "appearance" | "audit_log" | "browser_only" @@ -1122,6 +1125,7 @@ export type FeatureName = | "template_rbac" | "user_limit" export const FeatureNames: FeatureName[] = [ + "advanced_template_scheduling", "appearance", "audit_log", "browser_only", From ad64806901c3a897835232c3a86f02e129fcd2f4 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 20 Feb 2023 14:18:05 +0000 Subject: [PATCH 04/24] working API and dashboard --- coderd/apidoc/docs.go | 4 ++ coderd/apidoc/swagger.json | 4 ++ coderd/database/dbfake/databasefake.go | 1 - coderd/database/dbgen/generator.go | 1 - ...100_workspace_build_max_deadline.down.sql} | 0 ...00100_workspace_build_max_deadline.up.sql} | 0 coderd/database/modelqueries.go | 1 + coderd/database/queries.sql.go | 5 +- coderd/database/queries/templates.sql | 3 +- coderd/templates.go | 51 +++++++++++++++---- coderd/templates_test.go | 8 ++- codersdk/organizations.go | 3 ++ docs/api/schemas.md | 2 + docs/api/templates.md | 1 + enterprise/coderd/templates_test.go | 39 ++++++++++++++ site/src/api/typesGenerated.ts | 1 + site/src/i18n/en/createTemplatePage.json | 2 + site/src/i18n/en/templateSettingsPage.json | 18 ++++--- .../CreateTemplatePage/CreateTemplateForm.tsx | 37 ++++++++++++-- .../TemplateSettingsForm.tsx | 42 +++++++++++++-- .../createTemplate/createTemplateXService.ts | 3 ++ 21 files changed, 193 insertions(+), 33 deletions(-) rename coderd/database/migrations/{000097_workspace_build_max_deadline.down.sql => 000100_workspace_build_max_deadline.down.sql} (100%) rename coderd/database/migrations/{000097_workspace_build_max_deadline.up.sql => 000100_workspace_build_max_deadline.up.sql} (100%) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b2f224135c8b2..865254d2f8c14 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5759,6 +5759,10 @@ const docTemplate = `{ "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, + "max_ttl_ms": { + "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", + "type": "integer" + }, "name": { "description": "Name is the name of the template.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c777743e6d011..8397bd6fe9440 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5107,6 +5107,10 @@ "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, + "max_ttl_ms": { + "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", + "type": "integer" + }, "name": { "description": "Name is the name of the template.", "type": "string" diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index bce496e205582..f7e8443987550 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -2615,7 +2615,6 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl Provisioner: arg.Provisioner, ActiveVersionID: arg.ActiveVersionID, Description: arg.Description, - DefaultTTL: arg.DefaultTTL, CreatedBy: arg.CreatedBy, UserACL: arg.UserACL, GroupACL: arg.GroupACL, diff --git a/coderd/database/dbgen/generator.go b/coderd/database/dbgen/generator.go index cb8f52c06529a..11303a9a29793 100644 --- a/coderd/database/dbgen/generator.go +++ b/coderd/database/dbgen/generator.go @@ -60,7 +60,6 @@ func Template(t testing.TB, db database.Store, seed database.Template) database. Provisioner: takeFirst(seed.Provisioner, database.ProvisionerTypeEcho), ActiveVersionID: takeFirst(seed.ActiveVersionID, uuid.New()), Description: takeFirst(seed.Description, namesgenerator.GetRandomName(1)), - DefaultTTL: takeFirst(seed.DefaultTTL, 3600), CreatedBy: takeFirst(seed.CreatedBy, uuid.New()), Icon: takeFirst(seed.Icon, namesgenerator.GetRandomName(1)), UserACL: seed.UserACL, diff --git a/coderd/database/migrations/000097_workspace_build_max_deadline.down.sql b/coderd/database/migrations/000100_workspace_build_max_deadline.down.sql similarity index 100% rename from coderd/database/migrations/000097_workspace_build_max_deadline.down.sql rename to coderd/database/migrations/000100_workspace_build_max_deadline.down.sql diff --git a/coderd/database/migrations/000097_workspace_build_max_deadline.up.sql b/coderd/database/migrations/000100_workspace_build_max_deadline.up.sql similarity index 100% rename from coderd/database/migrations/000097_workspace_build_max_deadline.up.sql rename to coderd/database/migrations/000100_workspace_build_max_deadline.up.sql diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 348555285ad03..a9bdfca75d46f 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -77,6 +77,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.GroupACL, &i.DisplayName, &i.AllowUserCancelWorkspaceJobs, + &i.MaxTTL, ); err != nil { return nil, err } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2278594709f16..03c7c583f1776 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3331,7 +3331,6 @@ INSERT INTO provisioner, active_version_id, description, - default_ttl, created_by, icon, user_acl, @@ -3340,7 +3339,7 @@ INSERT INTO allow_user_cancel_workspace_jobs ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl ` type InsertTemplateParams struct { @@ -3352,7 +3351,6 @@ type InsertTemplateParams struct { Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"` Description string `db:"description" json:"description"` - DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` Icon string `db:"icon" json:"icon"` UserACL TemplateACL `db:"user_acl" json:"user_acl"` @@ -3371,7 +3369,6 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.Provisioner, arg.ActiveVersionID, arg.Description, - arg.DefaultTTL, arg.CreatedBy, arg.Icon, arg.UserACL, diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 30cccd40b033d..03b9c9bccc954 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -67,7 +67,6 @@ INSERT INTO provisioner, active_version_id, description, - default_ttl, created_by, icon, user_acl, @@ -76,7 +75,7 @@ INSERT INTO allow_user_cancel_workspace_jobs ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *; -- name: UpdateTemplateActiveVersionByID :exec UPDATE diff --git a/coderd/templates.go b/coderd/templates.go index 21d47b138ac16..e91334cbb7449 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -213,16 +213,31 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return } - var ttl time.Duration + var ( + defaultTTL time.Duration + maxTTL time.Duration + ) if createTemplate.DefaultTTLMillis != nil { - ttl = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond + defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond + } + if createTemplate.MaxTTLMillis != nil { + maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond + } + + var validErrs []codersdk.ValidationError + if defaultTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) + } + if maxTTL < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) + } + if maxTTL != 0 && defaultTTL > maxTTL { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) } - if ttl < 0 { + if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid create template request.", - Validations: []codersdk.ValidationError{ - {Field: "default_ttl_ms", Detail: "Must be a positive integer."}, - }, + Message: "Invalid create template request.", + Validations: validErrs, }) return } @@ -245,7 +260,6 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque Provisioner: importJob.Provisioner, ActiveVersionID: templateVersion.ID, Description: createTemplate.Description, - DefaultTTL: int64(ttl), CreatedBy: apiKey.UserID, UserACL: database.TemplateACL{}, GroupACL: database.TemplateACL{ @@ -259,6 +273,17 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return xerrors.Errorf("insert template: %s", err) } + if defaultTTL != 0 || maxTTL != 0 { + dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, provisionerdserver.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: defaultTTL, + MaxTTL: maxTTL, + }) + if err != nil { + return xerrors.Errorf("set template schedule options: %s", err) + } + } + templateAudit.New = dbTemplate err = tx.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ @@ -453,6 +478,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.DefaultTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } + if req.MaxTTLMillis < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) + } + if req.MaxTTLMillis != 0 && req.DefaultTTLMillis > req.MaxTTLMillis { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) + } if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -469,7 +500,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.DisplayName == template.DisplayName && req.Icon == template.Icon && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && - req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() { + req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && + req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() { return nil } @@ -647,6 +679,7 @@ func (api *API) convertTemplate( Description: template.Description, Icon: template.Icon, DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), + MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: createdByName, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 9ebb12c6a944d..092f6f75c9d99 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -440,7 +440,13 @@ func TestPatchTemplateMeta(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.EqualValues(t, 0, template.MaxTTLMillis) req := codersdk.UpdateTemplateMeta{ - MaxTTLMillis: time.Hour.Milliseconds(), + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + MaxTTLMillis: (2 * time.Hour).Milliseconds(), } ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 5427b93e2006e..f3a30bf4f94fe 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -88,6 +88,9 @@ type CreateTemplateRequest struct { // DefaultTTLMillis allows optionally specifying the default TTL // for all workspaces created from this template. DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"` + // MaxTTLMillis allows optionally specifying the max lifetime for + // workspaces created from this template. + MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"` // Allow users to cancel in-progress workspace jobs. // *bool as the default value is "true". diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 3c671f08b8dda..320b98693a6f6 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -953,6 +953,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a "description": "string", "display_name": "string", "icon": "string", + "max_ttl_ms": 0, "name": "string", "parameter_values": [ { @@ -976,6 +977,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. | | `display_name` | string | false | | Display name is the displayed name of the template. | | `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | +| `max_ttl_ms` | integer | false | | Max ttl ms allows optionally specifying the max lifetime for workspaces created from this template. | | `name` | string | true | | Name is the name of the template. | | `parameter_values` | array of [codersdk.CreateParameterRequest](#codersdkcreateparameterrequest) | false | | Parameter values is a structure used to create a new parameter value for a scope.] | | `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. | diff --git a/docs/api/templates.md b/docs/api/templates.md index 5986990f4cea5..39a40dfe1167c 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -192,6 +192,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "description": "string", "display_name": "string", "icon": "string", + "max_ttl_ms": 0, "name": "string", "parameter_values": [ { diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 7c9303f188310..c379675edff2d 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -5,6 +5,7 @@ import ( "context" "net/http" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -21,6 +22,44 @@ import ( "github.com/coder/coder/testutil" ) +func TestTemplates(t *testing.T) { + t.Parallel() + + t.Run("SetMaxTTL", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.EqualValues(t, 0, template.MaxTTLMillis) + + ctx, _ := testutil.Context(t) + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + MaxTTLMillis: (2 * time.Hour).Milliseconds(), + }) + require.NoError(t, err) + require.Equal(t, 2*time.Hour, time.Duration(updated.MaxTTLMillis)*time.Millisecond) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Equal(t, 2*time.Hour, time.Duration(template.MaxTTLMillis)*time.Millisecond) + }) +} + func TestTemplateACL(t *testing.T) { t.Parallel() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b17146287b780..8957e39081a8a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -179,6 +179,7 @@ export interface CreateTemplateRequest { readonly template_version_id: string readonly parameter_values?: CreateParameterRequest[] readonly default_ttl_ms?: number + readonly max_ttl_ms?: number readonly allow_user_cancel_workspace_jobs?: boolean } diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index 575c492aa0fe3..996adb276e473 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -27,10 +27,12 @@ "description": "Description", "icon": "Icon", "autoStop": "Auto-stop default", + "maxTTL": "Maximum lifetime of workspaces", "allowUsersToCancel": "Allow users to cancel in-progress workspace jobs" }, "helperText": { "autoStop": "Time in hours", + "maxTTL": "Time in hours", "allowUsersToCancel": "If checked, users may be able to corrupt their workspace." }, "upload": { diff --git a/site/src/i18n/en/templateSettingsPage.json b/site/src/i18n/en/templateSettingsPage.json index f738567b50756..edcaf469a6726 100644 --- a/site/src/i18n/en/templateSettingsPage.json +++ b/site/src/i18n/en/templateSettingsPage.json @@ -5,14 +5,20 @@ "descriptionLabel": "Description", "descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.", "defaultTtlLabel": "Auto-stop default", + "maxTtlLabel": "Maximum lifetime for started workspaces", "iconLabel": "Icon", "formAriaLabel": "Template settings form", "selectEmoji": "Select emoji", - "ttlMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).", - "ttlMinError": "Default time until auto-stop must not be less than 0.", - "ttlHelperText_zero": "Workspaces created from this template will run until stopped manually.", - "ttlHelperText_one": "Workspaces created from this template will default to stopping after {{count}} hour.", - "ttlHelperText_other": "Workspaces created from this template will default to stopping after {{count}} hours.", + "defaultTTLMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).", + "defaultTTLMinError": "Default time until auto-stop must not be less than 0.", + "defaultTTLHelperText_zero": "Workspaces created from this template will run until stopped manually.", + "defaultTTLHelperText_one": "Workspaces created from this template will default to stopping after {{count}} hour.", + "defaultTTLHelperText_other": "Workspaces created from this template will default to stopping after {{count}} hours.", + "maxTTLMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).", + "maxTTLMinError": "Maximum time until auto-stop must not be less than 0.", + "maxTTLHelperText_zero": "Workspaces created from this template may run indefinitely.", + "maxTTLHelperText_one": "Workspaces created from this template must stop within {{count}} hour of starting.", + "maxTTLHelperText_other": "Workspaces created from this template must stop within {{count}} hours of starting.", "allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.", "allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.", "allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.", @@ -26,7 +32,7 @@ }, "schedule": { "title": "Schedule", - "description": "Define when workspaces created from this template automatically stop." + "description": "Define when workspaces created from this template are stopped." }, "operations": { "title": "Operations", diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 48fe9dd55f8d0..1a274b232b680 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -23,6 +23,8 @@ import * as Yup from "yup" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { LazyIconField } from "components/IconField/LazyIconField" +import { Maybe } from "components/Conditionals/Maybe" +import { useDashboard } from "components/Dashboard/DashboardProvider" const validationSchema = Yup.object({ name: nameValidator("Name"), @@ -30,6 +32,7 @@ const validationSchema = Yup.object({ description: Yup.string().optional(), icon: Yup.string().optional(), default_ttl_hours: Yup.number(), + max_ttl_hours: Yup.number(), allow_user_cancel_workspace_jobs: Yup.boolean(), parameter_values_by_name: Yup.object().optional(), }) @@ -40,17 +43,27 @@ const defaultInitialValues: CreateTemplateData = { description: "", icon: "", default_ttl_hours: 24, + // max_ttl is an enterprise-only feature, and the server ignores the value if + // you are not licensed. We hide the form value based on entitlements. + max_ttl_hours: 24 * 7, allow_user_cancel_workspace_jobs: false, parameter_values_by_name: undefined, } -const getInitialValues = (starterTemplate?: TemplateExample) => { +const getInitialValues = (canSetMaxTTL: boolean, starterTemplate?: TemplateExample) => { + let initialValues = defaultInitialValues + if (!canSetMaxTTL) { + initialValues = { + ...initialValues, + max_ttl_hours: 0, + } + } if (!starterTemplate) { - return defaultInitialValues + return initialValues } return { - ...defaultInitialValues, + ...initialValues, name: starterTemplate.id, display_name: starterTemplate.name, icon: starterTemplate.icon, @@ -83,8 +96,11 @@ export const CreateTemplateForm: FC = ({ }) => { const styles = useStyles() const formFooterStyles = useFormFooterStyles() + const { entitlements } = useDashboard() + const canSetMaxTTL = entitlements.features["advanced_template_scheduling"].enabled + const form = useFormik({ - initialValues: getInitialValues(starterTemplate), + initialValues: getInitialValues(canSetMaxTTL, starterTemplate), validationSchema, onSubmit, }) @@ -188,6 +204,19 @@ export const CreateTemplateForm: FC = ({ type="number" helperText={t("form.helperText.autoStop")} /> + + + + diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx index 84bdbe314b0fd..46a0c42ed96e0 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx @@ -23,14 +23,15 @@ import { Stack } from "components/Stack/Stack" import Checkbox from "@material-ui/core/Checkbox" import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { makeStyles } from "@material-ui/core/styles" +import { useDashboard } from "components/Dashboard/DashboardProvider" -const TTLHelperText = ({ ttl }: { ttl?: number }) => { ++const TTLHelperText = ({ ttl, translationName }: { ttl?: number, translationName: string }) => { const { t } = useTranslation("templateSettingsPage") const count = typeof ttl !== "number" ? 0 : ttl return ( // no helper text if ttl is negative - error will show once field is considered touched = 0}> - {t("ttlHelperText", { count })} ++ {t(translationName, { count })} ) } @@ -53,10 +54,17 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => ), default_ttl_ms: Yup.number() .integer() - .min(0, i18next.t("ttlMinError", { ns: "templateSettingsPage" })) + .min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" })) .max( 24 * MAX_TTL_DAYS /* 7 days in hours */, - i18next.t("ttlMaxError", { ns: "templateSettingsPage" }), + i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }), + ), + max_ttl_ms: Yup.number() + .integer() + .min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" })) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }), ), allow_user_cancel_workspace_jobs: Yup.boolean(), }) @@ -79,6 +87,9 @@ export const TemplateSettingsForm: FC = ({ isSubmitting, initialTouched, }) => { + const { entitlements } = useDashboard() + const canSetMaxTTL = entitlements.features["advanced_template_scheduling"].enabled + const validationSchema = getValidationSchema() const form: FormikContextType = useFormik({ @@ -88,6 +99,9 @@ export const TemplateSettingsForm: FC = ({ description: template.description, // on display, convert from ms => hours default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION, + // the API ignores this value, but to avoid tripping up validation set + // it to zero if the user can't set the field. + max_ttl_ms: canSetMaxTTL ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0, icon: template.icon, allow_user_cancel_workspace_jobs: template.allow_user_cancel_workspace_jobs, @@ -100,6 +114,9 @@ export const TemplateSettingsForm: FC = ({ default_ttl_ms: formData.default_ttl_ms ? formData.default_ttl_ms * MS_HOUR_CONVERSION : undefined, + max_ttl_ms: formData.max_ttl_ms + ? formData.max_ttl_ms * MS_HOUR_CONVERSION + : undefined, }) }, initialTouched, @@ -172,7 +189,7 @@ export const TemplateSettingsForm: FC = ({ , + , )} disabled={isSubmitting} fullWidth @@ -181,6 +198,21 @@ export const TemplateSettingsForm: FC = ({ variant="outlined" type="number" /> + + + , + )} + disabled={isSubmitting} + fullWidth + inputProps={{ min: 0, step: 1 }} + label={t("defaultTtlLabel")} + variant="outlined" + type="number" + /> + } @@ -372,6 +373,7 @@ export const createTemplateMachine = const { default_ttl_hours, + max_ttl_hours, parameter_values_by_name, ...safeTemplateData } = templateData @@ -379,6 +381,7 @@ export const createTemplateMachine = return createTemplate(organizationId, { ...safeTemplateData, default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms + max_ttl_ms: templateData.max_ttl_hours * 60 * 60 * 1000, // Convert hours to ms template_version_id: version.id, }) }, From 96fd84050f5adc41de667ec615fe248f52f0faeb Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 20 Feb 2023 15:25:39 +0000 Subject: [PATCH 05/24] working CLI --- cli/templateedit.go | 29 ++- cli/templateedit_test.go | 211 ++++++++++++++++++ .../coder_templates_edit_--help.golden | 15 +- .../CreateTemplatePage/CreateTemplateForm.tsx | 8 +- .../TemplateSettingsForm.tsx | 27 ++- .../TemplateSettingsPage.test.tsx | 84 ++++++- site/src/testHelpers/entities.ts | 1 + 7 files changed, 348 insertions(+), 27 deletions(-) diff --git a/cli/templateedit.go b/cli/templateedit.go index 5a99634511520..1e487fa8000c2 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "net/http" "time" "github.com/spf13/cobra" @@ -18,6 +19,7 @@ func templateEdit() *cobra.Command { description string icon string defaultTTL time.Duration + maxTTL time.Duration allowUserCancelWorkspaceJobs bool ) @@ -30,6 +32,21 @@ func templateEdit() *cobra.Command { if err != nil { return xerrors.Errorf("create client: %w", err) } + + if maxTTL != 0 { + entitlements, err := client.Entitlements(cmd.Context()) + var sdkErr *codersdk.Error + if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl") + } else if err != nil { + return xerrors.Errorf("get entitlements: %w", err) + } + + if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { + return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl") + } + } + organization, err := CurrentOrganization(cmd, client) if err != nil { return xerrors.Errorf("get current organization: %w", err) @@ -46,6 +63,7 @@ func templateEdit() *cobra.Command { Description: description, Icon: icon, DefaultTTLMillis: defaultTTL.Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, } @@ -58,11 +76,12 @@ func templateEdit() *cobra.Command { }, } - cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name") - cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name") - cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description") - cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path") - cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template to this value.") + cmd.Flags().StringVarP(&name, "name", "", "", "Edit the template name.") + cmd.Flags().StringVarP(&displayName, "display-name", "", "", "Edit the template display name.") + cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description.") + cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path.") + cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template default to this value.") + cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 0, "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.") cmd.Flags().BoolVarP(&allowUserCancelWorkspaceJobs, "allow-user-cancel-workspace-jobs", "", true, "Allow users to cancel in-progress workspace jobs.") cliui.AllowSkipPrompt(cmd) diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 28fe0edabf8c5..4361985feaddd 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -1,8 +1,17 @@ package cli_test import ( + "bytes" "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" "strconv" + "strings" + "sync/atomic" "testing" "time" @@ -11,6 +20,7 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) @@ -230,4 +240,205 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, "", updated.Icon) assert.Equal(t, "", updated.DisplayName) }) + t.Run("MaxTTL", func(t *testing.T) { + t.Parallel() + t.Run("BlockedAGPL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: 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, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--max-ttl", "1h", + } + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + ctx, _ := testutil.Context(t) + err := cmd.ExecuteContext(ctx) + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + }) + + t.Run("BlockedNotEntitled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: 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, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // Make a proxy server that will return a valid entitlements + // response, but without advanced scheduling entitlement. + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/entitlements" { + res := codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + Warnings: []string{}, + Errors: []string{}, + HasLicense: true, + Trial: true, + RequireTelemetry: false, + Experimental: false, + } + for _, feature := range codersdk.FeatureNames { + res.Features[feature] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: false, + Limit: nil, + Actual: nil, + } + } + httpapi.Write(r.Context(), w, http.StatusOK, res) + return + } + + // Otherwise, proxy the request to the real API server. + httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + })) + defer proxy.Close() + + // Create a new client that uses the proxy server. + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxyClient := codersdk.New(proxyURL) + proxyClient.SetSessionToken(client.SessionToken()) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--max-ttl", "1h", + } + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx, _ := testutil.Context(t) + err = cmd.ExecuteContext(ctx) + require.Error(t, err) + require.ErrorContains(t, err, "license is not entitled") + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + }) + t.Run("Entitled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: 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, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.MaxTTLMillis = nil + }) + + // Make a proxy server that will return a valid entitlements + // response, including a valid advanced scheduling entitlement. + var updateTemplateCalled int64 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/entitlements" { + res := codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + Warnings: []string{}, + Errors: []string{}, + HasLicense: true, + Trial: true, + RequireTelemetry: false, + Experimental: false, + } + for _, feature := range codersdk.FeatureNames { + var one int64 = 1 + res.Features[feature] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: true, + Limit: &one, + Actual: &one, + } + } + httpapi.Write(r.Context(), w, http.StatusOK, res) + return + } + if strings.HasPrefix(r.URL.Path, "/api/v2/templates/") { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + _ = r.Body.Close() + + var req codersdk.UpdateTemplateMeta + err = json.Unmarshal(body, &req) + require.NoError(t, err) + assert.Equal(t, time.Hour.Milliseconds(), req.MaxTTLMillis) + + r.Body = io.NopCloser(bytes.NewReader(body)) + atomic.AddInt64(&updateTemplateCalled, 1) + // We still want to call the real route. + } + + // Otherwise, proxy the request to the real API server. + httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + })) + defer proxy.Close() + + // Create a new client that uses the proxy server. + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxyClient := codersdk.New(proxyURL) + proxyClient.SetSessionToken(client.SessionToken()) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--max-ttl", "1h", + } + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx, _ := testutil.Context(t) + err = cmd.ExecuteContext(ctx) + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled)) + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + }) + }) } diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index f92ba830ad7c6..5bcc48e9a5ae3 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -7,12 +7,17 @@ Flags: --allow-user-cancel-workspace-jobs Allow users to cancel in-progress workspace jobs. (default true) --default-ttl duration Edit the template default time before shutdown - - workspaces created from this template to this value. - --description string Edit the template description - --display-name string Edit the template display name + workspaces created from this template default to + this value. + --description string Edit the template description. + --display-name string Edit the template display name. -h, --help help for edit - --icon string Edit the template icon path - --name string Edit the template name + --icon string Edit the template icon path. + --max-ttl duration Edit the template maximum time before shutdown - + workspaces created from this template must shutdown + within the given duration after starting. This is + an enterprise-only feature. + --name string Edit the template name. -y, --yes Bypass prompts Global Flags: diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 1a274b232b680..cc389bfa4558d 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -50,7 +50,10 @@ const defaultInitialValues: CreateTemplateData = { parameter_values_by_name: undefined, } -const getInitialValues = (canSetMaxTTL: boolean, starterTemplate?: TemplateExample) => { +const getInitialValues = ( + canSetMaxTTL: boolean, + starterTemplate?: TemplateExample, +) => { let initialValues = defaultInitialValues if (!canSetMaxTTL) { initialValues = { @@ -97,7 +100,8 @@ export const CreateTemplateForm: FC = ({ const styles = useStyles() const formFooterStyles = useFormFooterStyles() const { entitlements } = useDashboard() - const canSetMaxTTL = entitlements.features["advanced_template_scheduling"].enabled + const canSetMaxTTL = + entitlements.features["advanced_template_scheduling"].enabled const form = useFormik({ initialValues: getInitialValues(canSetMaxTTL, starterTemplate), diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx index 46a0c42ed96e0..7cea8f375aa33 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx @@ -25,13 +25,19 @@ import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { makeStyles } from "@material-ui/core/styles" import { useDashboard } from "components/Dashboard/DashboardProvider" -+const TTLHelperText = ({ ttl, translationName }: { ttl?: number, translationName: string }) => { +const TTLHelperText = ({ + ttl, + translationName, +}: { + ttl?: number + translationName: string +}) => { const { t } = useTranslation("templateSettingsPage") const count = typeof ttl !== "number" ? 0 : ttl return ( // no helper text if ttl is negative - error will show once field is considered touched = 0}> -+ {t(translationName, { count })} + {t(translationName, { count })} ) } @@ -88,7 +94,8 @@ export const TemplateSettingsForm: FC = ({ initialTouched, }) => { const { entitlements } = useDashboard() - const canSetMaxTTL = entitlements.features["advanced_template_scheduling"].enabled + const canSetMaxTTL = + entitlements.features["advanced_template_scheduling"].enabled const validationSchema = getValidationSchema() const form: FormikContextType = @@ -189,7 +196,10 @@ export const TemplateSettingsForm: FC = ({ , + , )} disabled={isSubmitting} fullWidth @@ -202,13 +212,16 @@ export const TemplateSettingsForm: FC = ({ , + "max_ttl_ms", + , )} disabled={isSubmitting} fullWidth inputProps={{ min: 0, step: 1 }} - label={t("defaultTtlLabel")} + label={t("maxTtlLabel")} variant="outlined" type="number" /> diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx index 94d227e67d4b9..2f100e10aa18a 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx @@ -1,13 +1,15 @@ import { screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import * as API from "api/api" -import { UpdateTemplateMeta } from "api/typesGenerated" +import { Entitlements, UpdateTemplateMeta } from "api/typesGenerated" import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" -import { MockTemplate } from "../../testHelpers/entities" +import { MockEntitlements, MockTemplate } from "../../testHelpers/entities" import { renderWithAuth } from "../../testHelpers/renderHelpers" +import { server } from "../../testHelpers/server" import { getValidationSchema } from "./TemplateSettingsForm" import { TemplateSettingsPage } from "./TemplateSettingsPage" import i18next from "i18next" +import { rest } from "msw" const { t } = i18next @@ -27,7 +29,9 @@ const validFormValues = { display_name: "A display name", description: "A description", icon: "vscode.png", + // these are the form values which are actually hours default_ttl_ms: 1, + max_ttl_ms: 2, allow_user_cancel_workspace_jobs: false, } @@ -36,6 +40,7 @@ const fillAndSubmitForm = async ({ display_name, description, default_ttl_ms, + max_ttl_ms, icon, allow_user_cancel_workspace_jobs, }: Required) => { @@ -61,9 +66,17 @@ const fillAndSubmitForm = async ({ await userEvent.type(iconField, icon) const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" }) - const maxTtlField = await screen.findByLabelText(defaultTtlLabel) - await userEvent.clear(maxTtlField) - await userEvent.type(maxTtlField, default_ttl_ms.toString()) + const defaultTtlField = await screen.findByLabelText(defaultTtlLabel) + await userEvent.clear(defaultTtlField) + await userEvent.type(defaultTtlField, default_ttl_ms.toString()) + + const entitlements = await API.getEntitlements() + if (entitlements.features["advanced_template_scheduling"].enabled) { + const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" }) + const maxTtlField = await screen.findByLabelText(maxTtlLabel) + await userEvent.clear(maxTtlField) + await userEvent.type(maxTtlField, max_ttl_ms.toString()) + } const allowCancelJobsField = screen.getByRole("checkbox") // checkbox is checked by default, so it must be clicked to get unchecked @@ -109,7 +122,60 @@ describe("TemplateSettingsPage", () => { }) await fillAndSubmitForm(validFormValues) - expect(screen.getByDisplayValue(1)).toBeInTheDocument() // the default_ttl_ms + expect( + screen.getByDisplayValue(validFormValues.default_ttl_ms), + ).toBeInTheDocument() + expect( + screen.queryAllByDisplayValue(validFormValues.max_ttl_ms), + ).toHaveLength(0) + await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) + + await waitFor(() => + expect(API.updateTemplateMeta).toBeCalledWith( + "test-template", + expect.objectContaining({ + ...validFormValues, + // convert from the display value (hours) to ms + default_ttl_ms: validFormValues.default_ttl_ms * 3600000, + // this value is undefined if not entitled + max_ttl_ms: undefined, + }), + ), + ) + }) + + test("max ttl shows if entitled", async () => { + const entitlements: Entitlements = { + ...MockEntitlements, + has_license: true, + features: { + ...MockEntitlements.features, + advanced_template_scheduling: { + entitlement: "entitled", + enabled: true, + }, + }, + } + server.use( + rest.get("/api/v2/entitlements", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(entitlements)) + }), + ) + + await renderTemplateSettingsPage() + + jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ + ...MockTemplate, + ...validFormValues, + }) + + await fillAndSubmitForm(validFormValues) + expect( + screen.getByDisplayValue(validFormValues.default_ttl_ms), + ).toBeInTheDocument() + expect( + screen.getByDisplayValue(validFormValues.max_ttl_ms), + ).toBeInTheDocument() await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) await waitFor(() => @@ -117,7 +183,9 @@ describe("TemplateSettingsPage", () => { "test-template", expect.objectContaining({ ...validFormValues, - default_ttl_ms: 3600000, // the default_ttl_ms to ms + // convert from the display value (hours) to ms + default_ttl_ms: validFormValues.default_ttl_ms * 3600000, + max_ttl_ms: validFormValues.max_ttl_ms * 3600000, }), ), ) @@ -148,7 +216,7 @@ describe("TemplateSettingsPage", () => { } const validate = () => getValidationSchema().validateSync(values) expect(validate).toThrowError( - t("ttlMaxError", { ns: "templateSettingsPage" }), + t("defaultTTLMaxError", { ns: "templateSettingsPage" }), ) }) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8876bd169a7a2..049ea658d4ef9 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -266,6 +266,7 @@ export const MockTemplate: TypesGen.Template = { }, description: "This is a test description.", default_ttl_ms: 24 * 60 * 60 * 1000, + max_ttl_ms: 2 * 24 * 60 * 60 * 1000, created_by_id: "test-creator-id", created_by_name: "test_creator", icon: "/icon/code.svg", From 4e1d948df80eab6abc37befc45d6980a482281fa Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 23 Feb 2023 16:08:56 +0000 Subject: [PATCH 06/24] fixup! Merge branch 'main' into dean/schedule-max-ttl --- docs/cli/coder_templates_edit.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/cli/coder_templates_edit.md b/docs/cli/coder_templates_edit.md index e9f59bc9c26bb..60a8b18c6b804 100644 --- a/docs/cli/coder_templates_edit.md +++ b/docs/cli/coder_templates_edit.md @@ -22,7 +22,7 @@ Allow users to cancel in-progress workspace jobs. ### --default-ttl -Edit the template default time before shutdown - workspaces created from this template to this value. +Edit the template default time before shutdown - workspaces created from this template default to this value.
| | | | --- | --- | @@ -30,28 +30,36 @@ Edit the template default time before shutdown - workspaces created from this te ### --description -Edit the template description +Edit the template description.
| | | | --- | --- | ### --display-name -Edit the template display name +Edit the template display name.
| | | | --- | --- | ### --icon -Edit the template icon path +Edit the template icon path.
| | | | --- | --- | +### --max-ttl + +Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature. +
+| | | +| --- | --- | +| Default | 0s | + ### --name -Edit the template name +Edit the template name.
| | | | --- | --- | From 69035a6915fa37ff479e70728c541bb0b2aeb796 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 23 Feb 2023 17:25:32 +0000 Subject: [PATCH 07/24] feat: block disabling auto off if template has max ttl --- .../provisionerdserver/provisionerdserver.go | 6 +- coderd/workspaces.go | 42 ++++++- coderd/workspaces_test.go | 2 +- enterprise/coderd/templates_test.go | 114 ++++++++++++++++++ 4 files changed, 154 insertions(+), 10 deletions(-) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 964a921d19f10..0e7e8c57dab1c 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -308,7 +308,7 @@ func (server *Server) includeLastVariableValues(ctx context.Context, templateVer templateVersion, err := server.Database.GetTemplateVersionByID(ctx, templateVersionID) if err != nil { - return nil, fmt.Errorf("get template version: %w", err) + return nil, xerrors.Errorf("get template version: %w", err) } if templateVersion.TemplateID.UUID == uuid.Nil { @@ -317,7 +317,7 @@ func (server *Server) includeLastVariableValues(ctx context.Context, templateVer template, err := server.Database.GetTemplateByID(ctx, templateVersion.TemplateID.UUID) if err != nil { - return nil, fmt.Errorf("get template: %w", err) + return nil, xerrors.Errorf("get template: %w", err) } if template.ActiveVersionID == uuid.Nil { @@ -326,7 +326,7 @@ func (server *Server) includeLastVariableValues(ctx context.Context, templateVer templateVariables, err := server.Database.GetTemplateVersionVariables(ctx, template.ActiveVersionID) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("get template version variables: %w", err) + return nil, xerrors.Errorf("get template version variables: %w", err) } for _, templateVariable := range templateVariables { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 929edaad90fb3..9507792672956 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -362,7 +362,16 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, template.DefaultTTL) + templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, template.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template schedule.", + Detail: err.Error(), + }) + return + } + + dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, templateSchedule.MaxTTL) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid Workspace Time to Shutdown.", @@ -799,9 +808,15 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { var dbTTL sql.NullInt64 err := api.Database.InTx(func(s database.Store) error { + templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, s, workspace.TemplateID) + if err != nil { + return xerrors.Errorf("get template schedule: %w", err) + } + + // don't override 0 ttl with template default here because it indicates + // disabled auto-stop var validityErr error - // don't override 0 ttl with template default here because it indicates disabled auto-stop - dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0) + dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, templateSchedule.MaxTTL) if validityErr != nil { return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()} } @@ -1187,14 +1202,25 @@ func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 { return &millis } -func validWorkspaceTTLMillis(millis *int64, def int64) (sql.NullInt64, error) { +func validWorkspaceTTLMillis(millis *int64, templateDefault, templateMax time.Duration) (sql.NullInt64, error) { + if templateDefault == 0 && templateMax != 0 || (templateMax > 0 && templateDefault > templateMax) { + templateDefault = templateMax + } + if ptr.NilOrZero(millis) { - if def == 0 { + if templateDefault == 0 { + if templateMax > 0 { + return sql.NullInt64{ + Int64: int64(templateMax), + Valid: true, + }, nil + } + return sql.NullInt64{}, nil } return sql.NullInt64{ - Int64: def, + Int64: int64(templateDefault), Valid: true, }, nil } @@ -1209,6 +1235,10 @@ func validWorkspaceTTLMillis(millis *int64, def int64) (sql.NullInt64, error) { return sql.NullInt64{}, errTTLMax } + if templateMax > 0 && truncated > templateMax { + return sql.NullInt64{}, xerrors.Errorf("time until shutdown must be less than or equal to the template's maximum TTL %q", templateMax.String()) + } + return sql.NullInt64{ Valid: true, Int64: int64(truncated), diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 2b1c7dfe8484c..93c38941c6ba7 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -331,7 +331,7 @@ func TestPostWorkspacesByOrganization(t *testing.T) { }) // TTL should be set by the template require.Equal(t, template.DefaultTTLMillis, templateTTL) - require.Equal(t, template.DefaultTTLMillis, template.DefaultTTLMillis, workspace.TTLMillis) + require.Equal(t, template.DefaultTTLMillis, *workspace.TTLMillis) }) t.Run("InvalidTTL", func(t *testing.T) { diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index c379675edff2d..3157a33ddc4b8 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -58,6 +58,120 @@ func TestTemplates(t *testing.T) { require.NoError(t, err) require.Equal(t, 2*time.Hour, time.Duration(template.MaxTTLMillis)*time.Millisecond) }) + + t.Run("CreateUpdateWorkspaceMaxTTL", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + exp := 24 * time.Hour.Milliseconds() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = &exp + ctr.MaxTTLMillis = &exp + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // No TTL provided should use template default + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + } + ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + + // Editing a workspace to have a higher TTL than the template's max + // should error + exp = exp + time.Minute.Milliseconds() + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: &exp, + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") + require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") + + // Creating workspace with TTL higher than max should error + req.Name = "testing2" + req.TTLMillis = &exp + ws, err = client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) + require.Error(t, err) + apiErr = nil + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") + require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") + }) + + t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + exp := 24 * time.Hour.Milliseconds() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.MaxTTLMillis = &exp + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // No TTL provided should use template default + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + } + ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + + // Editing a workspace to disable the TTL should do nothing + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: nil, + }) + require.NoError(t, err) + ws, err = client.Workspace(ctx, ws.ID) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + + // Editing a workspace to have a TTL of 0 should do nothing + zero := int64(0) + err = client.UpdateWorkspaceTTL(ctx, ws.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: &zero, + }) + require.NoError(t, err) + ws, err = client.Workspace(ctx, ws.ID) + require.NoError(t, err) + require.EqualValues(t, exp, *ws.TTLMillis) + }) } func TestTemplateACL(t *testing.T) { From 89ccfc8f488739f0fcfa3d225206221fdf579233 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 1 Mar 2023 14:56:30 +0000 Subject: [PATCH 08/24] fixup! Merge branch 'main' into dean/schedule-max-ttl --- ...line.down.sql => 000102_workspace_build_max_deadline.down.sql} | 0 ...deadline.up.sql => 000102_workspace_build_max_deadline.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000100_workspace_build_max_deadline.down.sql => 000102_workspace_build_max_deadline.down.sql} (100%) rename coderd/database/migrations/{000100_workspace_build_max_deadline.up.sql => 000102_workspace_build_max_deadline.up.sql} (100%) diff --git a/coderd/database/migrations/000100_workspace_build_max_deadline.down.sql b/coderd/database/migrations/000102_workspace_build_max_deadline.down.sql similarity index 100% rename from coderd/database/migrations/000100_workspace_build_max_deadline.down.sql rename to coderd/database/migrations/000102_workspace_build_max_deadline.down.sql diff --git a/coderd/database/migrations/000100_workspace_build_max_deadline.up.sql b/coderd/database/migrations/000102_workspace_build_max_deadline.up.sql similarity index 100% rename from coderd/database/migrations/000100_workspace_build_max_deadline.up.sql rename to coderd/database/migrations/000102_workspace_build_max_deadline.up.sql From feb7b3cb790e0e621ad6748460cab23ca450ec9c Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 1 Mar 2023 16:30:06 +0000 Subject: [PATCH 09/24] feat: apply template max TTL to workspace TTL on update --- coderd/database/dbauthz/querier.go | 7 + coderd/database/dbfake/databasefake.go | 21 ++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 25 ++ coderd/database/queries/workspaces.sql | 14 + coderd/database/sqlc.yaml | 1 + .../provisionerdserver_test.go | 299 +++++++++++++----- enterprise/coderd/provisionerdaemons.go | 27 +- enterprise/coderd/templates_test.go | 33 +- 9 files changed, 352 insertions(+), 76 deletions(-) diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 2005faec5d527..b9b2bd4669317 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -1499,6 +1499,13 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) } +func (q *querier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.TemplateID) + } + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspaceTTLToBeWithinTemplateMax)(ctx, arg) +} + func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWorkspaceTTLParams) error { fetch := func(ctx context.Context, arg database.UpdateWorkspaceTTLParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 8da322627dfab..355a20092c062 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -3515,6 +3515,26 @@ func (q *fakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database. return sql.ErrNoRows } +func (q *fakeQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(_ context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspace := range q.workspaces { + if workspace.TemplateID != arg.TemplateID || !workspace.Ttl.Valid || workspace.Ttl.Int64 < arg.TemplateMaxTTL { + continue + } + + workspace.Ttl = sql.NullInt64{Int64: arg.TemplateMaxTTL, Valid: true} + q.workspaces[index] = workspace + } + + return nil +} + func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.UpdateWorkspaceBuildByIDParams) (database.WorkspaceBuild, error) { if err := validateDatabaseType(arg); err != nil { return database.WorkspaceBuild{}, err @@ -3530,6 +3550,7 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U workspaceBuild.UpdatedAt = arg.UpdatedAt workspaceBuild.ProvisionerState = arg.ProvisionerState workspaceBuild.Deadline = arg.Deadline + workspaceBuild.MaxDeadline = arg.MaxDeadline q.workspaceBuilds[index] = workspaceBuild return workspaceBuild, nil } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f894b4e23f872..18b3c329642e9 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -211,6 +211,7 @@ type sqlcQuerier interface { UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error + UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error } var _ sqlcQuerier = (*sqlQuerier)(nil) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ed88196786950..e606845d90055 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7281,3 +7281,28 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace _, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl) return err } + +const updateWorkspaceTTLToBeWithinTemplateMax = `-- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec +UPDATE + workspaces +SET + ttl = LEAST(ttl, $1::bigint) +WHERE + template_id = $2 + -- LEAST() does not pick NULL, so filter it out as we don't want to set a + -- TTL on the workspace if it's unset. + -- + -- During build time, the template max TTL will still be used if the + -- workspace TTL is NULL. + AND ttl IS NOT NULL +` + +type UpdateWorkspaceTTLToBeWithinTemplateMaxParams struct { + TemplateMaxTTL int64 `db:"template_max_ttl" json:"template_max_ttl"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` +} + +func (q *sqlQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceTTLToBeWithinTemplateMax, arg.TemplateMaxTTL, arg.TemplateID) + return err +} diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index def4436bed94c..3beedc6932089 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -316,3 +316,17 @@ SET last_used_at = $2 WHERE id = $1; + +-- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec +UPDATE + workspaces +SET + ttl = LEAST(ttl, @template_max_ttl::bigint) +WHERE + template_id = @template_id + -- LEAST() does not pick NULL, so filter it out as we don't want to set a + -- TTL on the workspace if it's unset. + -- + -- During build time, the template max TTL will still be used if the + -- workspace TTL is NULL. + AND ttl IS NOT NULL; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 5cf4037ffc82a..fbe058988e2c7 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -43,6 +43,7 @@ overrides: troubleshooting_url: TroubleshootingURL default_ttl: DefaultTTL max_ttl: MaxTTL + template_max_ttl: TemplateMaxTTL motd_file: MOTDFile uuid: UUID diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index a6e6614fe295f..8bc367ae1d3fa 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -792,89 +792,226 @@ func TestCompleteJob(t *testing.T) { require.NoError(t, err) require.False(t, job.Error.Valid) }) + t.Run("WorkspaceBuild", func(t *testing.T) { t.Parallel() - srv := setup(t, false) - user := dbgen.User(t, srv.Database, database.User{}) - template := dbgen.Template(t, srv.Database, database.Template{ - Name: "template", - Provisioner: database.ProvisionerTypeEcho, - }) - file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID}) - workspace, err := srv.Database.InsertWorkspace(ctx, database.InsertWorkspaceParams{ - ID: uuid.New(), - TemplateID: template.ID, - }) - version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{ - TemplateID: uuid.NullUUID{ - UUID: template.ID, - Valid: true, + cases := []struct { + name string + templateDefaultTTL time.Duration + templateMaxTTL time.Duration + workspaceTTL time.Duration + transition database.WorkspaceTransition + // The TTL is actually a deadline time on the workspace_build row, + // so during the test this will be compared to be within 15 seconds + // of the expected value. + expectedTTL time.Duration + expectedMaxTTL time.Duration + }{ + { + name: "OK", + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 0, + expectedMaxTTL: 0, }, - JobID: uuid.New(), - }) - require.NoError(t, err) - build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ - ID: uuid.New(), - WorkspaceID: workspace.ID, - TemplateVersionID: version.ID, - Transition: database.WorkspaceTransitionDelete, - Reason: database.BuildReasonInitiator, - }) - require.NoError(t, err) - job, err := srv.Database.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ - ID: uuid.New(), - FileID: file.ID, - Provisioner: database.ProvisionerTypeEcho, - Type: database.ProvisionerJobTypeWorkspaceBuild, - StorageMethod: database.ProvisionerStorageMethodFile, - Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ - WorkspaceBuildID: build.ID, - })), - }) - require.NoError(t, err) - _, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ - WorkerID: uuid.NullUUID{ - UUID: srv.ID, - Valid: true, + { + name: "Delete", + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionDelete, + expectedTTL: 0, + expectedMaxTTL: 0, }, - Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, - }) - require.NoError(t, err) + { + name: "WorkspaceTTL", + templateDefaultTTL: 0, + templateMaxTTL: 0, + workspaceTTL: time.Hour, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: 0, + }, + { + name: "TemplateDefaultTTL", + templateDefaultTTL: time.Hour, + templateMaxTTL: 0, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: 0, + }, + { + name: "WorkspaceTTLOverridesTemplateDefaultTTL", + templateDefaultTTL: 2 * time.Hour, + templateMaxTTL: 0, + workspaceTTL: time.Hour, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: 0, + }, + { + name: "TemplateMaxTTL", + templateDefaultTTL: 0, + templateMaxTTL: time.Hour, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: time.Hour, + expectedMaxTTL: time.Hour, + }, + { + name: "TemplateMaxTTLOverridesWorkspaceTTL", + templateDefaultTTL: 0, + templateMaxTTL: 2 * time.Hour, + workspaceTTL: 3 * time.Hour, + transition: database.WorkspaceTransitionStart, + expectedTTL: 2 * time.Hour, + expectedMaxTTL: 2 * time.Hour, + }, + { + name: "TemplateMaxTTLOverridesTemplateDefaultTTL", + templateDefaultTTL: 3 * time.Hour, + templateMaxTTL: 2 * time.Hour, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedTTL: 2 * time.Hour, + expectedMaxTTL: 2 * time.Hour, + }, + } - publishedWorkspace := make(chan struct{}) - closeWorkspaceSubscribe, err := srv.Pubsub.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) { - close(publishedWorkspace) - }) - require.NoError(t, err) - defer closeWorkspaceSubscribe() - publishedLogs := make(chan struct{}) - closeLogsSubscribe, err := srv.Pubsub.Subscribe(provisionerdserver.ProvisionerJobLogsNotifyChannel(job.ID), func(_ context.Context, _ []byte) { - close(publishedLogs) - }) - require.NoError(t, err) - defer closeLogsSubscribe() + for _, c := range cases { + c := c - _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ - JobId: job.ID.String(), - Type: &proto.CompletedJob_WorkspaceBuild_{ - WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ - State: []byte{}, - Resources: []*sdkproto.Resource{{ - Name: "example", - Type: "aws_instance", - }}, - }, - }, - }) - require.NoError(t, err) + t.Run(c.name, func(t *testing.T) { + t.Parallel() - <-publishedWorkspace - <-publishedLogs + srv := setup(t, false) - workspace, err = srv.Database.GetWorkspaceByID(ctx, workspace.ID) - require.NoError(t, err) - require.True(t, workspace.Deleted) + var store provisionerdserver.TemplateScheduleStore = mockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { + return provisionerdserver.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: c.templateDefaultTTL, + MaxTTL: c.templateMaxTTL, + }, nil + }, + } + srv.TemplateScheduleStore.Store(&store) + + user := dbgen.User(t, srv.Database, database.User{}) + template := dbgen.Template(t, srv.Database, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + }) + template, err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + DefaultTTL: int64(c.templateDefaultTTL), + MaxTTL: int64(c.templateMaxTTL), + }) + require.NoError(t, err) + file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID}) + workspaceTTL := sql.NullInt64{} + if c.workspaceTTL != 0 { + workspaceTTL = sql.NullInt64{ + Int64: int64(c.workspaceTTL), + Valid: true, + } + } + workspace, err := srv.Database.InsertWorkspace(ctx, database.InsertWorkspaceParams{ + ID: uuid.New(), + TemplateID: template.ID, + Ttl: workspaceTTL, + }) + version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: uuid.New(), + }) + require.NoError(t, err) + build, err := srv.Database.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + ID: uuid.New(), + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + Transition: c.transition, + Reason: database.BuildReasonInitiator, + }) + require.NoError(t, err) + job, err := srv.Database.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: uuid.New(), + FileID: file.ID, + Provisioner: database.ProvisionerTypeEcho, + Type: database.ProvisionerJobTypeWorkspaceBuild, + StorageMethod: database.ProvisionerStorageMethodFile, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + })), + }) + require.NoError(t, err) + _, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + WorkerID: uuid.NullUUID{ + UUID: srv.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + publishedWorkspace := make(chan struct{}) + closeWorkspaceSubscribe, err := srv.Pubsub.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) { + close(publishedWorkspace) + }) + require.NoError(t, err) + defer closeWorkspaceSubscribe() + publishedLogs := make(chan struct{}) + closeLogsSubscribe, err := srv.Pubsub.Subscribe(provisionerdserver.ProvisionerJobLogsNotifyChannel(job.ID), func(_ context.Context, _ []byte) { + close(publishedLogs) + }) + require.NoError(t, err) + defer closeLogsSubscribe() + + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ + State: []byte{}, + Resources: []*sdkproto.Resource{{ + Name: "example", + Type: "aws_instance", + }}, + }, + }, + }) + require.NoError(t, err) + + <-publishedWorkspace + <-publishedLogs + + workspace, err = srv.Database.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, c.transition == database.WorkspaceTransitionDelete, workspace.Deleted) + + workspaceBuild, err := srv.Database.GetWorkspaceBuildByID(ctx, build.ID) + require.NoError(t, err) + + if c.expectedTTL == 0 { + require.True(t, workspaceBuild.Deadline.IsZero()) + } else { + require.WithinDuration(t, time.Now().Add(c.expectedTTL), workspaceBuild.Deadline, 15*time.Second, "deadline does not match expected") + } + if c.expectedMaxTTL == 0 { + require.True(t, workspaceBuild.MaxDeadline.IsZero()) + } else { + require.WithinDuration(t, time.Now().Add(c.expectedMaxTTL), workspaceBuild.MaxDeadline, 15*time.Second, "max deadline does not match expected") + require.GreaterOrEqual(t, workspaceBuild.MaxDeadline.Unix(), workspaceBuild.Deadline.Unix(), "max deadline is smaller than deadline") + } + }) + } }) t.Run("TemplateDryRun", func(t *testing.T) { @@ -1028,3 +1165,17 @@ func must[T any](value T, err error) T { } return value } + +type mockTemplateScheduleStore struct { + GetFn func(ctx context.Context, db database.Store, id uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) +} + +var _ provisionerdserver.TemplateScheduleStore = mockTemplateScheduleStore{} + +func (mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts provisionerdserver.TemplateScheduleOptions) (database.Template, error) { + return provisionerdserver.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, opts) +} + +func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, id uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { + return m.GetFn(ctx, db, id) +} diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 9cfaa2c9e3f3a..caddf10292e8d 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -323,10 +323,35 @@ func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C } func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts provisionerdserver.TemplateScheduleOptions) (database.Template, error) { - return db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ ID: tpl.ID, UpdatedAt: database.Now(), DefaultTTL: int64(opts.DefaultTTL), MaxTTL: int64(opts.MaxTTL), }) + if err != nil { + return database.Template{}, xerrors.Errorf("update template schedule: %w", err) + } + + // Update all workspaces using the template to set the user defined schedule + // to be within the new bounds. This essentially does the following for each + // workspace using the template. + // if (template.ttl != NULL) { + // workspace.ttl = min(workspace.ttl, template.ttl) + // } + // + // NOTE: this does not apply to currently running workspaces as their + // schedule information is committed to the workspace_build during start. + // This limitation is displayed to the user while editing the template. + if opts.MaxTTL > 0 { + err = db.UpdateWorkspaceTTLToBeWithinTemplateMax(ctx, database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams{ + TemplateID: template.ID, + TemplateMaxTTL: int64(opts.MaxTTL), + }) + if err != nil { + return database.Template{}, xerrors.Errorf("update TTL of all workspaces on template to be within new template max TTL: %w", err) + } + } + + return template, nil } diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 3157a33ddc4b8..b2e240ab2ae7d 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -28,7 +28,11 @@ func TestTemplates(t *testing.T) { t.Run("SetMaxTTL", func(t *testing.T) { t.Parallel() - client := coderdenttest.New(t, nil) + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) user := coderdtest.CreateFirstUser(t, client) _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ Features: license.Features{ @@ -37,9 +41,23 @@ func TestTemplates(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) require.EqualValues(t, 0, template.MaxTTLMillis) + // Create some workspaces to test propagation to user-defined TTLs. + workspace1 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + ttl := (24 * time.Hour).Milliseconds() + cwr.TTLMillis = &ttl + }) + workspace2TTL := (1 * time.Hour).Milliseconds() + workspace2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = &workspace2TTL + }) + workspace3 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = nil + }) + ctx, _ := testutil.Context(t) updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ @@ -57,6 +75,19 @@ func TestTemplates(t *testing.T) { template, err = client.Template(ctx, template.ID) require.NoError(t, err) require.Equal(t, 2*time.Hour, time.Duration(template.MaxTTLMillis)*time.Millisecond) + + // Verify that only the first workspace has been updated. + workspace1, err = client.Workspace(ctx, workspace1.ID) + require.NoError(t, err) + require.Equal(t, &template.MaxTTLMillis, workspace1.TTLMillis) + + workspace2, err = client.Workspace(ctx, workspace2.ID) + require.NoError(t, err) + require.Equal(t, &workspace2TTL, workspace2.TTLMillis) + + workspace3, err = client.Workspace(ctx, workspace3.ID) + require.NoError(t, err) + require.Nil(t, workspace3.TTLMillis) }) t.Run("CreateUpdateWorkspaceMaxTTL", func(t *testing.T) { From d54d7983e4c2439c48eaa7117f9ee06cdfe19ab7 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 1 Mar 2023 17:11:51 +0000 Subject: [PATCH 10/24] chore: fix tests and differences between sql and memory db --- cli/schedule_test.go | 7 +++++-- coderd/provisionerdserver/provisionerdserver.go | 3 --- .../provisionerdserver_test.go | 4 ++-- coderd/templates.go | 16 +++++++--------- enterprise/coderd/templates_test.go | 10 ++++++---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 8fc7c9b50b6c8..cd30de7d7f551 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -308,13 +308,16 @@ func TestScheduleOverride(t *testing.T) { user = coderdtest.CreateFirstUser(t, client) 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, func(cwr *codersdk.CreateWorkspaceRequest) { + template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.TTLMillis = nil }) cmdArgs = []string{"schedule", "override-stop", workspace.Name, "1h"} stdoutBuf = &bytes.Buffer{} ) + require.Zero(t, template.DefaultTTLMillis) + require.Zero(t, template.MaxTTLMillis) + // Unset the workspace TTL err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) require.NoError(t, err) diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index dc98ba5e5127e..b7538abd0e5df 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -917,9 +917,6 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete // The user is not permitted to set their own TTL. deadline = time.Time{} } - if deadline.IsZero() && templateSchedule.DefaultTTL > 0 { - deadline = now.Add(templateSchedule.DefaultTTL) - } if templateSchedule.MaxTTL > 0 { maxDeadline = now.Add(templateSchedule.MaxTTL) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 8bc367ae1d3fa..518be8e25c55c 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -836,12 +836,12 @@ func TestCompleteJob(t *testing.T) { expectedMaxTTL: 0, }, { - name: "TemplateDefaultTTL", + name: "TemplateDefaultTTLIgnored", templateDefaultTTL: time.Hour, templateMaxTTL: 0, workspaceTTL: 0, transition: database.WorkspaceTransitionStart, - expectedTTL: time.Hour, + expectedTTL: 0, expectedMaxTTL: 0, }, { diff --git a/coderd/templates.go b/coderd/templates.go index 0066941962f2f..0d0d0b5e9ed83 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -273,15 +273,13 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return xerrors.Errorf("insert template: %s", err) } - if defaultTTL != 0 || maxTTL != 0 { - dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, provisionerdserver.TemplateScheduleOptions{ - UserSchedulingEnabled: true, - DefaultTTL: defaultTTL, - MaxTTL: maxTTL, - }) - if err != nil { - return xerrors.Errorf("set template schedule options: %s", err) - } + dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, provisionerdserver.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: defaultTTL, + MaxTTL: maxTTL, + }) + if err != nil { + return xerrors.Errorf("set template schedule options: %s", err) } templateAudit.New = dbTemplate diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index b2e240ab2ae7d..503637e7e441a 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -54,11 +54,13 @@ func TestTemplates(t *testing.T) { workspace2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.TTLMillis = &workspace2TTL }) - workspace3 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.TTLMillis = nil - }) - + workspace3 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + // To unset TTL you have to update, as setting a nil TTL on create + // copies the template default TTL. ctx, _ := testutil.Context(t) + err := client.UpdateWorkspaceTTL(ctx, workspace3.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: nil, + }) updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, From 6caeb003cccb7940eb2040f8773c3053b732a2a5 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 1 Mar 2023 17:25:48 +0000 Subject: [PATCH 11/24] Few refactorings for ttl fields --- site/src/i18n/en/common.json | 4 +- site/src/i18n/en/createTemplatePage.json | 19 ++- site/src/i18n/en/templateSettingsPage.json | 16 +-- .../CreateTemplatePage/CreateTemplateForm.tsx | 115 ++++++++++++++---- .../TemplateSettingsForm.tsx | 63 ++++++---- .../TemplateSettingsPage.tsx | 5 + .../TemplateSettingsPageView.stories.tsx | 20 +-- .../TemplateSettingsPageView.tsx | 3 + 8 files changed, 177 insertions(+), 68 deletions(-) diff --git a/site/src/i18n/en/common.json b/site/src/i18n/en/common.json index f1b4116b244b5..61d268b5ba8cd 100644 --- a/site/src/i18n/en/common.json +++ b/site/src/i18n/en/common.json @@ -39,5 +39,7 @@ "updateCheck": { "message": "Coder {{version}} is now available. View the <4>release notes and <7>upgrade instructions for more information.", "error": "Coder update check failed." - } + }, + "licenseFieldTextHelper": "You need an enterprise license to use it.", + "learnMore": "Learn more" } diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index 996adb276e473..15696c4157598 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -26,13 +26,17 @@ "displayName": "Display name", "description": "Description", "icon": "Icon", - "autoStop": "Auto-stop default", - "maxTTL": "Maximum lifetime of workspaces", + "autoStop": "Default auto-stop", + "maxTTL": "Max. Lifetime", "allowUsersToCancel": "Allow users to cancel in-progress workspace jobs" }, "helperText": { - "autoStop": "Time in hours", - "maxTTL": "Time in hours", + "defaultTTLHelperText_zero": "Workspaces will run until stopped manually.", + "defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour.", + "defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours.", + "maxTTLHelperText_zero": "Workspaces may run indefinitely.", + "maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting.", + "maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting.", "allowUsersToCancel": "If checked, users may be able to corrupt their workspace." }, "upload": { @@ -41,6 +45,13 @@ }, "tooltip": { "allowUsersToCancel": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases." + }, + "error": { + "descriptionMax": "Please enter a description that is less than or equal to 128 characters.", + "defaultTTLMax": "Please enter a limit that is less than or equal to 168 hours (7 days).", + "defaultTTLMin": "Default time until auto-stop must not be less than 0.", + "maxTTLMax": "Please enter a limit that is less than or equal to 168 hours (7 days).", + "maxTTLMin": "Maximum time until auto-stop must not be less than 0." } } } diff --git a/site/src/i18n/en/templateSettingsPage.json b/site/src/i18n/en/templateSettingsPage.json index edcaf469a6726..89337d99b5664 100644 --- a/site/src/i18n/en/templateSettingsPage.json +++ b/site/src/i18n/en/templateSettingsPage.json @@ -4,21 +4,21 @@ "displayNameLabel": "Display name", "descriptionLabel": "Description", "descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.", - "defaultTtlLabel": "Auto-stop default", - "maxTtlLabel": "Maximum lifetime for started workspaces", + "defaultTtlLabel": "Default auto-stop", + "maxTtlLabel": "Max. Lifetime", "iconLabel": "Icon", "formAriaLabel": "Template settings form", "selectEmoji": "Select emoji", "defaultTTLMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).", "defaultTTLMinError": "Default time until auto-stop must not be less than 0.", - "defaultTTLHelperText_zero": "Workspaces created from this template will run until stopped manually.", - "defaultTTLHelperText_one": "Workspaces created from this template will default to stopping after {{count}} hour.", - "defaultTTLHelperText_other": "Workspaces created from this template will default to stopping after {{count}} hours.", + "defaultTTLHelperText_zero": "Workspaces will run until stopped manually.", + "defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour.", + "defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours.", "maxTTLMaxError": "Please enter a limit that is less than or equal to 168 hours (7 days).", "maxTTLMinError": "Maximum time until auto-stop must not be less than 0.", - "maxTTLHelperText_zero": "Workspaces created from this template may run indefinitely.", - "maxTTLHelperText_one": "Workspaces created from this template must stop within {{count}} hour of starting.", - "maxTTLHelperText_other": "Workspaces created from this template must stop within {{count}} hours of starting.", + "maxTTLHelperText_zero": "Workspaces may run indefinitely.", + "maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting.", + "maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting.", "allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.", "allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.", "allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.", diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index cc389bfa4558d..e65411271debc 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -17,7 +17,12 @@ import { useFormik } from "formik" import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate" import { FC } from "react" import { useTranslation } from "react-i18next" -import { nameValidator, getFormHelpers, onChangeTrimmed } from "util/formUtils" +import { + nameValidator, + getFormHelpers, + onChangeTrimmed, + templateDisplayNameValidator, +} from "util/formUtils" import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService" import * as Yup from "yup" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" @@ -25,14 +30,60 @@ import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { LazyIconField } from "components/IconField/LazyIconField" import { Maybe } from "components/Conditionals/Maybe" import { useDashboard } from "components/Dashboard/DashboardProvider" +import i18next from "i18next" +import Link from "@material-ui/core/Link" + +const MAX_DESCRIPTION_CHAR_LIMIT = 128 +const MAX_TTL_DAYS = 7 + +const TTLHelperText = ({ + ttl, + translationName, +}: { + ttl?: number + translationName: string +}) => { + const { t } = useTranslation("createTemplatePage") + const count = typeof ttl !== "number" ? 0 : ttl + return ( + // no helper text if ttl is negative - error will show once field is considered touched + = 0}> + {t(translationName, { count })} + + ) +} const validationSchema = Yup.object({ - name: nameValidator("Name"), - display_name: Yup.string().optional(), - description: Yup.string().optional(), + name: nameValidator( + i18next.t("form.fields.name", { ns: "createTemplatePage" }), + ), + display_name: templateDisplayNameValidator( + i18next.t("form.fields.displayName", { + ns: "createTemplatePage", + }), + ), + description: Yup.string().max( + MAX_DESCRIPTION_CHAR_LIMIT, + i18next.t("form.error.descriptionMax", { ns: "createTemplatePage" }), + ), icon: Yup.string().optional(), - default_ttl_hours: Yup.number(), - max_ttl_hours: Yup.number(), + default_ttl_hours: Yup.number() + .integer() + .min( + 0, + i18next.t("form.error.defaultTTLMin", { ns: "templateSettingsPage" }), + ) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("form.error.defaultTTLMax", { ns: "templateSettingsPage" }), + ), + max_ttl_hours: Yup.number() + .integer() + .min(0, i18next.t("form.error.maxTTLMin", { ns: "templateSettingsPage" })) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("form.error.maxTTLMax", { ns: "templateSettingsPage" }), + ), allow_user_cancel_workspace_jobs: Yup.boolean(), parameter_values_by_name: Yup.object().optional(), }) @@ -110,6 +161,7 @@ export const CreateTemplateForm: FC = ({ }) const getFieldHelpers = getFormHelpers(form, error) const { t } = useTranslation("createTemplatePage") + const { t: commonT } = useTranslation("common") return (
@@ -197,30 +249,47 @@ export const CreateTemplateForm: FC = ({

- + , + )} disabled={isSubmitting} onChange={onChangeTrimmed(form)} fullWidth label={t("form.fields.autoStop")} variant="outlined" type="number" - helperText={t("form.helperText.autoStop")} /> - - - + + ) : ( + <> + {commonT("licenseFieldTextHelper")}{" "} + + {commonT("learnMore")} + + . + + ), + )} + disabled={isSubmitting || !canSetMaxTTL} + fullWidth + label={t("form.fields.maxTTL")} + variant="outlined" + type="number" + /> @@ -346,6 +415,10 @@ const useStyles = makeStyles((theme) => ({ }, }, + ttlFields: { + width: "100%", + }, + formSectionInfo: { width: 312, flexShrink: 0, diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx index 7cea8f375aa33..bc115967672a1 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx @@ -23,7 +23,7 @@ import { Stack } from "components/Stack/Stack" import Checkbox from "@material-ui/core/Checkbox" import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { makeStyles } from "@material-ui/core/styles" -import { useDashboard } from "components/Dashboard/DashboardProvider" +import Link from "@material-ui/core/Link" const TTLHelperText = ({ ttl, @@ -81,6 +81,7 @@ export interface TemplateSettingsForm { onCancel: () => void isSubmitting: boolean error?: unknown + canSetMaxTTL: boolean // Helpful to show field errors on Storybook initialTouched?: FormikTouched } @@ -90,13 +91,11 @@ export const TemplateSettingsForm: FC = ({ onSubmit, onCancel, error, + canSetMaxTTL, isSubmitting, initialTouched, }) => { - const { entitlements } = useDashboard() - const canSetMaxTTL = - entitlements.features["advanced_template_scheduling"].enabled - + const { t: commonT } = useTranslation("common") const validationSchema = getValidationSchema() const form: FormikContextType = useFormik({ @@ -193,39 +192,49 @@ export const TemplateSettingsForm: FC = ({ title={t("schedule.title")} description={t("schedule.description")} > - , - )} - disabled={isSubmitting} - fullWidth - inputProps={{ min: 0, step: 1 }} - label={t("defaultTtlLabel")} - variant="outlined" - type="number" - /> - - + , )} disabled={isSubmitting} fullWidth inputProps={{ min: 0, step: 1 }} + label={t("defaultTtlLabel")} + variant="outlined" + type="number" + /> + + + ) : ( + <> + {commonT("licenseFieldTextHelper")}{" "} + + {commonT("learnMore")} + + . + + ), + )} + disabled={isSubmitting || !canSetMaxTTL} + fullWidth + inputProps={{ min: 0, step: 1 }} label={t("maxTtlLabel")} variant="outlined" type="number" /> - + ({ fontSize: theme.spacing(1.5), color: theme.palette.text.secondary, }, + + ttlFields: { + width: "100%", + }, })) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx index b00a56ac569d4..9fb935d731bab 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx @@ -1,4 +1,5 @@ import { useMachine } from "@xstate/react" +import { useDashboard } from "components/Dashboard/DashboardProvider" import { useOrganizationId } from "hooks/useOrganizationId" import { FC } from "react" import { Helmet } from "react-helmet-async" @@ -27,6 +28,9 @@ export const TemplateSettingsPage: FC = () => { saveTemplateSettingsError, getTemplateError, } = state.context + const { entitlements } = useDashboard() + const canSetMaxTTL = + entitlements.features["advanced_template_scheduling"].enabled return ( <> @@ -34,6 +38,7 @@ export const TemplateSettingsPage: FC = () => { {pageTitle(t("title"))} = (args) => ( @@ -17,10 +23,11 @@ const Template: Story = (args) => ( ) export const Example = Template.bind({}) -Example.args = { - template: Mocks.MockTemplate, - onSubmit: action("onSubmit"), - onCancel: action("cancel"), +Example.args = {} + +export const CantSetMaxTTL = Template.bind({}) +CantSetMaxTTL.args = { + canSetMaxTTL: false, } export const GetTemplateError = Template.bind({}) @@ -32,13 +39,10 @@ GetTemplateError.args = { detail: "You do not have permission to access this resource.", }), }, - onSubmit: action("onSubmit"), - onCancel: action("cancel"), } export const SaveTemplateSettingsError = Template.bind({}) SaveTemplateSettingsError.args = { - template: Mocks.MockTemplate, errors: { saveTemplateSettingsError: makeMockApiError({ message: 'Template "test" already exists.', @@ -53,6 +57,4 @@ SaveTemplateSettingsError.args = { initialTouched: { name: true, }, - onSubmit: action("onSubmit"), - onCancel: action("cancel"), } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx index 6c514e1470b9a..14eec2798bb95 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx @@ -18,6 +18,7 @@ export interface TemplateSettingsPageViewProps { saveTemplateSettingsError?: unknown } initialTouched?: ComponentProps["initialTouched"] + canSetMaxTTL: boolean } export const TemplateSettingsPageView: FC = ({ @@ -25,6 +26,7 @@ export const TemplateSettingsPageView: FC = ({ onCancel, onSubmit, isSubmitting, + canSetMaxTTL, errors = {}, initialTouched, }) => { @@ -43,6 +45,7 @@ export const TemplateSettingsPageView: FC = ({ {template && ( <> Date: Wed, 1 Mar 2023 17:55:44 +0000 Subject: [PATCH 12/24] fixup! Merge branch 'main' into dean/schedule-max-ttl --- enterprise/coderd/templates_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 503637e7e441a..7c58bd9527514 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -61,6 +61,7 @@ func TestTemplates(t *testing.T) { err := client.UpdateWorkspaceTTL(ctx, workspace3.ID, codersdk.UpdateWorkspaceTTLRequest{ TTLMillis: nil, }) + require.NoError(t, err) updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ Name: template.Name, From 651149b362458fdffac4e359d4ca1ae99a4a31d1 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 1 Mar 2023 18:57:28 +0000 Subject: [PATCH 13/24] fixup! Merge branch 'main' into dean/schedule-max-ttl --- docs/admin/audit-logs.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 5b9355b391e3d..fb4bb7a85de33 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -9,17 +9,17 @@ We track the following resources: -| Resource | | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
updated_atfalse
user_idfalse
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
is_privatetrue
min_autostart_intervaltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
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_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
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_idfalse
job_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| Resource | | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
updated_atfalse
user_idfalse
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
is_privatetrue
max_ttltrue
min_autostart_intervaltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
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_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
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_idfalse
job_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| From 25f7d2c5a6671fc7e9ce8fa5e7daf0b9da1565d1 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 2 Mar 2023 15:05:40 +0000 Subject: [PATCH 14/24] chore: add test for activitybump max_ttl --- cli/testdata/coder_list_--output_json.golden | 1 + coderd/activitybump_test.go | 85 ++++++++++++++++++-- coderd/coderdtest/coderdtest.go | 67 +++++++-------- coderd/workspacebuilds.go | 1 + codersdk/workspacebuilds.go | 1 + 5 files changed, 118 insertions(+), 37 deletions(-) diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index ac3761efd6dca..9a39ed4f9c3dc 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -39,6 +39,7 @@ "reason": "initiator", "resources": [], "deadline": "[timestamp]", + "max_deadline": null, "status": "running", "daily_cost": 0 }, diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index fe8c947dcea62..d9568a3308c8f 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -5,24 +5,42 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) +type mockTemplateScheduleStore struct { + getFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) +} + +var _ provisionerdserver.TemplateScheduleStore = mockTemplateScheduleStore{} + +func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { + return m.getFn(ctx, db, templateID) +} + +func (mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) { + return provisionerdserver.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options) +} + func TestWorkspaceActivityBump(t *testing.T) { t.Parallel() ctx := context.Background() - const ttl = time.Minute - - setupActivityTest := func(t *testing.T) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { - ttlMillis := int64(ttl / time.Millisecond) + setupActivityTest := func(t *testing.T, maxDeadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { + const ttl = time.Minute + maxTTL := time.Duration(0) + if len(maxDeadline) > 0 { + maxTTL = maxDeadline[0] + } client = coderdtest.New(t, &coderdtest.Options{ AppHostname: proxyTestSubdomainRaw, @@ -30,9 +48,19 @@ func TestWorkspaceActivityBump(t *testing.T) { // Agent stats trigger the activity bump, so we want to report // very frequently in tests. AgentStatsRefreshInterval: time.Millisecond * 100, + TemplateScheduleStore: mockTemplateScheduleStore{ + getFn: func(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { + return provisionerdserver.TemplateScheduleOptions{ + UserSchedulingEnabled: true, + DefaultTTL: ttl, + MaxTTL: maxTTL, + }, nil + }, + }, }) user := coderdtest.CreateFirstUser(t, client) + ttlMillis := int64(ttl / time.Millisecond) workspace = createWorkspaceWithApps(t, client, user.OrganizationID, "", 1234, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.TTLMillis = &ttlMillis }) @@ -42,10 +70,21 @@ func TestWorkspaceActivityBump(t *testing.T) { require.NoError(t, err) require.WithinDuration(t, time.Now().Add(time.Duration(ttlMillis)*time.Millisecond), - workspace.LatestBuild.Deadline.Time, testutil.WaitMedium, + workspace.LatestBuild.Deadline.Time, + testutil.WaitMedium, ) firstDeadline := workspace.LatestBuild.Deadline.Time + if maxTTL != 0 { + require.WithinDuration(t, + time.Now().Add(maxTTL), + workspace.LatestBuild.MaxDeadline.Time, + testutil.WaitMedium, + ) + } else { + require.True(t, workspace.LatestBuild.MaxDeadline.Time.IsZero()) + } + _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) return client, workspace, func(want bool) { @@ -74,6 +113,12 @@ func TestWorkspaceActivityBump(t *testing.T) { "deadline %v never updated", firstDeadline, ) + // If the workspace has a max deadline, the deadline must not exceed + // it. + if maxTTL != 0 && database.Now().Add(ttl).After(workspace.LatestBuild.MaxDeadline.Time) { + require.Equal(t, workspace.LatestBuild.Deadline.Time, workspace.LatestBuild.MaxDeadline.Time) + return + } require.WithinDuration(t, database.Now().Add(ttl), workspace.LatestBuild.Deadline.Time, 3*time.Second) } } @@ -111,4 +156,34 @@ func TestWorkspaceActivityBump(t *testing.T) { assertBumped(false) }) + + t.Run("NotExceedMaxDeadline", func(t *testing.T) { + t.Parallel() + + // Set the max deadline to be in 61 seconds. We bump by 1 minute, so we + // should expect the deadline to match the max deadline exactly. + client, workspace, assertBumped := setupActivityTest(t, 61*time.Second) + + // Bump by dialing the workspace and sending traffic. + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil), + }) + require.NoError(t, err) + defer conn.Close() + + // Must send network traffic after a few seconds to surpass bump threshold. + time.Sleep(time.Second * 3) + sshConn, err := conn.SSHClient(ctx) + require.NoError(t, err) + _ = sshConn.Close() + + assertBumped(true) + + // Double check that the workspace build's deadline is equal to the + // max deadline. + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, workspace.LatestBuild.Deadline.Time, workspace.LatestBuild.MaxDeadline.Time) + }) } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 7f94e852badc8..9a3d968b23e6f 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -65,6 +65,7 @@ import ( "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/updatecheck" @@ -85,22 +86,23 @@ type Options struct { // AccessURL denotes a custom access URL. By default we use the httptest // server's URL. Setting this may result in unexpected behavior (especially // with running agents). - AccessURL *url.URL - AppHostname string - AWSCertificates awsidentity.Certificates - Authorizer rbac.Authorizer - AzureCertificates x509.VerifyOptions - GithubOAuth2Config *coderd.GithubOAuth2Config - RealIPConfig *httpmw.RealIPConfig - OIDCConfig *coderd.OIDCConfig - GoogleTokenValidator *idtoken.Validator - SSHKeygenAlgorithm gitsshkey.Algorithm - AutobuildTicker <-chan time.Time - AutobuildStats chan<- executor.Stats - Auditor audit.Auditor - TLSCertificates []tls.Certificate - GitAuthConfigs []*gitauth.Config - TrialGenerator func(context.Context, string) error + AccessURL *url.URL + AppHostname string + AWSCertificates awsidentity.Certificates + Authorizer rbac.Authorizer + AzureCertificates x509.VerifyOptions + GithubOAuth2Config *coderd.GithubOAuth2Config + RealIPConfig *httpmw.RealIPConfig + OIDCConfig *coderd.OIDCConfig + GoogleTokenValidator *idtoken.Validator + SSHKeygenAlgorithm gitsshkey.Algorithm + AutobuildTicker <-chan time.Time + AutobuildStats chan<- executor.Stats + Auditor audit.Auditor + TLSCertificates []tls.Certificate + GitAuthConfigs []*gitauth.Config + TrialGenerator func(context.Context, string) error + TemplateScheduleStore provisionerdserver.TemplateScheduleStore // All rate limits default to -1 (unlimited) in tests if not set. APIRateLimit int @@ -287,22 +289,23 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can Pubsub: options.Pubsub, GitAuthConfigs: options.GitAuthConfigs, - Auditor: options.Auditor, - AWSCertificates: options.AWSCertificates, - AzureCertificates: options.AzureCertificates, - GithubOAuth2Config: options.GithubOAuth2Config, - RealIPConfig: options.RealIPConfig, - OIDCConfig: options.OIDCConfig, - GoogleTokenValidator: options.GoogleTokenValidator, - SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, - DERPServer: derpServer, - APIRateLimit: options.APIRateLimit, - LoginRateLimit: options.LoginRateLimit, - FilesRateLimit: options.FilesRateLimit, - Authorizer: options.Authorizer, - Telemetry: telemetry.NewNoop(), - TLSCertificates: options.TLSCertificates, - TrialGenerator: options.TrialGenerator, + Auditor: options.Auditor, + AWSCertificates: options.AWSCertificates, + AzureCertificates: options.AzureCertificates, + GithubOAuth2Config: options.GithubOAuth2Config, + RealIPConfig: options.RealIPConfig, + OIDCConfig: options.OIDCConfig, + GoogleTokenValidator: options.GoogleTokenValidator, + SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, + DERPServer: derpServer, + APIRateLimit: options.APIRateLimit, + LoginRateLimit: options.LoginRateLimit, + FilesRateLimit: options.FilesRateLimit, + Authorizer: options.Authorizer, + Telemetry: telemetry.NewNoop(), + TemplateScheduleStore: options.TemplateScheduleStore, + TLSCertificates: options.TLSCertificates, + TrialGenerator: options.TrialGenerator, DERPMap: &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: { diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 26176f5a0c93e..590f2df90eff1 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1139,6 +1139,7 @@ func (api *API) convertWorkspaceBuild( InitiatorUsername: initiator.Username, Job: apiJob, Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()), + MaxDeadline: codersdk.NewNullTime(build.MaxDeadline, !build.MaxDeadline.IsZero()), Reason: codersdk.BuildReason(build.Reason), Resources: apiResources, Status: convertWorkspaceStatus(apiJob.Status, transition), diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index ab43379c28da8..2d8a70724d1d5 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -68,6 +68,7 @@ type WorkspaceBuild struct { Reason BuildReason `db:"reason" json:"reason" enums:"initiator,autostart,autostop"` Resources []WorkspaceResource `json:"resources"` Deadline NullTime `json:"deadline,omitempty" format:"date-time"` + MaxDeadline NullTime `json:"max_deadline,omitempty" format:"date-time"` Status WorkspaceStatus `json:"status" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted"` DailyCost int32 `json:"daily_cost"` } From e3d8557b954a974d348903dcf0389d23fd8ed6e8 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 2 Mar 2023 15:40:26 +0000 Subject: [PATCH 15/24] chore: add tests for updating max_ttl on template --- coderd/activitybump_test.go | 13 ++- coderd/templates_test.go | 199 ++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 2 deletions(-) diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index d9568a3308c8f..9703ac59c713a 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -18,15 +18,24 @@ import ( type mockTemplateScheduleStore struct { getFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) + setFn func(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) } var _ provisionerdserver.TemplateScheduleStore = mockTemplateScheduleStore{} func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { - return m.getFn(ctx, db, templateID) + if m.getFn != nil { + return m.getFn(ctx, db, templateID) + } + + return provisionerdserver.NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID) } -func (mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) { +func (m mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) { + if m.setFn != nil { + return m.setFn(ctx, db, template, options) + } + return provisionerdserver.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options) } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 092f6f75c9d99..90c5d0721e27c 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -3,6 +3,7 @@ package coderd_test import ( "context" "net/http" + "sync/atomic" "testing" "time" @@ -15,6 +16,7 @@ import ( "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" @@ -143,6 +145,95 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Contains(t, err.Error(), "Try logging in using 'coder login '.") }) + t.Run("MaxTTL", func(t *testing.T) { + t.Parallel() + + const ( + defaultTTL = 1 * time.Hour + maxTTL = 24 * time.Hour + ) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: mockTemplateScheduleStore{ + setFn: func(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) { + atomic.AddInt64(&setCalled, 1) + require.Equal(t, maxTTL, options.MaxTTL) + template.DefaultTTL = int64(options.DefaultTTL) + template.MaxTTL = int64(options.MaxTTL) + return template, nil + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + DefaultTTLMillis: ptr.Ref(int64(0)), + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + }) + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.EqualValues(t, 0, got.DefaultTTLMillis) + require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis) + }) + + t.Run("DefaultTTLBigger", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + DefaultTTLMillis: ptr.Ref((maxTTL * 2).Milliseconds()), + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms") + }) + + t.Run("IgnoredUnlicensed", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + }) + require.NoError(t, err) + require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis) + require.Zero(t, got.MaxTTLMillis) + }) + }) + t.Run("NoVersion", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -345,6 +436,114 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, updated.DefaultTTLMillis, template.DefaultTTLMillis) }) + t.Run("MaxTTL", func(t *testing.T) { + t.Parallel() + + const ( + defaultTTL = 1 * time.Hour + maxTTL = 24 * time.Hour + ) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: mockTemplateScheduleStore{ + setFn: func(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) { + if atomic.AddInt64(&setCalled, 1) == 2 { + require.Equal(t, maxTTL, options.MaxTTL) + } + template.DefaultTTL = int64(options.DefaultTTL) + template.MaxTTL = int64(options.MaxTTL) + return template, nil + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: 0, + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.NoError(t, err) + + require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) + require.EqualValues(t, 0, got.DefaultTTLMillis) + require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis) + }) + + t.Run("DefaultTTLBigger", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: (maxTTL * 2).Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms") + }) + + t.Run("IgnoredUnlicensed", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = ptr.Ref(24 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: defaultTTL.Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + }) + require.NoError(t, err) + require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis) + require.Zero(t, got.MaxTTLMillis) + }) + }) + t.Run("NotModified", func(t *testing.T) { t.Parallel() From 2b029d8c4148783043b3f2507b10bb3d1959a3b6 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 2 Mar 2023 17:08:15 +0000 Subject: [PATCH 16/24] fixup! Merge branch 'main' into dean/schedule-max-ttl --- coderd/apidoc/docs.go | 4 ++++ coderd/apidoc/swagger.json | 4 ++++ ...own.sql => 000103_workspace_build_max_deadline.down.sql} | 0 ...ne.up.sql => 000103_workspace_build_max_deadline.up.sql} | 0 docs/api/builds.md | 6 ++++++ docs/api/schemas.md | 4 ++++ docs/api/workspaces.md | 4 ++++ site/src/api/typesGenerated.ts | 1 + 8 files changed, 23 insertions(+) rename coderd/database/migrations/{000102_workspace_build_max_deadline.down.sql => 000103_workspace_build_max_deadline.down.sql} (100%) rename coderd/database/migrations/{000102_workspace_build_max_deadline.up.sql => 000103_workspace_build_max_deadline.up.sql} (100%) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 48e956a4e291b..4d2bfeadf4dd5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8592,6 +8592,10 @@ const docTemplate = `{ "job": { "$ref": "#/definitions/codersdk.ProvisionerJob" }, + "max_deadline": { + "type": "string", + "format": "date-time" + }, "reason": { "enum": [ "initiator", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index dcc43d1714ba4..35a3e0bd3e582 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7739,6 +7739,10 @@ "job": { "$ref": "#/definitions/codersdk.ProvisionerJob" }, + "max_deadline": { + "type": "string", + "format": "date-time" + }, "reason": { "enum": ["initiator", "autostart", "autostop"], "allOf": [ diff --git a/coderd/database/migrations/000102_workspace_build_max_deadline.down.sql b/coderd/database/migrations/000103_workspace_build_max_deadline.down.sql similarity index 100% rename from coderd/database/migrations/000102_workspace_build_max_deadline.down.sql rename to coderd/database/migrations/000103_workspace_build_max_deadline.down.sql diff --git a/coderd/database/migrations/000102_workspace_build_max_deadline.up.sql b/coderd/database/migrations/000103_workspace_build_max_deadline.up.sql similarity index 100% rename from coderd/database/migrations/000102_workspace_build_max_deadline.up.sql rename to coderd/database/migrations/000103_workspace_build_max_deadline.up.sql diff --git a/docs/api/builds.md b/docs/api/builds.md index 343bac7312b96..62c901971c79f 100644 --- a/docs/api/builds.md +++ b/docs/api/builds.md @@ -49,6 +49,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -195,6 +196,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -708,6 +710,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -859,6 +862,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -983,6 +987,7 @@ Status Code **200** | `»» tags` | object | false | | | | `»»» [any property]` | string | false | | | | `»» worker_id` | string(uuid) | false | | | +| `» max_deadline` | string(date-time) | false | | | | `» reason` | [codersdk.BuildReason](schemas.md#codersdkbuildreason) | false | | | | `» resources` | array | false | | | | `»» agents` | array | false | | | @@ -1175,6 +1180,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 9c2fbcdc2226c..a7a86d05364bf 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5304,6 +5304,7 @@ Parameter represents a set value for the scope. }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -5762,6 +5763,7 @@ Parameter represents a set value for the scope. }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -5865,6 +5867,7 @@ Parameter represents a set value for the scope. | `initiator_id` | string | false | | | | `initiator_name` | string | false | | | | `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | | +| `max_deadline` | string | false | | | | `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | | `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | | | `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | | @@ -6126,6 +6129,7 @@ Parameter represents a set value for the scope. }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 67e1f8c0125f7..79b1d707b7639 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -81,6 +81,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -246,6 +247,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -434,6 +436,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { @@ -596,6 +599,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ }, "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" }, + "max_deadline": "2019-08-24T14:15:22Z", "reason": "initiator", "resources": [ { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b90ce766b3e50..9dfdd37dfceef 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1043,6 +1043,7 @@ export interface WorkspaceBuild { readonly reason: BuildReason readonly resources: WorkspaceResource[] readonly deadline?: string + readonly max_deadline?: string readonly status: WorkspaceStatus readonly daily_cost: number } From e60919e05cedc38f6cb4b88554e5b256ec04dc39 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 2 Mar 2023 17:13:27 +0000 Subject: [PATCH 17/24] chore: fix security.yaml not having protoc --- .github/workflows/ci.yaml | 5 +++-- .github/workflows/security.yaml | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4753aafeef26c..48eabc80c039e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -186,8 +186,9 @@ jobs: - name: Install Protoc run: | - # protoc must be in lockstep with our dogfood Dockerfile - # or the version in the comments will differ. + # protoc must be in lockstep with our dogfood Dockerfile or the + # version in the comments will differ. This is also defined in + # security.yaml set -x cd dogfood DOCKER_BUILDKIT=1 docker build . --target proto -t protoc diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 5689825374b37..ab1e27c225fa7 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -94,6 +94,22 @@ jobs: - name: Install yq run: go run github.com/mikefarah/yq/v4@v4.30.6 + - name: Install protoc-gen-go + run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26 + - name: Install protoc-gen-go-drpc + run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.26 + - name: Install Protoc + run: | + # protoc must be in lockstep with our dogfood Dockerfile or the + # version in the comments will differ. This is also defined in + # ci.yaml. + set -x + cd dogfood + DOCKER_BUILDKIT=1 docker build . --target proto -t protoc + protoc_path=/usr/local/bin/protoc + docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path + chmod +x $protoc_path + protoc --version - name: Build Coder linux amd64 Docker image id: build From 351b708f492e83f4f54271ff836c5f63ff86ab2b Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 2 Mar 2023 18:18:27 +0000 Subject: [PATCH 18/24] fixup! Merge branch 'main' into dean/schedule-max-ttl --- ...line.down.sql => 000104_workspace_build_max_deadline.down.sql} | 0 ...deadline.up.sql => 000104_workspace_build_max_deadline.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000103_workspace_build_max_deadline.down.sql => 000104_workspace_build_max_deadline.down.sql} (100%) rename coderd/database/migrations/{000103_workspace_build_max_deadline.up.sql => 000104_workspace_build_max_deadline.up.sql} (100%) diff --git a/coderd/database/migrations/000103_workspace_build_max_deadline.down.sql b/coderd/database/migrations/000104_workspace_build_max_deadline.down.sql similarity index 100% rename from coderd/database/migrations/000103_workspace_build_max_deadline.down.sql rename to coderd/database/migrations/000104_workspace_build_max_deadline.down.sql diff --git a/coderd/database/migrations/000103_workspace_build_max_deadline.up.sql b/coderd/database/migrations/000104_workspace_build_max_deadline.up.sql similarity index 100% rename from coderd/database/migrations/000103_workspace_build_max_deadline.up.sql rename to coderd/database/migrations/000104_workspace_build_max_deadline.up.sql From 847bc4c5078422fddb4f1895e2f46feec9c51a53 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 2 Mar 2023 18:42:11 +0000 Subject: [PATCH 19/24] chore: move schedule code to new package --- cli/list.go | 2 +- cli/schedule.go | 2 +- cli/util.go | 2 +- coderd/activitybump_test.go | 20 ++++++++-------- .../autobuild/executor/lifecycle_executor.go | 2 +- .../executor/lifecycle_executor_test.go | 2 +- coderd/coderd.go | 9 ++++---- coderd/coderdtest/coderdtest.go | 4 ++-- .../provisionerdserver/provisionerdserver.go | 3 ++- .../provisionerdserver_test.go | 23 ++++++++++--------- .../schedule/schedule.go => schedule/cron.go} | 5 ++-- .../cron_test.go} | 2 +- .../template.go} | 2 +- coderd/templates.go | 6 ++--- coderd/templates_test.go | 6 ++--- coderd/workspaces.go | 2 +- coderd/workspaces_test.go | 2 +- enterprise/coderd/coderd.go | 6 ++--- enterprise/coderd/provisionerdaemons.go | 11 +++++---- 19 files changed, 58 insertions(+), 53 deletions(-) rename coderd/{autobuild/schedule/schedule.go => schedule/cron.go} (96%) rename coderd/{autobuild/schedule/schedule_test.go => schedule/cron_test.go} (99%) rename coderd/{provisionerdserver/templatescheduleoptions.go => schedule/template.go} (98%) diff --git a/cli/list.go b/cli/list.go index 510589eff64d0..33493cf807080 100644 --- a/cli/list.go +++ b/cli/list.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" ) diff --git a/cli/schedule.go b/cli/schedule.go index f774f5a51ad87..ff81b8e81dc50 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -10,7 +10,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/cli/cliui" - "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/coderd/util/tz" "github.com/coder/coder/codersdk" diff --git a/cli/util.go b/cli/util.go index 7ea5dd27e756b..777335d0a7d80 100644 --- a/cli/util.go +++ b/cli/util.go @@ -8,7 +8,7 @@ import ( "golang.org/x/xerrors" - "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/tz" ) diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index 9703ac59c713a..1548eddbb057c 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -11,32 +11,32 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/provisionerdserver" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" ) type mockTemplateScheduleStore struct { - getFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) - setFn func(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) + getFn func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) + setFn func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) } -var _ provisionerdserver.TemplateScheduleStore = mockTemplateScheduleStore{} +var _ schedule.TemplateScheduleStore = mockTemplateScheduleStore{} -func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { +func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) { if m.getFn != nil { return m.getFn(ctx, db, templateID) } - return provisionerdserver.NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID) + return schedule.NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID) } -func (m mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) { +func (m mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { if m.setFn != nil { return m.setFn(ctx, db, template, options) } - return provisionerdserver.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options) + return schedule.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options) } func TestWorkspaceActivityBump(t *testing.T) { @@ -58,8 +58,8 @@ func TestWorkspaceActivityBump(t *testing.T) { // very frequently in tests. AgentStatsRefreshInterval: time.Millisecond * 100, TemplateScheduleStore: mockTemplateScheduleStore{ - getFn: func(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { - return provisionerdserver.TemplateScheduleOptions{ + getFn: func(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ UserSchedulingEnabled: true, DefaultTTL: ttl, MaxTTL: maxTTL, diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index 6e72dff6af272..f6b4d0db12d87 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -10,10 +10,10 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/provisionerdserver" + "github.com/coder/coder/coderd/schedule" ) // Executor automatically starts or stops workspaces. diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index ac0ee6c6254f8..2548e317b69ff 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -11,9 +11,9 @@ import ( "github.com/google/uuid" "github.com/coder/coder/coderd/autobuild/executor" - "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" diff --git a/coderd/coderd.go b/coderd/coderd.go index 9d82b709f98a6..450e1bc0746d7 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -51,6 +51,7 @@ import ( "github.com/coder/coder/coderd/metricscache" "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/updatecheck" @@ -118,7 +119,7 @@ type Options struct { DERPMap *tailcfg.DERPMap SwaggerEndpoint bool SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error - TemplateScheduleStore provisionerdserver.TemplateScheduleStore + TemplateScheduleStore schedule.TemplateScheduleStore // APIRateLimit is the minutely throughput rate limit per user or ip. // Setting a rate limit <0 will disable the rate limiter across the entire @@ -211,7 +212,7 @@ func New(options *Options) *API { options.SetUserGroups = func(context.Context, database.Store, uuid.UUID, []string) error { return nil } } if options.TemplateScheduleStore == nil { - options.TemplateScheduleStore = provisionerdserver.NewAGPLTemplateScheduleStore() + options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore() } siteCacheDir := options.CacheDir @@ -251,7 +252,7 @@ func New(options *Options) *API { }, metricsCache: metricsCache, Auditor: atomic.Pointer[audit.Auditor]{}, - TemplateScheduleStore: atomic.Pointer[provisionerdserver.TemplateScheduleStore]{}, + TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{}, Experiments: experiments, } if options.UpdateCheckOptions != nil { @@ -726,7 +727,7 @@ type API struct { WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] TailnetCoordinator atomic.Pointer[tailnet.Coordinator] QuotaCommitter atomic.Pointer[proto.QuotaCommitter] - TemplateScheduleStore atomic.Pointer[provisionerdserver.TemplateScheduleStore] + TemplateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore] HTTPAuth *HTTPAuthorizer diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 9a3d968b23e6f..e0b9a53d4e296 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -65,8 +65,8 @@ import ( "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/updatecheck" "github.com/coder/coder/coderd/util/ptr" @@ -102,7 +102,7 @@ type Options struct { TLSCertificates []tls.Certificate GitAuthConfigs []*gitauth.Config TrialGenerator func(context.Context, string) error - TemplateScheduleStore provisionerdserver.TemplateScheduleStore + TemplateScheduleStore schedule.TemplateScheduleStore // All rate limits default to -1 (unlimited) in tests if not set. APIRateLimit int diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index b7538abd0e5df..5f8a6b1c7cb9d 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -28,6 +28,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/parameter" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" @@ -54,7 +55,7 @@ type Server struct { Telemetry telemetry.Reporter QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] Auditor *atomic.Pointer[audit.Auditor] - TemplateScheduleStore *atomic.Pointer[TemplateScheduleStore] + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] AcquireJobDebounce time.Duration } diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 518be8e25c55c..ad359d69ba4f3 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/coderd/database/dbfake" "github.com/coder/coder/coderd/database/dbgen" "github.com/coder/coder/coderd/provisionerdserver" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisionerd/proto" @@ -32,9 +33,9 @@ func mockAuditor() *atomic.Pointer[audit.Auditor] { return ptr } -func testTemplateScheduleStore() *atomic.Pointer[provisionerdserver.TemplateScheduleStore] { - ptr := &atomic.Pointer[provisionerdserver.TemplateScheduleStore]{} - store := provisionerdserver.NewAGPLTemplateScheduleStore() +func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] { + ptr := &atomic.Pointer[schedule.TemplateScheduleStore]{} + store := schedule.NewAGPLTemplateScheduleStore() ptr.Store(&store) return ptr } @@ -890,9 +891,9 @@ func TestCompleteJob(t *testing.T) { srv := setup(t, false) - var store provisionerdserver.TemplateScheduleStore = mockTemplateScheduleStore{ - GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { - return provisionerdserver.TemplateScheduleOptions{ + var store schedule.TemplateScheduleStore = mockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ UserSchedulingEnabled: true, DefaultTTL: c.templateDefaultTTL, MaxTTL: c.templateMaxTTL, @@ -1167,15 +1168,15 @@ func must[T any](value T, err error) T { } type mockTemplateScheduleStore struct { - GetFn func(ctx context.Context, db database.Store, id uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) + GetFn func(ctx context.Context, db database.Store, id uuid.UUID) (schedule.TemplateScheduleOptions, error) } -var _ provisionerdserver.TemplateScheduleStore = mockTemplateScheduleStore{} +var _ schedule.TemplateScheduleStore = mockTemplateScheduleStore{} -func (mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts provisionerdserver.TemplateScheduleOptions) (database.Template, error) { - return provisionerdserver.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, opts) +func (mockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) { + return schedule.NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, opts) } -func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, id uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { +func (m mockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, id uuid.UUID) (schedule.TemplateScheduleOptions, error) { return m.GetFn(ctx, db, id) } diff --git a/coderd/autobuild/schedule/schedule.go b/coderd/schedule/cron.go similarity index 96% rename from coderd/autobuild/schedule/schedule.go rename to coderd/schedule/cron.go index d13c367a2eb09..3da7ebb4b58d9 100644 --- a/coderd/autobuild/schedule/schedule.go +++ b/coderd/schedule/cron.go @@ -1,5 +1,6 @@ -// package schedule provides utilities for parsing and deserializing -// cron-style expressions. +// package schedule provides utilities for managing template and workspace +// auto-start and auto-stop schedules. This includes utilities for parsing and +// deserializing cron-style expressions. package schedule import ( diff --git a/coderd/autobuild/schedule/schedule_test.go b/coderd/schedule/cron_test.go similarity index 99% rename from coderd/autobuild/schedule/schedule_test.go rename to coderd/schedule/cron_test.go index ccba3e4f37773..9437cff4eed04 100644 --- a/coderd/autobuild/schedule/schedule_test.go +++ b/coderd/schedule/cron_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/schedule" ) func Test_Weekly(t *testing.T) { diff --git a/coderd/provisionerdserver/templatescheduleoptions.go b/coderd/schedule/template.go similarity index 98% rename from coderd/provisionerdserver/templatescheduleoptions.go rename to coderd/schedule/template.go index 2387115c4f0b5..0d328837f3cf8 100644 --- a/coderd/provisionerdserver/templatescheduleoptions.go +++ b/coderd/schedule/template.go @@ -1,4 +1,4 @@ -package provisionerdserver +package schedule import ( "context" diff --git a/coderd/templates.go b/coderd/templates.go index 0d0d0b5e9ed83..aa4a6ddc239a2 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -18,8 +18,8 @@ import ( "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/codersdk" "github.com/coder/coder/examples" @@ -273,7 +273,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return xerrors.Errorf("insert template: %s", err) } - dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, provisionerdserver.TemplateScheduleOptions{ + dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ UserSchedulingEnabled: true, DefaultTTL: defaultTTL, MaxTTL: maxTTL, @@ -536,7 +536,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) { - updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, provisionerdserver.TemplateScheduleOptions{ + updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{ UserSchedulingEnabled: true, DefaultTTL: defaultTTL, MaxTTL: maxTTL, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 90c5d0721e27c..195169378d99d 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -16,7 +16,7 @@ import ( "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/provisionerdserver" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" @@ -159,7 +159,7 @@ func TestPostTemplateByOrganization(t *testing.T) { var setCalled int64 client := coderdtest.New(t, &coderdtest.Options{ TemplateScheduleStore: mockTemplateScheduleStore{ - setFn: func(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) { + setFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { atomic.AddInt64(&setCalled, 1) require.Equal(t, maxTTL, options.MaxTTL) template.DefaultTTL = int64(options.DefaultTTL) @@ -450,7 +450,7 @@ func TestPatchTemplateMeta(t *testing.T) { var setCalled int64 client := coderdtest.New(t, &coderdtest.Options{ TemplateScheduleStore: mockTemplateScheduleStore{ - setFn: func(ctx context.Context, db database.Store, template database.Template, options provisionerdserver.TemplateScheduleOptions) (database.Template, error) { + setFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { if atomic.AddInt64(&setCalled, 1) == 2 { require.Equal(t, maxTTL, options.MaxTTL) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 6f1cf039e52fd..44032ec915772 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -18,12 +18,12 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/searchquery" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 93c38941c6ba7..455487ddad21d 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -17,11 +17,11 @@ import ( "github.com/coder/coder/agent" "github.com/coder/coder/coderd/audit" - "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/parameter" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 4571e98ffc077..8ddfb0bbd98d2 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -20,8 +20,8 @@ import ( agplaudit "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/enterprise/derpmesh" @@ -315,10 +315,10 @@ func (api *API) updateEntitlements(ctx context.Context) error { if changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); changed { if enabled { store := &enterpriseTemplateScheduleStore{} - ptr := provisionerdserver.TemplateScheduleStore(store) + ptr := schedule.TemplateScheduleStore(store) api.AGPL.TemplateScheduleStore.Store(&ptr) } else { - store := provisionerdserver.NewAGPLTemplateScheduleStore() + store := schedule.NewAGPLTemplateScheduleStore() api.AGPL.TemplateScheduleStore.Store(&store) } } diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index caddf10292e8d..a130bbeeb1c24 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisionerd/proto" ) @@ -306,15 +307,15 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock type enterpriseTemplateScheduleStore struct{} -var _ provisionerdserver.TemplateScheduleStore = &enterpriseTemplateScheduleStore{} +var _ schedule.TemplateScheduleStore = &enterpriseTemplateScheduleStore{} -func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (provisionerdserver.TemplateScheduleOptions, error) { +func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) { tpl, err := db.GetTemplateByID(ctx, templateID) if err != nil { - return provisionerdserver.TemplateScheduleOptions{}, err + return schedule.TemplateScheduleOptions{}, err } - return provisionerdserver.TemplateScheduleOptions{ + return schedule.TemplateScheduleOptions{ // TODO: make configurable at template level UserSchedulingEnabled: true, DefaultTTL: time.Duration(tpl.DefaultTTL), @@ -322,7 +323,7 @@ func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C }, nil } -func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts provisionerdserver.TemplateScheduleOptions) (database.Template, error) { +func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) { template, err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ ID: tpl.ID, UpdatedAt: database.Now(), From 0611794a648ac06e0b4f855a0da8fe2913aa88d6 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 2 Mar 2023 18:42:52 +0000 Subject: [PATCH 20/24] fixup! chore: move schedule code to new package --- docs/admin/audit-logs.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 0d376d755b377..f3950f0419174 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -9,17 +9,17 @@ We track the following resources: -| Resource | | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idfalse
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
is_privatetrue
min_autostart_intervaltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
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_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
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_idfalse
job_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| Resource | | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idfalse
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
is_privatetrue
max_ttltrue
min_autostart_intervaltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
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_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
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_idfalse
job_idfalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| From e44f589765062ffbeaa4edd21c3c4dcb1dc8e1b7 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 6 Mar 2023 18:25:41 +0000 Subject: [PATCH 21/24] chore: add alpha label to max_ttl --- site/src/i18n/en/createTemplatePage.json | 2 +- site/src/i18n/en/templateSettingsPage.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index 15696c4157598..567e17ca60dbd 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -27,7 +27,7 @@ "description": "Description", "icon": "Icon", "autoStop": "Default auto-stop", - "maxTTL": "Max. Lifetime", + "maxTTL": "Max. Lifetime (alpha)", "allowUsersToCancel": "Allow users to cancel in-progress workspace jobs" }, "helperText": { diff --git a/site/src/i18n/en/templateSettingsPage.json b/site/src/i18n/en/templateSettingsPage.json index 89337d99b5664..bd0f35f42711d 100644 --- a/site/src/i18n/en/templateSettingsPage.json +++ b/site/src/i18n/en/templateSettingsPage.json @@ -5,7 +5,7 @@ "descriptionLabel": "Description", "descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.", "defaultTtlLabel": "Default auto-stop", - "maxTtlLabel": "Max. Lifetime", + "maxTtlLabel": "Max. Lifetime (alpha)", "iconLabel": "Icon", "formAriaLabel": "Template settings form", "selectEmoji": "Select emoji", From 3ce2bc3b32aead2ea2fe205c3f2c4f3bf3e0fd01 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 6 Mar 2023 20:20:25 +0000 Subject: [PATCH 22/24] Fix test --- .../TemplateSettingsPage.test.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx index 3facf3e44dda7..52b601a7c725e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx @@ -121,12 +121,17 @@ describe("TemplateSettingsPage", () => { await fillAndSubmitForm(validFormValues) await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) - expect(API.updateTemplateMeta).toBeCalledWith( - "test-template", - expect.objectContaining({ - ...validFormValues, - default_ttl_ms: 3600000, // the default_ttl_ms to ms - }), + await waitFor(() => + expect(API.updateTemplateMeta).toBeCalledWith( + "test-template", + expect.objectContaining({ + ...validFormValues, + // convert from the display value (hours) to ms + default_ttl_ms: validFormValues.default_ttl_ms * 3600000, + // this value is undefined if not entitled + max_ttl_ms: undefined, + }), + ), ) }) From 4beff52f503308a016cdb1ee0fd199c1dac802e7 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 6 Mar 2023 21:00:01 +0000 Subject: [PATCH 23/24] fixup! Fix test --- site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 3139234904906..1b3b6b2210f62 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -30,7 +30,7 @@ import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { LazyIconField } from "components/IconField/LazyIconField" import { Maybe } from "components/Conditionals/Maybe" import { useDashboard } from "components/Dashboard/DashboardProvider" -import i18next, { t } from "i18next" +import i18next from "i18next" import Link from "@material-ui/core/Link" import { FormFooter } from "components/FormFooter/FormFooter" import { From 5e97e9651393065ef0cf2831c245dfef06a07007 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 7 Mar 2023 14:02:51 +0000 Subject: [PATCH 24/24] Fix storybook --- site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx | 7 ++----- site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx | 5 +++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 1b3b6b2210f62..f1af33a9d38cf 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -29,7 +29,6 @@ import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuild import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { LazyIconField } from "components/IconField/LazyIconField" import { Maybe } from "components/Conditionals/Maybe" -import { useDashboard } from "components/Dashboard/DashboardProvider" import i18next from "i18next" import Link from "@material-ui/core/Link" import { FormFooter } from "components/FormFooter/FormFooter" @@ -142,6 +141,7 @@ export interface CreateTemplateFormProps { error?: unknown jobError?: string logs?: ProvisionerJobLog[] + canSetMaxTTL: boolean } export const CreateTemplateForm: FC = ({ @@ -155,12 +155,9 @@ export const CreateTemplateForm: FC = ({ error, jobError, logs, + canSetMaxTTL, }) => { const styles = useStyles() - const { entitlements } = useDashboard() - const canSetMaxTTL = - entitlements.features["advanced_template_scheduling"].enabled - const form = useFormik({ initialValues: getInitialValues(canSetMaxTTL, starterTemplate), validationSchema, diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index dabe9628feb4b..4692bbd346b02 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -2,6 +2,7 @@ import { useMachine } from "@xstate/react" import { isApiValidationError } from "api/errors" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" +import { useDashboard } from "components/Dashboard/DashboardProvider" import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" import { Loader } from "components/Loader/Loader" import { Stack } from "components/Stack/Stack" @@ -40,6 +41,9 @@ const CreateTemplatePage: FC = () => { variables, } = state.context const shouldDisplayForm = !state.hasTag("loading") + const { entitlements } = useDashboard() + const canSetMaxTTL = + entitlements.features["advanced_template_scheduling"].enabled const onCancel = () => { navigate(-1) @@ -63,6 +67,7 @@ const CreateTemplatePage: FC = () => { {shouldDisplayForm && (