|
4 | 4 | "bufio" |
5 | 5 | "bytes" |
6 | 6 | "context" |
| 7 | + "crypto/sha256" |
7 | 8 | "database/sql" |
8 | 9 | "encoding/json" |
9 | 10 | "fmt" |
@@ -257,6 +258,7 @@ func TestMetrics(t *testing.T) { |
257 | 258 | mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() |
258 | 259 | mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() |
259 | 260 | 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() |
260 | 262 | mDB.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatDebugRunsParams{})).Return(int64(0), nil).MinTimes(1) |
261 | 263 | mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). |
262 | 264 | DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { |
@@ -310,6 +312,7 @@ func TestMetrics(t *testing.T) { |
310 | 312 | mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() |
311 | 313 | mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() |
312 | 314 | 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() |
313 | 316 | mDB.EXPECT().DeleteOldChats(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatsParams{})).Return(int64(0), nil).MinTimes(1) |
314 | 317 | mDB.EXPECT().DeleteOldChatFiles(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatFilesParams{})).Return(int64(0), nil).MinTimes(1) |
315 | 318 | mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). |
@@ -3425,3 +3428,126 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { |
3425 | 3428 | testutil.TryReceive(ctx, t, done) |
3426 | 3429 | }) |
3427 | 3430 | } |
| 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 | +} |
0 commit comments