From cc560caf055523f46e03e88a9c2385e49b2229e1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 10 Oct 2023 09:50:59 -0500 Subject: [PATCH 01/15] chore: Add archive column to template versions --- coderd/database/dbauthz/dbauthz.go | 27 ++ coderd/database/dbauthz/dbauthz_test.go | 35 ++ coderd/database/dbfake/dbfake.go | 97 +++++ coderd/database/dbmetrics/dbmetrics.go | 14 + coderd/database/dbmock/dbmock.go | 29 ++ coderd/database/dump.sql | 4 +- .../000164_archive_template_versions.down.sql | 26 ++ .../000164_archive_template_versions.up.sql | 27 ++ coderd/database/models.go | 4 +- coderd/database/querier.go | 8 + coderd/database/querier_test.go | 160 +++++++ coderd/database/queries.sql.go | 407 ++++++++++++------ coderd/database/queries/templateversions.sql | 95 ++++ docs/admin/audit-logs.md | 2 +- enterprise/audit/table.go | 1 + 15 files changed, 805 insertions(+), 131 deletions(-) create mode 100644 coderd/database/migrations/000164_archive_template_versions.down.sql create mode 100644 coderd/database/migrations/000164_archive_template_versions.up.sql diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5771c6ab00ede..d71dccb20baaf 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -673,6 +673,17 @@ func (q *querier) AllUserIDs(ctx context.Context) ([]uuid.UUID, error) { return q.db.AllUserIDs(ctx) } +func (q *querier) ArchiveUnusedTemplateVersions(ctx context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { + tpl, err := q.db.GetTemplateByID(ctx, arg.TemplateID) + if err != nil { + return nil, err + } + if err := q.authorizeContext(ctx, rbac.ActionUpdate, tpl); err != nil { + return nil, err + } + return q.db.ArchiveUnusedTemplateVersions(ctx, arg) +} + func (q *querier) CleanTailnetCoordinators(ctx context.Context) error { if err := q.authorizeContext(ctx, rbac.ActionDelete, rbac.ResourceTailnetCoordinator); err != nil { return err @@ -2260,6 +2271,22 @@ func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) { return q.db.TryAcquireLock(ctx, id) } +func (q *querier) UnarchiveTemplateVersion(ctx context.Context, arg database.UnarchiveTemplateVersionParams) error { + v, err := q.db.GetTemplateVersionByID(ctx, arg.TemplateVersionID) + if err != nil { + return err + } + + tpl, err := q.db.GetTemplateByID(ctx, v.TemplateID.UUID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, rbac.ActionUpdate, tpl); err != nil { + return err + } + return q.db.UnarchiveTemplateVersion(ctx, arg) +} + func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { fetch := func(ctx context.Context, arg database.UpdateAPIKeyByIDParams) (database.APIKey, error) { return q.db.GetAPIKeyByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a12f721a4b5e5..28866396c8b86 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -342,6 +342,41 @@ func (s *MethodTestSuite) TestGroup() { } func (s *MethodTestSuite) TestProvsionerJob() { + s.Run("ArchiveUnusedTemplateVersions", s.Subtest(func(db database.Store, check *expects) { + j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeTemplateVersionImport, + Error: sql.NullString{ + String: "failed", + Valid: true, + }, + }) + tpl := dbgen.Template(s.T(), db, database.Template{}) + v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + JobID: j.ID, + }) + check.Args(database.ArchiveUnusedTemplateVersionsParams{ + UpdatedAt: dbtime.Now(), + TemplateID: tpl.ID, + TemplateVersionID: uuid.Nil, + JobStatus: database.NullProvisionerJobStatus{}, + }).Asserts(v.RBACObject(tpl), rbac.ActionUpdate) + })) + s.Run("UnarchiveTemplateVersion", s.Subtest(func(db database.Store, check *expects) { + j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + tpl := dbgen.Template(s.T(), db, database.Template{}) + v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}, + JobID: j.ID, + Archived: true, + }) + check.Args(database.UnarchiveTemplateVersionParams{ + UpdatedAt: dbtime.Now(), + TemplateVersionID: v.ID, + }).Asserts(v.RBACObject(tpl), rbac.ActionUpdate) + })) s.Run("Build/GetProvisionerJobByID", s.Subtest(func(db database.Store, check *expects) { w := dbgen.Workspace(s.T(), db, database.Workspace{}) j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index edc5572dd551b..dd00b288d389e 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -846,6 +846,82 @@ func (q *FakeQuerier) AllUserIDs(_ context.Context) ([]uuid.UUID, error) { return userIDs, nil } +func (q *FakeQuerier) ArchiveUnusedTemplateVersions(ctx context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + q.mutex.Lock() + defer q.mutex.Unlock() + type latestBuild struct { + Number int32 + Version uuid.UUID + } + latest := make(map[uuid.UUID]latestBuild) + + for _, b := range q.workspaceBuilds { + v, ok := latest[b.WorkspaceID] + if ok || b.BuildNumber < v.Number { + // Not the latest + continue + } + // Ignore deleted workspaces. + if b.Transition == database.WorkspaceTransitionDelete { + continue + } + latest[b.WorkspaceID] = latestBuild{ + Number: b.BuildNumber, + Version: b.TemplateVersionID, + } + } + + usedVersions := make(map[uuid.UUID]bool) + for _, l := range latest { + usedVersions[l.Version] = true + } + for _, tpl := range q.templates { + usedVersions[tpl.ActiveVersionID] = true + } + + var archived []uuid.UUID + for i, v := range q.templateVersions { + if arg.TemplateVersionID != uuid.Nil { + if v.ID != arg.TemplateVersionID { + continue + } + } + if v.Archived { + continue + } + + if _, ok := usedVersions[v.ID]; !ok { + var job *database.ProvisionerJob + for i, j := range q.provisionerJobs { + if v.JobID == j.ID { + job = &q.provisionerJobs[i] + break + } + } + + if arg.JobStatus.Valid { + if job.JobStatus != arg.JobStatus.ProvisionerJobStatus { + continue + } + } + + if job.JobStatus == database.ProvisionerJobStatusRunning || job.JobStatus == database.ProvisionerJobStatusPending { + continue + } + + v.Archived = true + q.templateVersions[i] = v + archived = append(archived, v.ID) + } + } + + return archived, nil +} + func (*FakeQuerier) CleanTailnetCoordinators(_ context.Context) error { return ErrUnimplemented } @@ -2759,6 +2835,9 @@ func (q *FakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg dat if templateVersion.TemplateID.UUID != arg.TemplateID { continue } + if arg.Archived.Valid && arg.Archived.Bool != templateVersion.Archived { + continue + } version = append(version, q.templateVersionWithUserNoLock(templateVersion)) } @@ -5261,6 +5340,24 @@ func (*FakeQuerier) TryAcquireLock(_ context.Context, _ int64) (bool, error) { return false, xerrors.New("TryAcquireLock must only be called within a transaction") } +func (q *FakeQuerier) UnarchiveTemplateVersion(ctx context.Context, arg database.UnarchiveTemplateVersionParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + for i, v := range q.data.templateVersions { + if v.ID == arg.TemplateVersionID { + v.Archived = false + v.UpdatedAt = arg.UpdatedAt + q.data.templateVersions[i] = v + return nil + } + } + + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index d33ae002f3258..1702b95513490 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -107,6 +107,13 @@ func (m metricsStore) AllUserIDs(ctx context.Context) ([]uuid.UUID, error) { return r0, r1 } +func (m metricsStore) ArchiveUnusedTemplateVersions(ctx context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { + start := time.Now() + r0, r1 := m.s.ArchiveUnusedTemplateVersions(ctx, arg) + m.queryLatencies.WithLabelValues("ArchiveUnusedTemplateVersions").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) CleanTailnetCoordinators(ctx context.Context) error { start := time.Now() err := m.s.CleanTailnetCoordinators(ctx) @@ -1432,6 +1439,13 @@ func (m metricsStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock return ok, err } +func (m metricsStore) UnarchiveTemplateVersion(ctx context.Context, arg database.UnarchiveTemplateVersionParams) error { + start := time.Now() + r0 := m.s.UnarchiveTemplateVersion(ctx, arg) + m.queryLatencies.WithLabelValues("UnarchiveTemplateVersion").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { start := time.Now() err := m.s.UpdateAPIKeyByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index dfc0f6dad5694..8a4c3a298efb5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -97,6 +97,21 @@ func (mr *MockStoreMockRecorder) AllUserIDs(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllUserIDs", reflect.TypeOf((*MockStore)(nil).AllUserIDs), arg0) } +// ArchiveUnusedTemplateVersions mocks base method. +func (m *MockStore) ArchiveUnusedTemplateVersions(arg0 context.Context, arg1 database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ArchiveUnusedTemplateVersions", arg0, arg1) + ret0, _ := ret[0].([]uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ArchiveUnusedTemplateVersions indicates an expected call of ArchiveUnusedTemplateVersions. +func (mr *MockStoreMockRecorder) ArchiveUnusedTemplateVersions(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ArchiveUnusedTemplateVersions", reflect.TypeOf((*MockStore)(nil).ArchiveUnusedTemplateVersions), arg0, arg1) +} + // CleanTailnetCoordinators mocks base method. func (m *MockStore) CleanTailnetCoordinators(arg0 context.Context) error { m.ctrl.T.Helper() @@ -3024,6 +3039,20 @@ func (mr *MockStoreMockRecorder) TryAcquireLock(arg0, arg1 interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TryAcquireLock", reflect.TypeOf((*MockStore)(nil).TryAcquireLock), arg0, arg1) } +// UnarchiveTemplateVersion mocks base method. +func (m *MockStore) UnarchiveTemplateVersion(arg0 context.Context, arg1 database.UnarchiveTemplateVersionParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnarchiveTemplateVersion", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnarchiveTemplateVersion indicates an expected call of UnarchiveTemplateVersion. +func (mr *MockStoreMockRecorder) UnarchiveTemplateVersion(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnarchiveTemplateVersion", reflect.TypeOf((*MockStore)(nil).UnarchiveTemplateVersion), arg0, arg1) +} + // UpdateAPIKeyByID mocks base method. func (m *MockStore) UpdateAPIKeyByID(arg0 context.Context, arg1 database.UpdateAPIKeyByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c806248a18c48..be44222cc03e0 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -676,7 +676,8 @@ CREATE TABLE template_versions ( job_id uuid NOT NULL, created_by uuid NOT NULL, external_auth_providers text[], - message character varying(1048576) DEFAULT ''::character varying NOT NULL + message character varying(1048576) DEFAULT ''::character varying NOT NULL, + archived boolean DEFAULT false NOT NULL ); COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version'; @@ -721,6 +722,7 @@ CREATE VIEW template_version_with_user AS template_versions.created_by, template_versions.external_auth_providers, template_versions.message, + template_versions.archived, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username FROM (public.template_versions diff --git a/coderd/database/migrations/000164_archive_template_versions.down.sql b/coderd/database/migrations/000164_archive_template_versions.down.sql new file mode 100644 index 0000000000000..2c89f985aa225 --- /dev/null +++ b/coderd/database/migrations/000164_archive_template_versions.down.sql @@ -0,0 +1,26 @@ +BEGIN; + +-- The view will be rebuilt with the new column +DROP VIEW template_version_with_user; + +ALTER TABLE template_versions + DROP COLUMN archived; + +-- Restore the old version of the template_version_with_user view. +CREATE VIEW + template_version_with_user +AS +SELECT + template_versions.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username +FROM + template_versions + LEFT JOIN + visible_users + ON + template_versions.created_by = visible_users.id; + +COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/migrations/000164_archive_template_versions.up.sql b/coderd/database/migrations/000164_archive_template_versions.up.sql new file mode 100644 index 0000000000000..d18d4cdfe47e4 --- /dev/null +++ b/coderd/database/migrations/000164_archive_template_versions.up.sql @@ -0,0 +1,27 @@ +BEGIN; + +-- The view will be rebuilt with the new column +DROP VIEW template_version_with_user; + +-- Archived template versions are not visible or usable by default. +ALTER TABLE template_versions + ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE; + +-- Restore the old version of the template_version_with_user view. +CREATE VIEW + template_version_with_user +AS +SELECT + template_versions.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username +FROM + template_versions + LEFT JOIN + visible_users + ON + template_versions.created_by = visible_users.id; + +COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index 5f389f36be4b8..267bd1a7ce7c4 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1942,6 +1942,7 @@ type TemplateVersion struct { CreatedBy uuid.UUID `db:"created_by" json:"created_by"` ExternalAuthProviders []string `db:"external_auth_providers" json:"external_auth_providers"` Message string `db:"message" json:"message"` + Archived bool `db:"archived" json:"archived"` CreatedByAvatarURL sql.NullString `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` } @@ -1995,7 +1996,8 @@ type TemplateVersionTable struct { // IDs of External auth providers for a specific template version ExternalAuthProviders []string `db:"external_auth_providers" json:"external_auth_providers"` // Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact. - Message string `db:"message" json:"message"` + Message string `db:"message" json:"message"` + Archived bool `db:"archived" json:"archived"` } type TemplateVersionVariable struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c6318184185e7..ac7b6faf9641a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -33,6 +33,12 @@ type sqlcQuerier interface { ActivityBumpWorkspace(ctx context.Context, workspaceID uuid.UUID) error // AllUserIDs returns all UserIDs regardless of user status or deletion. AllUserIDs(ctx context.Context) ([]uuid.UUID, error) + // Archiving templates is a soft delete action, so is reversible. + // Archiving prevents the version from being used and discovered + // by listing. + // Only unused template versions will be archived, which are any versions not + // referenced by the latest build of a workspace. + ArchiveUnusedTemplateVersions(ctx context.Context, arg ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) CleanTailnetCoordinators(ctx context.Context) error DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error @@ -281,6 +287,8 @@ type sqlcQuerier interface { // This must be called from within a transaction. The lock will be automatically // released when the transaction ends. TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) + // This will always work regardless of the current state of the template version. + UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 97e5068e3689c..810f4ae6c8a3e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "sort" "testing" "time" @@ -494,6 +495,165 @@ func TestUserChangeLoginType(t *testing.T) { require.Equal(t, bobExpPass, bob.HashedPassword, "hashed password should not change") } +type tvArgs struct { + Status database.ProvisionerJobStatus + // CreateWorkspace is true if we should create a workspace for the template version + CreateWorkspace bool + WorkspaceTransition database.WorkspaceTransition +} + +func createTemplateVersion(t testing.TB, db database.Store, tpl database.Template, args tvArgs) database.TemplateVersion { + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: tpl.ID, + Valid: true, + }, + OrganizationID: tpl.OrganizationID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + CreatedBy: tpl.CreatedBy, + }) + + earlier := sql.NullTime{ + Time: dbtime.Now().Add(time.Second * -30), + Valid: true, + } + now := sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + } + j := database.ProvisionerJob{ + ID: version.JobID, + CreatedAt: earlier.Time, + UpdatedAt: earlier.Time, + Error: sql.NullString{}, + OrganizationID: tpl.OrganizationID, + InitiatorID: tpl.CreatedBy, + Type: database.ProvisionerJobTypeTemplateVersionImport, + } + + switch args.Status { + case database.ProvisionerJobStatusRunning: + j.StartedAt = earlier + case database.ProvisionerJobStatusPending: + case database.ProvisionerJobStatusFailed: + j.StartedAt = earlier + j.CompletedAt = now + j.Error = sql.NullString{ + String: "failed", + Valid: true, + } + j.ErrorCode = sql.NullString{ + String: "failed", + Valid: true, + } + case database.ProvisionerJobStatusSucceeded: + j.StartedAt = earlier + j.CompletedAt = now + default: + t.Fatal(fmt.Sprintf("invalid status: %s", args.Status)) + + } + + dbgen.ProvisionerJob(t, db, nil, j) + if args.CreateWorkspace { + wrk := dbgen.Workspace(t, db, database.Workspace{ + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + OwnerID: tpl.CreatedBy, + OrganizationID: tpl.OrganizationID, + TemplateID: tpl.ID, + }) + trans := database.WorkspaceTransitionStart + if args.WorkspaceTransition != "" { + trans = args.WorkspaceTransition + } + buildJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + CompletedAt: now, + InitiatorID: tpl.CreatedBy, + OrganizationID: tpl.OrganizationID, + }) + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: wrk.ID, + TemplateVersionID: version.ID, + BuildNumber: 1, + Transition: trans, + InitiatorID: tpl.CreatedBy, + JobID: buildJob.ID, + }) + } + return version +} + +func TestArchiveVersions(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + t.Run("ArchiveFailedVersions", func(t *testing.T) { + t.Parallel() + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + ctx := context.Background() + var _ = ctx + + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + // Create some versions + failed := createTemplateVersion(t, db, tpl, tvArgs{ + Status: database.ProvisionerJobStatusFailed, + CreateWorkspace: false, + }) + unused := createTemplateVersion(t, db, tpl, tvArgs{ + Status: database.ProvisionerJobStatusSucceeded, + CreateWorkspace: false, + }) + createTemplateVersion(t, db, tpl, tvArgs{ + Status: database.ProvisionerJobStatusSucceeded, + CreateWorkspace: true, + }) + deleted := createTemplateVersion(t, db, tpl, tvArgs{ + Status: database.ProvisionerJobStatusSucceeded, + CreateWorkspace: true, + WorkspaceTransition: database.WorkspaceTransitionDelete, + }) + + // Now archive failed versions + archived, err := db.ArchiveUnusedTemplateVersions(ctx, database.ArchiveUnusedTemplateVersionsParams{ + UpdatedAt: dbtime.Now(), + TemplateID: tpl.ID, + // All versions + TemplateVersionID: uuid.Nil, + JobStatus: database.NullProvisionerJobStatus{ + ProvisionerJobStatus: database.ProvisionerJobStatusFailed, + Valid: true, + }, + }) + require.NoError(t, err, "archive failed versions") + require.Len(t, archived, 1, "should only archive one version") + require.Equal(t, failed.ID, archived[0], "should archive failed version") + + // Archive all unused versions + archived, err = db.ArchiveUnusedTemplateVersions(ctx, database.ArchiveUnusedTemplateVersionsParams{ + UpdatedAt: dbtime.Now(), + TemplateID: tpl.ID, + // All versions + TemplateVersionID: uuid.Nil, + }) + require.NoError(t, err, "archive failed versions") + require.Len(t, archived, 2) + require.ElementsMatch(t, []uuid.UUID{deleted.ID, unused.ID}, archived, "should archive unused versions") + }) +} + func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) { t.Helper() require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f69eb27729672..fea67c4d31ae0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5319,9 +5319,123 @@ func (q *sqlQuerier) InsertTemplateVersionParameter(ctx context.Context, arg Ins return i, err } +const archiveUnusedTemplateVersions = `-- name: ArchiveUnusedTemplateVersions :many +UPDATE + template_versions +SET + archived = true, + updated_at = $1 +FROM + -- Archive all versions that are returned from this query. + ( + SELECT + scoped_template_versions.id + FROM + -- Scope an archive to a single template and ignore already archived template versions + ( + SELECT + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived + FROM + template_versions + WHERE + template_versions.template_id = $2 :: uuid + AND + archived = false + AND + -- This allows archiving a specific template version. + CASE + WHEN $3::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + template_versions.id = $3 :: uuid + ELSE + true + END + ) AS scoped_template_versions + LEFT JOIN + provisioner_jobs ON scoped_template_versions.job_id = provisioner_jobs.id + LEFT JOIN + templates ON scoped_template_versions.template_id = templates.id + WHERE + -- Actively used template versions (meaning the latest build is using + -- the version) are never archived. A "restart" command on the workspace, + -- even if failed, would use the version. So it cannot be archived until + -- the build is outdated. + NOT EXISTS ( + -- Return all "used" versions, where "used" is defined as being + -- used by a latest workspace build. + SELECT template_version_id FROM ( + SELECT + DISTINCT ON (workspace_id) template_version_id, transition + FROM + workspace_builds + ORDER BY workspace_id, build_number DESC + ) AS used_versions + WHERE + used_versions.transition != 'delete' + AND + scoped_template_versions.id = used_versions.template_version_id + ) + -- Also never archive the active template version + AND active_version_id != scoped_template_versions.id + AND CASE + -- Optionally, only archive versions that match a given + -- job status like 'failed'. + WHEN $4 :: provisioner_job_status IS NOT NULL THEN + provisioner_jobs.job_status = $4 :: provisioner_job_status + ELSE + true + END + -- Pending or running jobs should not be archived, as they are "in progress" + AND provisioner_jobs.job_status != 'running' + AND provisioner_jobs.job_status != 'pending' + ) AS archived_versions +WHERE + template_versions.id IN (archived_versions.id) +RETURNING template_versions.id +` + +type ArchiveUnusedTemplateVersionsParams struct { + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + JobStatus NullProvisionerJobStatus `db:"job_status" json:"job_status"` +} + +// Archiving templates is a soft delete action, so is reversible. +// Archiving prevents the version from being used and discovered +// by listing. +// Only unused template versions will be archived, which are any versions not +// referenced by the latest build of a workspace. +func (q *sqlQuerier) ArchiveUnusedTemplateVersions(ctx context.Context, arg ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { + rows, err := q.db.QueryContext(ctx, archiveUnusedTemplateVersions, + arg.UpdatedAt, + arg.TemplateID, + arg.TemplateVersionID, + arg.JobStatus, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getPreviousTemplateVersion = `-- name: GetPreviousTemplateVersion :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, created_by_avatar_url, created_by_username + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE @@ -5357,6 +5471,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev &i.CreatedBy, pq.Array(&i.ExternalAuthProviders), &i.Message, + &i.Archived, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -5365,7 +5480,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev const getTemplateVersionByID = `-- name: GetTemplateVersionByID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, created_by_avatar_url, created_by_username + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE @@ -5387,6 +5502,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( &i.CreatedBy, pq.Array(&i.ExternalAuthProviders), &i.Message, + &i.Archived, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -5395,7 +5511,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( const getTemplateVersionByJobID = `-- name: GetTemplateVersionByJobID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, created_by_avatar_url, created_by_username + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE @@ -5417,6 +5533,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U &i.CreatedBy, pq.Array(&i.ExternalAuthProviders), &i.Message, + &i.Archived, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -5425,7 +5542,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U const getTemplateVersionByTemplateIDAndName = `-- name: GetTemplateVersionByTemplateIDAndName :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, created_by_avatar_url, created_by_username + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE @@ -5453,6 +5570,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, &i.CreatedBy, pq.Array(&i.ExternalAuthProviders), &i.Message, + &i.Archived, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -5461,7 +5579,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, const getTemplateVersionsByIDs = `-- name: GetTemplateVersionsByIDs :many SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, created_by_avatar_url, created_by_username + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE @@ -5489,6 +5607,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU &i.CreatedBy, pq.Array(&i.ExternalAuthProviders), &i.Message, + &i.Archived, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -5507,16 +5626,23 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU const getTemplateVersionsByTemplateID = `-- name: GetTemplateVersionsByTemplateID :many SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, created_by_avatar_url, created_by_username + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE template_id = $1 :: uuid + AND CASE + -- If no filter is provided, default to returning ALL template versions. + -- The called should always provide a filter if they want to omit + -- archived versions. + WHEN $2 :: boolean IS NULL THEN true + ELSE template_versions.archived = $2 :: boolean + END AND CASE -- This allows using the last element on a page as effectively a cursor. -- This is an important option for scripts that need to paginate without -- duplicating or missing data. - WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( + WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( -- The pagination cursor is the last ID of the previous page. -- The query is ordered by the created_at field, so select all -- rows after the cursor. @@ -5526,7 +5652,7 @@ WHERE FROM template_versions WHERE - id = $2 + id = $3 ) ) ELSE true @@ -5534,22 +5660,24 @@ WHERE ORDER BY -- Deterministic and consistent ordering of all rows, even if they share -- a timestamp. This is to ensure consistent pagination. - (created_at, id) ASC OFFSET $3 + (created_at, id) ASC OFFSET $4 LIMIT -- A null limit means "no limit", so 0 means return all - NULLIF($4 :: int, 0) + NULLIF($5 :: int, 0) ` type GetTemplateVersionsByTemplateIDParams struct { - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - AfterID uuid.UUID `db:"after_id" json:"after_id"` - OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` - LimitOpt int32 `db:"limit_opt" json:"limit_opt"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Archived sql.NullBool `db:"archived" json:"archived"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg GetTemplateVersionsByTemplateIDParams) ([]TemplateVersion, error) { rows, err := q.db.QueryContext(ctx, getTemplateVersionsByTemplateID, arg.TemplateID, + arg.Archived, arg.AfterID, arg.OffsetOpt, arg.LimitOpt, @@ -5573,6 +5701,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge &i.CreatedBy, pq.Array(&i.ExternalAuthProviders), &i.Message, + &i.Archived, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -5590,7 +5719,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge } const getTemplateVersionsCreatedAfter = `-- name: GetTemplateVersionsCreatedAfter :many -SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE created_at > $1 +SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, created_by_avatar_url, created_by_username FROM template_version_with_user AS template_versions WHERE created_at > $1 ` func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) { @@ -5614,6 +5743,7 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create &i.CreatedBy, pq.Array(&i.ExternalAuthProviders), &i.Message, + &i.Archived, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -5677,6 +5807,27 @@ func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTempla return err } +const unarchiveTemplateVersion = `-- name: UnarchiveTemplateVersion :exec +UPDATE + template_versions +SET + archived = false, + updated_at = $1 +WHERE + id = $2 +` + +type UnarchiveTemplateVersionParams struct { + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` +} + +// This will always work regardless of the current state of the template version. +func (q *sqlQuerier) UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error { + _, err := q.db.ExecContext(ctx, unarchiveTemplateVersion, arg.UpdatedAt, arg.TemplateVersionID) + return err +} + const updateTemplateVersionByID = `-- name: UpdateTemplateVersionByID :exec UPDATE template_versions @@ -9572,6 +9723,119 @@ func (q *sqlQuerier) InsertWorkspaceResourceMetadata(ctx context.Context, arg In return items, nil } +const getWorkspaceAgentScriptsByAgentIDs = `-- name: GetWorkspaceAgentScriptsByAgentIDs :many +SELECT workspace_agent_id, log_source_id, log_path, created_at, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds FROM workspace_agent_scripts WHERE workspace_agent_id = ANY($1 :: uuid [ ]) +` + +func (q *sqlQuerier) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentScriptsByAgentIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentScript + for rows.Next() { + var i WorkspaceAgentScript + if err := rows.Scan( + &i.WorkspaceAgentID, + &i.LogSourceID, + &i.LogPath, + &i.CreatedAt, + &i.Script, + &i.Cron, + &i.StartBlocksLogin, + &i.RunOnStart, + &i.RunOnStop, + &i.TimeoutSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertWorkspaceAgentScripts = `-- name: InsertWorkspaceAgentScripts :many +INSERT INTO + workspace_agent_scripts (workspace_agent_id, created_at, log_source_id, log_path, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds) +SELECT + $1 :: uuid AS workspace_agent_id, + $2 :: timestamptz AS created_at, + unnest($3 :: uuid [ ]) AS log_source_id, + unnest($4 :: text [ ]) AS log_path, + unnest($5 :: text [ ]) AS script, + unnest($6 :: text [ ]) AS cron, + unnest($7 :: boolean [ ]) AS start_blocks_login, + unnest($8 :: boolean [ ]) AS run_on_start, + unnest($9 :: boolean [ ]) AS run_on_stop, + unnest($10 :: integer [ ]) AS timeout_seconds +RETURNING workspace_agent_scripts.workspace_agent_id, workspace_agent_scripts.log_source_id, workspace_agent_scripts.log_path, workspace_agent_scripts.created_at, workspace_agent_scripts.script, workspace_agent_scripts.cron, workspace_agent_scripts.start_blocks_login, workspace_agent_scripts.run_on_start, workspace_agent_scripts.run_on_stop, workspace_agent_scripts.timeout_seconds +` + +type InsertWorkspaceAgentScriptsParams struct { + WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + LogSourceID []uuid.UUID `db:"log_source_id" json:"log_source_id"` + LogPath []string `db:"log_path" json:"log_path"` + Script []string `db:"script" json:"script"` + Cron []string `db:"cron" json:"cron"` + StartBlocksLogin []bool `db:"start_blocks_login" json:"start_blocks_login"` + RunOnStart []bool `db:"run_on_start" json:"run_on_start"` + RunOnStop []bool `db:"run_on_stop" json:"run_on_stop"` + TimeoutSeconds []int32 `db:"timeout_seconds" json:"timeout_seconds"` +} + +func (q *sqlQuerier) InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) { + rows, err := q.db.QueryContext(ctx, insertWorkspaceAgentScripts, + arg.WorkspaceAgentID, + arg.CreatedAt, + pq.Array(arg.LogSourceID), + pq.Array(arg.LogPath), + pq.Array(arg.Script), + pq.Array(arg.Cron), + pq.Array(arg.StartBlocksLogin), + pq.Array(arg.RunOnStart), + pq.Array(arg.RunOnStop), + pq.Array(arg.TimeoutSeconds), + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentScript + for rows.Next() { + var i WorkspaceAgentScript + if err := rows.Scan( + &i.WorkspaceAgentID, + &i.LogSourceID, + &i.LogPath, + &i.CreatedAt, + &i.Script, + &i.Cron, + &i.StartBlocksLogin, + &i.RunOnStart, + &i.RunOnStop, + &i.TimeoutSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getDeploymentWorkspaceStats = `-- name: GetDeploymentWorkspaceStats :one WITH workspaces_with_jobs AS ( SELECT @@ -10532,116 +10796,3 @@ func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.C _, err := q.db.ExecContext(ctx, updateWorkspacesDormantDeletingAtByTemplateID, arg.TimeTilDormantAutodeleteMs, arg.DormantAt, arg.TemplateID) return err } - -const getWorkspaceAgentScriptsByAgentIDs = `-- name: GetWorkspaceAgentScriptsByAgentIDs :many -SELECT workspace_agent_id, log_source_id, log_path, created_at, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds FROM workspace_agent_scripts WHERE workspace_agent_id = ANY($1 :: uuid [ ]) -` - -func (q *sqlQuerier) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaceAgentScriptsByAgentIDs, pq.Array(ids)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceAgentScript - for rows.Next() { - var i WorkspaceAgentScript - if err := rows.Scan( - &i.WorkspaceAgentID, - &i.LogSourceID, - &i.LogPath, - &i.CreatedAt, - &i.Script, - &i.Cron, - &i.StartBlocksLogin, - &i.RunOnStart, - &i.RunOnStop, - &i.TimeoutSeconds, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const insertWorkspaceAgentScripts = `-- name: InsertWorkspaceAgentScripts :many -INSERT INTO - workspace_agent_scripts (workspace_agent_id, created_at, log_source_id, log_path, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds) -SELECT - $1 :: uuid AS workspace_agent_id, - $2 :: timestamptz AS created_at, - unnest($3 :: uuid [ ]) AS log_source_id, - unnest($4 :: text [ ]) AS log_path, - unnest($5 :: text [ ]) AS script, - unnest($6 :: text [ ]) AS cron, - unnest($7 :: boolean [ ]) AS start_blocks_login, - unnest($8 :: boolean [ ]) AS run_on_start, - unnest($9 :: boolean [ ]) AS run_on_stop, - unnest($10 :: integer [ ]) AS timeout_seconds -RETURNING workspace_agent_scripts.workspace_agent_id, workspace_agent_scripts.log_source_id, workspace_agent_scripts.log_path, workspace_agent_scripts.created_at, workspace_agent_scripts.script, workspace_agent_scripts.cron, workspace_agent_scripts.start_blocks_login, workspace_agent_scripts.run_on_start, workspace_agent_scripts.run_on_stop, workspace_agent_scripts.timeout_seconds -` - -type InsertWorkspaceAgentScriptsParams struct { - WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - LogSourceID []uuid.UUID `db:"log_source_id" json:"log_source_id"` - LogPath []string `db:"log_path" json:"log_path"` - Script []string `db:"script" json:"script"` - Cron []string `db:"cron" json:"cron"` - StartBlocksLogin []bool `db:"start_blocks_login" json:"start_blocks_login"` - RunOnStart []bool `db:"run_on_start" json:"run_on_start"` - RunOnStop []bool `db:"run_on_stop" json:"run_on_stop"` - TimeoutSeconds []int32 `db:"timeout_seconds" json:"timeout_seconds"` -} - -func (q *sqlQuerier) InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) { - rows, err := q.db.QueryContext(ctx, insertWorkspaceAgentScripts, - arg.WorkspaceAgentID, - arg.CreatedAt, - pq.Array(arg.LogSourceID), - pq.Array(arg.LogPath), - pq.Array(arg.Script), - pq.Array(arg.Cron), - pq.Array(arg.StartBlocksLogin), - pq.Array(arg.RunOnStart), - pq.Array(arg.RunOnStop), - pq.Array(arg.TimeoutSeconds), - ) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceAgentScript - for rows.Next() { - var i WorkspaceAgentScript - if err := rows.Scan( - &i.WorkspaceAgentID, - &i.LogSourceID, - &i.LogPath, - &i.CreatedAt, - &i.Script, - &i.Cron, - &i.StartBlocksLogin, - &i.RunOnStart, - &i.RunOnStop, - &i.TimeoutSeconds, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index db30b8cb17e84..094c1b6014de7 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -5,6 +5,13 @@ FROM template_version_with_user AS template_versions WHERE template_id = @template_id :: uuid + AND CASE + -- If no filter is provided, default to returning ALL template versions. + -- The called should always provide a filter if they want to omit + -- archived versions. + WHEN sqlc.narg('archived') :: boolean IS NULL THEN true + ELSE template_versions.archived = sqlc.narg('archived') :: boolean + END AND CASE -- This allows using the last element on a page as effectively a cursor. -- This is an important option for scripts that need to paginate without @@ -129,3 +136,91 @@ WHERE AND template_id = $3 ORDER BY created_at DESC LIMIT 1; + +-- name: UnarchiveTemplateVersion :exec +-- This will always work regardless of the current state of the template version. +UPDATE + template_versions +SET + archived = false, + updated_at = sqlc.arg('updated_at') +WHERE + id = sqlc.arg('template_version_id'); + +-- name: ArchiveUnusedTemplateVersions :many +-- Archiving templates is a soft delete action, so is reversible. +-- Archiving prevents the version from being used and discovered +-- by listing. +-- Only unused template versions will be archived, which are any versions not +-- referenced by the latest build of a workspace. +UPDATE + template_versions +SET + archived = true, + updated_at = sqlc.arg('updated_at') +FROM + -- Archive all versions that are returned from this query. + ( + SELECT + scoped_template_versions.id + FROM + -- Scope an archive to a single template and ignore already archived template versions + ( + SELECT + * + FROM + template_versions + WHERE + template_versions.template_id = sqlc.arg('template_id') :: uuid + AND + archived = false + AND + -- This allows archiving a specific template version. + CASE + WHEN sqlc.arg('template_version_id')::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + template_versions.id = sqlc.arg('template_version_id') :: uuid + ELSE + true + END + ) AS scoped_template_versions + LEFT JOIN + provisioner_jobs ON scoped_template_versions.job_id = provisioner_jobs.id + LEFT JOIN + templates ON scoped_template_versions.template_id = templates.id + WHERE + -- Actively used template versions (meaning the latest build is using + -- the version) are never archived. A "restart" command on the workspace, + -- even if failed, would use the version. So it cannot be archived until + -- the build is outdated. + NOT EXISTS ( + -- Return all "used" versions, where "used" is defined as being + -- used by a latest workspace build. + SELECT template_version_id FROM ( + SELECT + DISTINCT ON (workspace_id) template_version_id, transition + FROM + workspace_builds + ORDER BY workspace_id, build_number DESC + ) AS used_versions + WHERE + used_versions.transition != 'delete' + AND + scoped_template_versions.id = used_versions.template_version_id + ) + -- Also never archive the active template version + AND active_version_id != scoped_template_versions.id + AND CASE + -- Optionally, only archive versions that match a given + -- job status like 'failed'. + WHEN sqlc.narg('job_status') :: provisioner_job_status IS NOT NULL THEN + provisioner_jobs.job_status = sqlc.narg('job_status') :: provisioner_job_status + ELSE + true + END + -- Pending or running jobs should not be archived, as they are "in progress" + AND provisioner_jobs.job_status != 'running' + AND provisioner_jobs.job_status != 'pending' + ) AS archived_versions +WHERE + template_versions.id IN (archived_versions.id) +RETURNING template_versions.id; diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index a7fdeb6d0a2b0..c1878b1daa9d0 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -16,7 +16,7 @@ We track the following resources: | 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_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| | User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 4a6bc96740b2c..1261e4f6319c4 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -99,6 +99,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "external_auth_providers": ActionIgnore, // Not helpful because this can only change when new versions are added. "created_by_avatar_url": ActionIgnore, "created_by_username": ActionIgnore, + "archived": ActionTrack, }, &database.User{}: { "id": ActionTrack, From ba1310a67bef0164d1e41fec7c99ead5b3cb8274 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 10 Oct 2023 10:01:51 -0500 Subject: [PATCH 02/15] Linting --- coderd/database/dbfake/dbfake.go | 4 ++-- coderd/database/querier_test.go | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index dd00b288d389e..e9b6582bc6b32 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -846,7 +846,7 @@ func (q *FakeQuerier) AllUserIDs(_ context.Context) ([]uuid.UUID, error) { return userIDs, nil } -func (q *FakeQuerier) ArchiveUnusedTemplateVersions(ctx context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { +func (q *FakeQuerier) ArchiveUnusedTemplateVersions(_ context.Context, arg database.ArchiveUnusedTemplateVersionsParams) ([]uuid.UUID, error) { err := validateDatabaseType(arg) if err != nil { return nil, err @@ -5340,7 +5340,7 @@ func (*FakeQuerier) TryAcquireLock(_ context.Context, _ int64) (bool, error) { return false, xerrors.New("TryAcquireLock must only be called within a transaction") } -func (q *FakeQuerier) UnarchiveTemplateVersion(ctx context.Context, arg database.UnarchiveTemplateVersionParams) error { +func (q *FakeQuerier) UnarchiveTemplateVersion(_ context.Context, arg database.UnarchiveTemplateVersionParams) error { err := validateDatabaseType(arg) if err != nil { return err diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 810f4ae6c8a3e..cf6072c5a60ab 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -6,7 +6,6 @@ import ( "context" "database/sql" "encoding/json" - "fmt" "sort" "testing" "time" @@ -551,8 +550,7 @@ func createTemplateVersion(t testing.TB, db database.Store, tpl database.Templat j.StartedAt = earlier j.CompletedAt = now default: - t.Fatal(fmt.Sprintf("invalid status: %s", args.Status)) - + t.Fatalf("invalid status: %s", args.Status) } dbgen.ProvisionerJob(t, db, nil, j) From b804fcf57eb1eec9eb36a93c88f9c3519e091117 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 10 Oct 2023 15:03:12 +0000 Subject: [PATCH 03/15] Make gen --- coderd/database/queries.sql.go | 226 ++++++++++++++++----------------- 1 file changed, 113 insertions(+), 113 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fea67c4d31ae0..ca8c1009f709d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9723,119 +9723,6 @@ func (q *sqlQuerier) InsertWorkspaceResourceMetadata(ctx context.Context, arg In return items, nil } -const getWorkspaceAgentScriptsByAgentIDs = `-- name: GetWorkspaceAgentScriptsByAgentIDs :many -SELECT workspace_agent_id, log_source_id, log_path, created_at, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds FROM workspace_agent_scripts WHERE workspace_agent_id = ANY($1 :: uuid [ ]) -` - -func (q *sqlQuerier) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaceAgentScriptsByAgentIDs, pq.Array(ids)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceAgentScript - for rows.Next() { - var i WorkspaceAgentScript - if err := rows.Scan( - &i.WorkspaceAgentID, - &i.LogSourceID, - &i.LogPath, - &i.CreatedAt, - &i.Script, - &i.Cron, - &i.StartBlocksLogin, - &i.RunOnStart, - &i.RunOnStop, - &i.TimeoutSeconds, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const insertWorkspaceAgentScripts = `-- name: InsertWorkspaceAgentScripts :many -INSERT INTO - workspace_agent_scripts (workspace_agent_id, created_at, log_source_id, log_path, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds) -SELECT - $1 :: uuid AS workspace_agent_id, - $2 :: timestamptz AS created_at, - unnest($3 :: uuid [ ]) AS log_source_id, - unnest($4 :: text [ ]) AS log_path, - unnest($5 :: text [ ]) AS script, - unnest($6 :: text [ ]) AS cron, - unnest($7 :: boolean [ ]) AS start_blocks_login, - unnest($8 :: boolean [ ]) AS run_on_start, - unnest($9 :: boolean [ ]) AS run_on_stop, - unnest($10 :: integer [ ]) AS timeout_seconds -RETURNING workspace_agent_scripts.workspace_agent_id, workspace_agent_scripts.log_source_id, workspace_agent_scripts.log_path, workspace_agent_scripts.created_at, workspace_agent_scripts.script, workspace_agent_scripts.cron, workspace_agent_scripts.start_blocks_login, workspace_agent_scripts.run_on_start, workspace_agent_scripts.run_on_stop, workspace_agent_scripts.timeout_seconds -` - -type InsertWorkspaceAgentScriptsParams struct { - WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - LogSourceID []uuid.UUID `db:"log_source_id" json:"log_source_id"` - LogPath []string `db:"log_path" json:"log_path"` - Script []string `db:"script" json:"script"` - Cron []string `db:"cron" json:"cron"` - StartBlocksLogin []bool `db:"start_blocks_login" json:"start_blocks_login"` - RunOnStart []bool `db:"run_on_start" json:"run_on_start"` - RunOnStop []bool `db:"run_on_stop" json:"run_on_stop"` - TimeoutSeconds []int32 `db:"timeout_seconds" json:"timeout_seconds"` -} - -func (q *sqlQuerier) InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) { - rows, err := q.db.QueryContext(ctx, insertWorkspaceAgentScripts, - arg.WorkspaceAgentID, - arg.CreatedAt, - pq.Array(arg.LogSourceID), - pq.Array(arg.LogPath), - pq.Array(arg.Script), - pq.Array(arg.Cron), - pq.Array(arg.StartBlocksLogin), - pq.Array(arg.RunOnStart), - pq.Array(arg.RunOnStop), - pq.Array(arg.TimeoutSeconds), - ) - if err != nil { - return nil, err - } - defer rows.Close() - var items []WorkspaceAgentScript - for rows.Next() { - var i WorkspaceAgentScript - if err := rows.Scan( - &i.WorkspaceAgentID, - &i.LogSourceID, - &i.LogPath, - &i.CreatedAt, - &i.Script, - &i.Cron, - &i.StartBlocksLogin, - &i.RunOnStart, - &i.RunOnStop, - &i.TimeoutSeconds, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getDeploymentWorkspaceStats = `-- name: GetDeploymentWorkspaceStats :one WITH workspaces_with_jobs AS ( SELECT @@ -10796,3 +10683,116 @@ func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.C _, err := q.db.ExecContext(ctx, updateWorkspacesDormantDeletingAtByTemplateID, arg.TimeTilDormantAutodeleteMs, arg.DormantAt, arg.TemplateID) return err } + +const getWorkspaceAgentScriptsByAgentIDs = `-- name: GetWorkspaceAgentScriptsByAgentIDs :many +SELECT workspace_agent_id, log_source_id, log_path, created_at, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds FROM workspace_agent_scripts WHERE workspace_agent_id = ANY($1 :: uuid [ ]) +` + +func (q *sqlQuerier) GetWorkspaceAgentScriptsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgentScript, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentScriptsByAgentIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentScript + for rows.Next() { + var i WorkspaceAgentScript + if err := rows.Scan( + &i.WorkspaceAgentID, + &i.LogSourceID, + &i.LogPath, + &i.CreatedAt, + &i.Script, + &i.Cron, + &i.StartBlocksLogin, + &i.RunOnStart, + &i.RunOnStop, + &i.TimeoutSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertWorkspaceAgentScripts = `-- name: InsertWorkspaceAgentScripts :many +INSERT INTO + workspace_agent_scripts (workspace_agent_id, created_at, log_source_id, log_path, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds) +SELECT + $1 :: uuid AS workspace_agent_id, + $2 :: timestamptz AS created_at, + unnest($3 :: uuid [ ]) AS log_source_id, + unnest($4 :: text [ ]) AS log_path, + unnest($5 :: text [ ]) AS script, + unnest($6 :: text [ ]) AS cron, + unnest($7 :: boolean [ ]) AS start_blocks_login, + unnest($8 :: boolean [ ]) AS run_on_start, + unnest($9 :: boolean [ ]) AS run_on_stop, + unnest($10 :: integer [ ]) AS timeout_seconds +RETURNING workspace_agent_scripts.workspace_agent_id, workspace_agent_scripts.log_source_id, workspace_agent_scripts.log_path, workspace_agent_scripts.created_at, workspace_agent_scripts.script, workspace_agent_scripts.cron, workspace_agent_scripts.start_blocks_login, workspace_agent_scripts.run_on_start, workspace_agent_scripts.run_on_stop, workspace_agent_scripts.timeout_seconds +` + +type InsertWorkspaceAgentScriptsParams struct { + WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + LogSourceID []uuid.UUID `db:"log_source_id" json:"log_source_id"` + LogPath []string `db:"log_path" json:"log_path"` + Script []string `db:"script" json:"script"` + Cron []string `db:"cron" json:"cron"` + StartBlocksLogin []bool `db:"start_blocks_login" json:"start_blocks_login"` + RunOnStart []bool `db:"run_on_start" json:"run_on_start"` + RunOnStop []bool `db:"run_on_stop" json:"run_on_stop"` + TimeoutSeconds []int32 `db:"timeout_seconds" json:"timeout_seconds"` +} + +func (q *sqlQuerier) InsertWorkspaceAgentScripts(ctx context.Context, arg InsertWorkspaceAgentScriptsParams) ([]WorkspaceAgentScript, error) { + rows, err := q.db.QueryContext(ctx, insertWorkspaceAgentScripts, + arg.WorkspaceAgentID, + arg.CreatedAt, + pq.Array(arg.LogSourceID), + pq.Array(arg.LogPath), + pq.Array(arg.Script), + pq.Array(arg.Cron), + pq.Array(arg.StartBlocksLogin), + pq.Array(arg.RunOnStart), + pq.Array(arg.RunOnStop), + pq.Array(arg.TimeoutSeconds), + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgentScript + for rows.Next() { + var i WorkspaceAgentScript + if err := rows.Scan( + &i.WorkspaceAgentID, + &i.LogSourceID, + &i.LogPath, + &i.CreatedAt, + &i.Script, + &i.Cron, + &i.StartBlocksLogin, + &i.RunOnStart, + &i.RunOnStop, + &i.TimeoutSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} From b9160ae5adeef3bb8759964aa2b0eb01c1537082 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 10 Oct 2023 10:05:29 -0500 Subject: [PATCH 04/15] Add comment on unit test --- coderd/database/querier_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index cf6072c5a60ab..8b55d6b5c1794 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -501,7 +501,9 @@ type tvArgs struct { WorkspaceTransition database.WorkspaceTransition } +// createTemplateVersion is a helper function to create a version with its dependencies. func createTemplateVersion(t testing.TB, db database.Store, tpl database.Template, args tvArgs) database.TemplateVersion { + t.Helper() version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ TemplateID: uuid.NullUUID{ UUID: tpl.ID, From 49b509b127227d4f1e2755ea8a0751b664567f59 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 10 Oct 2023 10:09:30 -0500 Subject: [PATCH 05/15] Linting --- coderd/database/querier_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 8b55d6b5c1794..4fd0579aff242 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -599,7 +599,6 @@ func TestArchiveVersions(t *testing.T) { require.NoError(t, err) db := database.New(sqlDB) ctx := context.Background() - var _ = ctx org := dbgen.Organization(t, db, database.Organization{}) user := dbgen.User(t, db, database.User{}) From 7c09da253b732ac74a4ee7398dbfbaad6415701e Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 10 Oct 2023 09:56:56 -0500 Subject: [PATCH 06/15] feat: add archiving template versions to hide from ui --- cli/templatedelete.go | 28 +--- cli/templates.go | 37 ++++- cli/templateversionarchive.go | 184 +++++++++++++++++++++ cli/templateversions.go | 24 ++- coderd/apidoc/docs.go | 129 +++++++++++++++ coderd/apidoc/swagger.json | 115 +++++++++++++ coderd/coderd.go | 3 + coderd/httpapi/queryparams.go | 11 ++ coderd/httpapi/queryparams_test.go | 42 +++++ coderd/templates.go | 9 ++ coderd/templateversions.go | 195 +++++++++++++++++++++++ coderd/templateversions_test.go | 114 +++++++++++++ coderd/workspaces.go | 12 ++ codersdk/templates.go | 58 ++++++- codersdk/templateversions.go | 1 + docs/api/schemas.md | 16 ++ docs/api/templates.md | 151 ++++++++++++++++++ docs/cli/templates.md | 1 + docs/cli/templates_archive.md | 29 ++++ docs/cli/templates_versions.md | 8 +- docs/cli/templates_versions_archive.md | 21 +++ docs/cli/templates_versions_list.md | 18 ++- docs/cli/templates_versions_unarchive.md | 21 +++ docs/manifest.json | 15 ++ site/src/api/typesGenerated.ts | 13 ++ site/src/testHelpers/entities.ts | 3 + 26 files changed, 1221 insertions(+), 37 deletions(-) create mode 100644 cli/templateversionarchive.go create mode 100644 docs/cli/templates_archive.md create mode 100644 docs/cli/templates_versions_archive.md create mode 100644 docs/cli/templates_versions_unarchive.md diff --git a/cli/templatedelete.go b/cli/templatedelete.go index 6cb4213a93895..e15fe4bd48722 100644 --- a/cli/templatedelete.go +++ b/cli/templatedelete.go @@ -48,33 +48,13 @@ func (r *RootCmd) templateDelete() *clibase.Cmd { templates = append(templates, template) } } else { - allTemplates, err := client.TemplatesByOrganization(ctx, organization.ID) + template, err := selectTemplate(inv, client, organization) if err != nil { - return xerrors.Errorf("get templates by organization: %w", err) + return err } - if len(allTemplates) == 0 { - return xerrors.Errorf("no templates exist in the current organization %q", organization.Name) - } - - opts := make([]string, 0, len(allTemplates)) - for _, template := range allTemplates { - opts = append(opts, template.Name) - } - - selection, err := cliui.Select(inv, cliui.SelectOptions{ - Options: opts, - }) - if err != nil { - return xerrors.Errorf("select template: %w", err) - } - - for _, template := range allTemplates { - if template.Name == selection { - templates = append(templates, template) - templateNames = append(templateNames, template.Name) - } - } + templates = append(templates, template) + templateNames = append(templateNames, template.Name) } // Confirm deletion of the template. diff --git a/cli/templates.go b/cli/templates.go index 3d24ec14b5ccc..d7cd02a467ef2 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -3,9 +3,9 @@ package cli import ( "time" - "github.com/google/uuid" - "github.com/coder/pretty" + "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" @@ -43,12 +43,45 @@ func (r *RootCmd) templates() *clibase.Cmd { r.templateVersions(), r.templateDelete(), r.templatePull(), + r.archiveTemplateVersions(), }, } return cmd } +func selectTemplate(inv *clibase.Invocation, client *codersdk.Client, organization codersdk.Organization) (codersdk.Template, error) { + var empty codersdk.Template + ctx := inv.Context() + allTemplates, err := client.TemplatesByOrganization(ctx, organization.ID) + if err != nil { + return empty, xerrors.Errorf("get templates by organization: %w", err) + } + + if len(allTemplates) == 0 { + return empty, xerrors.Errorf("no templates exist in the current organization %q", organization.Name) + } + + opts := make([]string, 0, len(allTemplates)) + for _, template := range allTemplates { + opts = append(opts, template.Name) + } + + selection, err := cliui.Select(inv, cliui.SelectOptions{ + Options: opts, + }) + if err != nil { + return empty, xerrors.Errorf("select template: %w", err) + } + + for _, template := range allTemplates { + if template.Name == selection { + return template, nil + } + } + return empty, xerrors.Errorf("no template selected") +} + type templateTableRow struct { // Used by json format: Template codersdk.Template diff --git a/cli/templateversionarchive.go b/cli/templateversionarchive.go new file mode 100644 index 0000000000000..edb7aa4c656c1 --- /dev/null +++ b/cli/templateversionarchive.go @@ -0,0 +1,184 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/coder/pretty" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clibase" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" +) + +func (r *RootCmd) unarchiveTemplateVersion() *clibase.Cmd { + return r.setArchiveTemplateVersion(false) +} + +func (r *RootCmd) archiveTemplateVersion() *clibase.Cmd { + return r.setArchiveTemplateVersion(true) +} + +//nolint:revive +func (r *RootCmd) setArchiveTemplateVersion(archive bool) *clibase.Cmd { + presentVerb := "archive" + pastVerb := "archived" + if !archive { + presentVerb = "unarchive" + pastVerb = "unarchived" + } + + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: presentVerb + " [template-version-names...] ", + Short: strings.ToUpper(string(presentVerb[0])) + presentVerb[1:] + " a template version(s).", + Middleware: clibase.Chain( + r.InitClient(client), + ), + Options: clibase.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *clibase.Invocation) error { + var ( + ctx = inv.Context() + versions []codersdk.TemplateVersion + ) + + organization, err := CurrentOrganization(inv, client) + if err != nil { + return err + } + + if len(inv.Args) == 0 { + return xerrors.Errorf("missing template name") + } + if len(inv.Args) < 2 { + return xerrors.Errorf("missing template version name(s)") + } + + templateName := inv.Args[0] + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + for _, versionName := range inv.Args[1:] { + version, err := client.TemplateVersionByOrganizationAndName(ctx, organization.ID, template.Name, versionName) + if err != nil { + return xerrors.Errorf("get template version by name %q: %w", versionName, err) + } + versions = append(versions, version) + } + + for _, version := range versions { + if version.Archived == archive { + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Version "+pretty.Sprint(cliui.DefaultStyles.Keyword, version.Name)+" already "+pastVerb), + ) + continue + } + + err := client.SetArchiveTemplateVersion(ctx, version.ID, archive) + if err != nil { + return xerrors.Errorf("%s template version %q: %w", presentVerb, version.Name, err) + } + + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Version "+pretty.Sprint(cliui.DefaultStyles.Keyword, version.Name)+" "+pastVerb+" at "+cliui.Timestamp(time.Now())), + ) + } + return nil + }, + } + + return cmd +} + +func (r *RootCmd) archiveTemplateVersions() *clibase.Cmd { + var all clibase.Bool + client := new(codersdk.Client) + cmd := &clibase.Cmd{ + Use: "archive [template-name...] ", + Short: "Archive unused failed template versions from a given template(s)", + Middleware: clibase.Chain( + r.InitClient(client), + ), + Options: clibase.OptionSet{ + cliui.SkipPromptOption(), + clibase.Option{ + Name: "all", + Description: "Include all unused template versions. By default, only failed template versions are archived.", + Flag: "all", + Value: &all, + }, + }, + Handler: func(inv *clibase.Invocation) error { + var ( + ctx = inv.Context() + templateNames = []string{} + templates = []codersdk.Template{} + ) + + organization, err := CurrentOrganization(inv, client) + if err != nil { + return err + } + + if len(inv.Args) > 0 { + templateNames = inv.Args + + for _, templateName := range templateNames { + template, err := client.TemplateByName(ctx, organization.ID, templateName) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + templates = append(templates, template) + } + } else { + template, err := selectTemplate(inv, client, organization) + if err != nil { + return err + } + + templates = append(templates, template) + templateNames = append(templateNames, template.Name) + } + + // Confirm archive of the template. + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Archive template versions of these templates: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(templateNames, ", "))), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + for _, template := range templates { + resp, err := client.ArchiveTemplateVersions(ctx, template.ID, all.Value()) + if err != nil { + return xerrors.Errorf("archive template %q: %w", template.Name, err) + } + + _, _ = fmt.Fprintln( + inv.Stdout, fmt.Sprintf("Archived %d versions from "+pretty.Sprint(cliui.DefaultStyles.Keyword, template.Name)+" at "+cliui.Timestamp(time.Now()), len(resp.ArchivedIDs)), + ) + + if ok, _ := inv.ParsedFlags().GetBool("verbose"); err == nil && ok { + data, err := json.Marshal(resp) + if err != nil { + return xerrors.Errorf("marshal verbose response: %w", err) + } + _, _ = fmt.Fprintln( + inv.Stdout, string(data), + ) + } + } + return nil + }, + } + + return cmd +} diff --git a/cli/templateversions.go b/cli/templateversions.go index 299ae98e96b23..0accfb58f9497 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/coder/pretty" + "github.com/google/uuid" "golang.org/x/xerrors" @@ -29,6 +31,8 @@ func (r *RootCmd) templateVersions() *clibase.Cmd { }, Children: []*clibase.Cmd{ r.templateVersionsList(), + r.archiveTemplateVersion(), + r.unarchiveTemplateVersion(), }, } @@ -42,6 +46,8 @@ func (r *RootCmd) templateVersionsList() *clibase.Cmd { ) client := new(codersdk.Client) + var includeArchived clibase.Bool + cmd := &clibase.Cmd{ Use: "list