chore: add archive column to template versions (#10178)

* chore: add archive column to template versions
This commit is contained in:
Steven Masley 2023-10-10 10:52:42 -05:00 committed by GitHub
parent c11f241622
commit 69d13f1676
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 691 additions and 18 deletions

View File

@ -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)

View File

@ -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{

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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 {

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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;

View File

@ -16,7 +16,7 @@ We track the following resources:
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |

View File

@ -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,