Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit dc1a54c

Browse files
authored
chore: purge identified terraform modules via dbpurge (#28802)
1 parent dd1aa88 commit dc1a54c

10 files changed

Lines changed: 314 additions & 0 deletions

File tree

coderd/database/dbauthz/dbauthz.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2198,6 +2198,13 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u
21982198
return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID)
21992199
}
22002200

2201+
func (q *querier) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg database.DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) {
2202+
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
2203+
return 0, err
2204+
}
2205+
return q.db.DeleteCachedModuleFilesCreatedBetween(ctx, arg)
2206+
}
2207+
22012208
func (q *querier) DeleteChatContextResourcesByChatID(ctx context.Context, chatID uuid.UUID) error {
22022209
chat, err := q.db.GetChatByID(ctx, chatID)
22032210
if err != nil {

coderd/database/dbauthz/dbauthz_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5429,6 +5429,11 @@ func (s *MethodTestSuite) TestSystemFunctions() {
54295429
dbm.EXPECT().DeleteOldWorkspaceAgentLogs(gomock.Any(), t).Return(int64(0), nil).AnyTimes()
54305430
check.Args(t).Asserts(rbac.ResourceSystem, policy.ActionDelete)
54315431
}))
5432+
s.Run("DeleteCachedModuleFilesCreatedBetween", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
5433+
arg := database.DeleteCachedModuleFilesCreatedBetweenParams{}
5434+
dbm.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), arg).Return(int64(0), nil).AnyTimes()
5435+
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionDelete)
5436+
}))
54325437
s.Run("InsertWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
54335438
arg := database.InsertWorkspaceAgentStatsParams{}
54345439
dbm.EXPECT().InsertWorkspaceAgentStats(gomock.Any(), arg).Return(xerrors.New("any error")).AnyTimes()

coderd/database/dbmetrics/querymetrics.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbmock/dbmock.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbpurge/dbpurge.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ const (
5959
chatSearchBackfillMaxBatches = 5
6060
)
6161

62+
// Terraform module archives ingested during this window may contain the
63+
// identified upstream module.
64+
//
65+
// This is a one-off cleanup, not a recurring purge. It runs once per coderd
66+
// process because the window is fixed in the past: after a successful pass
67+
// there is nothing left to match. It lives here rather than in a migration
68+
// because migrations cannot be backported. The version table records a single
69+
// high-water mark, so a migration cherry-picked onto a release branch would
70+
// cause later upgrades to skip every migration in between.
71+
var (
72+
identifiedModuleCacheStart = time.Date(2026, 8, 31, 8, 0, 0, 0, time.UTC)
73+
identifiedModuleCacheEnd = time.Date(2026, 8, 31, 22, 0, 0, 0, time.UTC)
74+
)
75+
6276
type Option func(*instance)
6377

6478
// WithClock overrides the clock used by the purger. Defaults to
@@ -185,6 +199,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
185199
// after the transaction commits.
186200
var staleDrained bool
187201

202+
// Latched after a successful commit so the one-off module cache cleanup
203+
// is attempted again if the transaction rolls back.
204+
ranModuleCachePurge := false
205+
188206
// Start a transaction to grab advisory lock, we don't want to run
189207
// multiple purges at the same time (multiple replicas).
190208
err := db.InTx(func(tx database.Store) error {
@@ -380,6 +398,20 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
380398
}
381399
backfilledChatSearchRows += reindexedChatSearchRows
382400

401+
// One-off cleanup of the identified Terraform module cache. Skipped
402+
// once this process has completed a pass.
403+
var purgedIdentifiedModuleFiles int64
404+
if !i.identifiedModuleCachePurged {
405+
purgedIdentifiedModuleFiles, err = tx.DeleteCachedModuleFilesCreatedBetween(ctx, database.DeleteCachedModuleFilesCreatedBetweenParams{
406+
CreatedAtAfter: identifiedModuleCacheStart,
407+
CreatedAtBefore: identifiedModuleCacheEnd,
408+
})
409+
if err != nil {
410+
return xerrors.Errorf("failed to delete identified module cache files: %w", err)
411+
}
412+
ranModuleCachePurge = true
413+
}
414+
383415
i.logger.Debug(ctx, "purged old database entries",
384416
slog.F("workspace_agent_logs", purgedWorkspaceAgentLogs),
385417
slog.F("expired_api_keys", expiredAPIKeys),
@@ -393,6 +425,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
393425
slog.F("chat_files", purgedChatFiles),
394426
slog.F("chat_debug_runs", purgedChatDebugRuns),
395427
slog.F("chat_search_rows_backfilled", backfilledChatSearchRows),
428+
slog.F("identified_module_files", purgedIdentifiedModuleFiles),
396429
slog.F("duration", i.clk.Since(start)),
397430
)
398431

@@ -408,6 +441,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
408441
i.recordsPurged.WithLabelValues("chats").Add(float64(purgedChats))
409442
i.recordsPurged.WithLabelValues("chat_debug_runs").Add(float64(purgedChatDebugRuns))
410443
i.recordsPurged.WithLabelValues("chat_files").Add(float64(purgedChatFiles))
444+
i.recordsPurged.WithLabelValues("identified_module_files").Add(float64(purgedIdentifiedModuleFiles))
411445
}
412446
if i.chatSearchRowsBackfilled != nil {
413447
i.chatSearchRowsBackfilled.Add(float64(backfilledChatSearchRows))
@@ -429,6 +463,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
429463
i.chatSearchStaleDrained = true
430464
}
431465

466+
if ranModuleCachePurge {
467+
i.identifiedModuleCachePurged = true
468+
}
469+
432470
// Surface the deferred chat-config error so doTick records
433471
// the failed iteration metric.
434472
if chatConfigErr != nil {
@@ -450,6 +488,11 @@ type instance struct {
450488
chatSearchBackfillBatchSize int32
451489
chatSearchBackfillMaxBatches int
452490
chatSearchStaleDrained bool
491+
492+
// identifiedModuleCachePurged latches once this process has completed a
493+
// pass of the one-off module cache cleanup. The window is fixed in the
494+
// past, so a completed pass leaves nothing to match on later ticks.
495+
identifiedModuleCachePurged bool
453496
}
454497

455498
func (i *instance) Close() error {

coderd/database/dbpurge/dbpurge_internal_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,17 @@ func TestDBPurgeAuthorization(t *testing.T) {
4343
err := inst.purgeTick(ctx, db, now)
4444
require.NoError(t, err)
4545
}
46+
47+
// The behavior of the one-off module cache cleanup is covered by
48+
// TestDeleteIdentifiedModuleCacheFiles, which supplies its own window. This
49+
// guards the production constants themselves, which that test no longer reads.
50+
func TestIdentifiedModuleCacheWindow(t *testing.T) {
51+
t.Parallel()
52+
53+
require.True(t, identifiedModuleCacheStart.Before(identifiedModuleCacheEnd),
54+
"window start must precede window end")
55+
require.Equal(t, time.UTC, identifiedModuleCacheStart.Location(),
56+
"window bounds must be UTC so they do not shift with the host timezone")
57+
require.Equal(t, time.UTC, identifiedModuleCacheEnd.Location(),
58+
"window bounds must be UTC so they do not shift with the host timezone")
59+
}

coderd/database/dbpurge/dbpurge_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
"context"
7+
"crypto/sha256"
78
"database/sql"
89
"encoding/json"
910
"fmt"
@@ -257,6 +258,7 @@ func TestMetrics(t *testing.T) {
257258
mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
258259
mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes()
259260
mDB.EXPECT().ReindexStaleChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes()
261+
mDB.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteCachedModuleFilesCreatedBetweenParams{})).Return(int64(0), nil).AnyTimes()
260262
mDB.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatDebugRunsParams{})).Return(int64(0), nil).MinTimes(1)
261263
mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).
262264
DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error {
@@ -310,6 +312,7 @@ func TestMetrics(t *testing.T) {
310312
mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
311313
mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes()
312314
mDB.EXPECT().ReindexStaleChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes()
315+
mDB.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteCachedModuleFilesCreatedBetweenParams{})).Return(int64(0), nil).AnyTimes()
313316
mDB.EXPECT().DeleteOldChats(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatsParams{})).Return(int64(0), nil).MinTimes(1)
314317
mDB.EXPECT().DeleteOldChatFiles(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatFilesParams{})).Return(int64(0), nil).MinTimes(1)
315318
mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).
@@ -3425,3 +3428,126 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) {
34253428
testutil.TryReceive(ctx, t, done)
34263429
})
34273430
}
3431+
3432+
//nolint:paralleltest // It uses LockIDDBPurge.
3433+
func TestDeleteIdentifiedModuleCacheFiles(t *testing.T) {
3434+
ctx := testutil.Context(t, testutil.WaitShort)
3435+
clk := quartz.NewMock(t)
3436+
clk.Set(dbtime.Now()).MustWait(ctx)
3437+
3438+
// The window under test is supplied explicitly rather than copied from the
3439+
// production constants, so revising the incident timestamps cannot silently
3440+
// invalidate these boundary assertions.
3441+
windowStart := time.Date(2026, 8, 31, 8, 0, 0, 0, time.UTC)
3442+
windowEnd := time.Date(2026, 8, 31, 22, 0, 0, 0, time.UTC)
3443+
inWindow := windowStart.Add(time.Minute)
3444+
3445+
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
3446+
org := dbgen.Organization(t, db, database.Organization{})
3447+
user := dbgen.User(t, db, database.User{})
3448+
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
3449+
3450+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
3451+
3452+
mkFile := func(name string, createdBy uuid.UUID, mimetype string, createdAt time.Time) database.File {
3453+
file, err := db.InsertFile(ctx, database.InsertFileParams{
3454+
ID: uuid.New(),
3455+
Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(name))),
3456+
CreatedBy: createdBy,
3457+
CreatedAt: createdAt,
3458+
Mimetype: mimetype,
3459+
Data: []byte{},
3460+
})
3461+
require.NoError(t, err, "insert file %q", name)
3462+
return file
3463+
}
3464+
3465+
// mkVersion creates a template version whose cached module files point at a
3466+
// file with the given properties. InsertFile is used directly because
3467+
// dbgen.File treats uuid.Nil as unset and substitutes a random creator,
3468+
// while uuid.Nil is exactly what identifies a provisionerd module archive.
3469+
mkVersion := func(name string, createdBy uuid.UUID, mimetype string, createdAt time.Time) (database.File, database.TemplateVersion) {
3470+
file := mkFile(name, createdBy, mimetype, createdAt)
3471+
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
3472+
Name: name,
3473+
OrganizationID: org.ID,
3474+
CreatedBy: user.ID,
3475+
})
3476+
_ = dbgen.TemplateVersionTerraformValues(t, db, database.TemplateVersionTerraformValue{
3477+
TemplateVersionID: tv.ID,
3478+
CachedModuleFiles: uuid.NullUUID{UUID: file.ID, Valid: true},
3479+
})
3480+
return file, tv
3481+
}
3482+
3483+
// Identified: a provisionerd module archive cached inside the window.
3484+
identified, identifiedTV := mkVersion("identified", uuid.Nil, "application/x-tar", inWindow)
3485+
// The lower bound is inclusive.
3486+
atStart, atStartTV := mkVersion("at-start", uuid.Nil, "application/x-tar", windowStart)
3487+
// The upper bound is exclusive, so this archive is known good.
3488+
atEnd, atEndTV := mkVersion("at-end", uuid.Nil, "application/x-tar", windowEnd)
3489+
// Cached before and after the window.
3490+
before, beforeTV := mkVersion("before", uuid.Nil, "application/x-tar", windowStart.Add(-time.Hour))
3491+
after, afterTV := mkVersion("after", uuid.Nil, "application/x-tar", windowEnd.Add(time.Hour))
3492+
// A user-uploaded template tarball shares the mimetype but has a real
3493+
// creator, so it must survive even though it is inside the window.
3494+
userUpload, userUploadTV := mkVersion("user-upload", user.ID, "application/x-tar", inWindow)
3495+
3496+
// An unreferenced archive inside the window. Only archives referenced by a
3497+
// template version are in scope.
3498+
orphan := mkFile("orphan", uuid.Nil, "application/x-tar", inWindow)
3499+
3500+
// when dbpurge runs
3501+
tick := awaitDoTicks(ctx, t, clk, 2)
3502+
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(clk))
3503+
defer closer.Close()
3504+
tick() // doTick() has now run.
3505+
3506+
assertFileDeleted := func(id uuid.UUID, name string) {
3507+
t.Helper()
3508+
_, err := db.GetFileByID(ctx, id)
3509+
require.ErrorIs(t, err, sql.ErrNoRows, "%s should be deleted", name)
3510+
}
3511+
assertFileExists := func(id uuid.UUID, name string) {
3512+
t.Helper()
3513+
_, err := db.GetFileByID(ctx, id)
3514+
require.NoError(t, err, "%s should be retained", name)
3515+
}
3516+
// assertCacheRef checks the template version still exists and that its
3517+
// module cache reference was cleared only when the file was deleted.
3518+
assertCacheRef := func(tv database.TemplateVersion, wantFile uuid.UUID, wantValid bool, name string) {
3519+
t.Helper()
3520+
values, err := db.GetTemplateVersionTerraformValues(ctx, tv.ID)
3521+
require.NoError(t, err, "%s: terraform values row must be retained", name)
3522+
require.Equal(t, wantValid, values.CachedModuleFiles.Valid, "%s: cache reference validity", name)
3523+
if wantValid {
3524+
require.Equal(t, wantFile, values.CachedModuleFiles.UUID, "%s: cache reference target", name)
3525+
}
3526+
}
3527+
3528+
// then the identified archives are deleted and their references cleared
3529+
assertFileDeleted(identified.ID, "archive inside the window")
3530+
assertCacheRef(identifiedTV, uuid.Nil, false, "archive inside the window")
3531+
assertFileDeleted(atStart.ID, "archive at the inclusive lower bound")
3532+
assertCacheRef(atStartTV, uuid.Nil, false, "archive at the inclusive lower bound")
3533+
3534+
// and everything else is untouched
3535+
assertFileExists(atEnd.ID, "archive at the exclusive upper bound")
3536+
assertCacheRef(atEndTV, atEnd.ID, true, "archive at the exclusive upper bound")
3537+
assertFileExists(before.ID, "archive cached before the window")
3538+
assertCacheRef(beforeTV, before.ID, true, "archive cached before the window")
3539+
assertFileExists(after.ID, "archive cached after the window")
3540+
assertCacheRef(afterTV, after.ID, true, "archive cached after the window")
3541+
assertFileExists(userUpload.ID, "user-uploaded tarball")
3542+
assertCacheRef(userUploadTV, userUpload.ID, true, "user-uploaded tarball")
3543+
assertFileExists(orphan.ID, "unreferenced archive")
3544+
3545+
// The cleanup is one-off, not a recurring purge. A second tick must not
3546+
// repeat it, so an archive inserted into the window after the first pass
3547+
// survives. This documents the latch: the window is fixed in the past and
3548+
// nothing can legitimately land in it again.
3549+
late, lateTV := mkVersion("late", uuid.Nil, "application/x-tar", inWindow)
3550+
tick()
3551+
assertFileExists(late.ID, "archive inserted after the one-off pass")
3552+
assertCacheRef(lateTV, late.ID, true, "archive inserted after the one-off pass")
3553+
}

coderd/database/querier.go

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)