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..e9b6582bc6b32 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(_ 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(_ 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..4fd0579aff242 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -494,6 +494,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
+}
+
+// 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,
+ 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.Fatalf("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()
+
+ 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..ca8c1009f709d 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
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 |
Field | Tracked |
---|
created_at | false |
private_key | true |
public_key | true |
updated_at | false |
user_id | true |
|
| License
create, delete | Field | Tracked |
---|
exp | true |
id | false |
jwt | false |
uploaded_at | true |
uuid | true |
|
| Template
write, delete | Field | Tracked |
---|
active_version_id | true |
allow_user_autostart | true |
allow_user_autostop | true |
allow_user_cancel_workspace_jobs | true |
autostop_requirement_days_of_week | true |
autostop_requirement_weeks | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_username | false |
default_ttl | true |
deleted | false |
description | true |
display_name | true |
failure_ttl | true |
group_acl | true |
icon | true |
id | true |
max_ttl | true |
name | true |
organization_id | false |
provisioner | true |
time_til_dormant | true |
time_til_dormant_autodelete | true |
updated_at | false |
user_acl | true |
|
-| TemplateVersion
create, write | Field | Tracked |
---|
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_username | false |
external_auth_providers | false |
id | true |
job_id | false |
message | false |
name | true |
organization_id | false |
readme | true |
template_id | true |
updated_at | false |
|
+| TemplateVersion
create, write | Field | Tracked |
---|
archived | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_username | false |
external_auth_providers | false |
id | true |
job_id | false |
message | false |
name | true |
organization_id | false |
readme | true |
template_id | true |
updated_at | false |
|
| User
create, write, delete | Field | Tracked |
---|
avatar_url | false |
created_at | false |
deleted | true |
email | true |
hashed_password | true |
id | true |
last_seen_at | false |
login_type | true |
quiet_hours_schedule | true |
rbac_roles | true |
status | true |
updated_at | false |
username | true |
|
| Workspace
create, write, delete | Field | Tracked |
---|
automatic_updates | true |
autostart_schedule | true |
created_at | false |
deleted | false |
deleting_at | true |
dormant_at | true |
id | true |
last_used_at | false |
name | true |
organization_id | false |
owner_id | true |
template_id | true |
ttl | true |
updated_at | false |
|
| WorkspaceBuild
start, stop | Field | Tracked |
---|
build_number | false |
created_at | false |
daily_cost | false |
deadline | false |
id | false |
initiator_by_avatar_url | false |
initiator_by_username | false |
initiator_id | false |
job_id | false |
max_deadline | false |
provisioner_state | false |
reason | false |
template_version_id | true |
transition | false |
updated_at | false |
workspace_id | false |
|
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,