Files
kandev/apps/backend/internal/backendapp/storage_maintenance_test.go
T
2026-09-16 23:20:37 +01:00

1552 lines
57 KiB
Go

package backendapp
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
agentdocker "github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/agent/runtime/activity"
"github.com/kandev/kandev/internal/db"
systemsettings "github.com/kandev/kandev/internal/system/settings"
storagepkg "github.com/kandev/kandev/internal/system/storage"
"github.com/kandev/kandev/internal/system/storage/databasestore"
"github.com/kandev/kandev/internal/system/storage/dockerstore"
"github.com/kandev/kandev/internal/system/storage/filescan"
"github.com/kandev/kandev/internal/system/storage/gocache"
"github.com/kandev/kandev/internal/system/storage/tempstore"
"github.com/kandev/kandev/internal/system/storage/workspaces"
"github.com/kandev/kandev/internal/worktree"
)
func TestStorageOverviewIncludesQuarantineAndManagedContainers(t *testing.T) {
home := t.TempDir()
for _, dir := range []string{filepath.Join(home, "tasks"), filepath.Join(home, "trash")} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
settings, store := newStorageMaintenanceStores(t)
entry := storagepkg.QuarantineEntry{
ID: "overview-entry", ResourceType: storagepkg.ResourceTypeGoCache,
OriginalPath: filepath.Join(home, "cache", "go-build"),
QuarantinePath: filepath.Join(home, "trash", "go-cache", "overview-entry"),
SizeBytes: 42, State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC(), DeleteAfter: time.Now().UTC().Add(time.Hour),
}
if err := store.CreateQuarantineEntry(context.Background(), &entry); err != nil {
t.Fatalf("CreateQuarantineEntry: %v", err)
}
docker := dockerstore.NewProvider(
&overviewDockerClient{usage: agentdocker.DiskUsage{ImageLayerBytes: 128, Containers: []agentdocker.ContainerUsage{
{ID: "managed", WritableBytes: 64, Labels: map[string]string{"kandev.managed": "true"}},
}}},
overviewContainerInventory{}, settings,
)
workspaceFactory := func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
return workspaces.New(workspaces.Config{
TasksRoot: filepath.Join(home, "tasks"), TrashRoot: filepath.Join(home, "trash"),
Inventory: overviewWorkspaceInventory{}, Store: store,
GracePeriod: time.Duration(current.OrphanGraceHours) * time.Hour,
Retention: time.Duration(current.QuarantineRetentionHours) * time.Hour,
})
}
overview := &storageOverview{
settings: settings, quarantine: store, workspaceFactory: workspaceFactory,
goCache: gocache.New(gocache.Config{
HomeDir: home, TrashDir: filepath.Join(home, "trash"), Settings: settings, Store: store,
}),
docker: docker, homeDir: home,
}
summary, err := overview.Summary(context.Background())
if err != nil {
t.Fatalf("Summary: %v", err)
}
quarantine, ok := summary.Quarantine.(storagepkg.QuarantineSummary)
if !ok || quarantine.Count != 1 || quarantine.SizeBytes != 42 {
t.Fatalf("quarantine summary = %#v", summary.Quarantine)
}
dockerSummary, ok := summary.Docker.(map[string]any)
if !ok || dockerSummary["managed_container_count"] != 1 ||
dockerSummary["managed_container_bytes"] != int64(64) ||
dockerSummary["image_layer_bytes"] != int64(128) {
t.Fatalf("docker summary = %#v", summary.Docker)
}
systemTemporary, ok := summary.SystemTemporary.(tempstore.Analysis)
if !ok || systemTemporary.Status != tempstore.StatusNotApplicable || systemTemporary.Roots == nil {
t.Fatalf("unavailable system temporary summary = %#v, want an empty roots array", summary.SystemTemporary)
}
overview.quarantine = failingQuarantineSummarizer{err: errors.New("quarantine unavailable")}
degraded, err := overview.Summary(context.Background())
if err != nil {
t.Fatalf("degraded Summary: %v", err)
}
quarantineWarning, ok := degraded.Quarantine.(map[string]any)
if !ok || quarantineWarning["available"] != false || quarantineWarning["warning"] != "quarantine unavailable" {
t.Fatalf("degraded quarantine = %#v", degraded.Quarantine)
}
if _, ok := degraded.Workspaces.(workspaces.Analysis); !ok {
t.Fatalf("workspace summary should remain available, got %#v", degraded.Workspaces)
}
}
func TestSystemTemporaryConfigUsesDisposableE2ERoot(t *testing.T) {
root := t.TempDir()
t.Setenv("KANDEV_E2E_SYSTEM_TEMP_ROOT", root)
configured := systemTemporaryConfig(filescan.NewLimiter(1))
if configured.EffectiveRoot != root || configured.UnixRoot != root {
t.Fatalf("system temporary config = %#v, want disposable root %q", configured, root)
}
}
func TestStorageOverviewIncludesInformationalSystemTemporaryFootprint(t *testing.T) {
root := t.TempDir()
writeStorageTestFile(t, filepath.Join(root, "temporary-file"), 23)
settings, store := newStorageMaintenanceStores(t)
overview := &storageOverview{
settings: settings, quarantine: store,
workspaceFactory: func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
return workspaces.New(workspaces.Config{
TasksRoot: filepath.Join(root, "tasks"), TrashRoot: filepath.Join(root, "trash"),
Inventory: overviewWorkspaceInventory{}, Store: store,
GracePeriod: time.Duration(current.OrphanGraceHours) * time.Hour,
Retention: time.Duration(current.QuarantineRetentionHours) * time.Hour,
})
},
goCache: gocache.New(gocache.Config{HomeDir: root, TrashDir: filepath.Join(root, "trash"), Settings: settings, Store: store}),
docker: dockerstore.NewProvider(&overviewDockerClient{}, overviewContainerInventory{}, settings),
systemTemporary: tempstore.New(tempstore.Config{
GOOS: "windows", EffectiveRoot: root,
RootResolver: func(context.Context) ([]tempstore.RootCandidate, error) {
return []tempstore.RootCandidate{{RequestedPath: root}}, nil
},
}),
homeDir: root,
}
summary, err := overview.Summary(context.Background())
if err != nil {
t.Fatalf("Summary: %v", err)
}
temporary, ok := summary.SystemTemporary.(tempstore.Analysis)
if !ok || temporary.SizeBytes == nil || *temporary.SizeBytes != 23 || temporary.IncludedInTotal {
t.Fatalf("system temporary summary = %#v, want informational 23-byte measurement", summary.SystemTemporary)
}
}
func TestStorageOverviewIncludesDatabaseAndBackupMeasurements(t *testing.T) {
root := t.TempDir()
databasePath := filepath.Join(root, "data", "kandev.db")
writeStorageTestFile(t, databasePath, 11)
writeStorageTestFile(t, databasePath+"-wal", 7)
writeStorageTestFile(t, filepath.Join(root, "data", "backups", "manual.db"), 13)
settings, store := newStorageMaintenanceStores(t)
overview := &storageOverview{
settings: settings,
quarantine: store,
database: databasestore.New(databasestore.Config{
Driver: "sqlite", DatabasePath: databasePath,
}),
workspaceFactory: func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
return workspaces.New(workspaces.Config{
TasksRoot: filepath.Join(root, "tasks"), TrashRoot: filepath.Join(root, "trash"),
Inventory: overviewWorkspaceInventory{}, Store: store,
GracePeriod: time.Duration(current.OrphanGraceHours) * time.Hour,
Retention: time.Duration(current.QuarantineRetentionHours) * time.Hour,
})
},
goCache: gocache.New(gocache.Config{
HomeDir: root, TrashDir: filepath.Join(root, "trash"), Settings: settings, Store: store,
}),
docker: dockerstore.NewProvider(&overviewDockerClient{}, overviewContainerInventory{}, settings),
homeDir: root,
}
summary, err := overview.Summary(context.Background())
if err != nil {
t.Fatalf("Summary: %v", err)
}
database, ok := summary.Database.(databasestore.Measurement)
if !ok {
t.Fatalf("database summary = %#v, want databasestore.Measurement", summary.Database)
}
if database.SizeBytes == nil || *database.SizeBytes != 18 {
t.Fatalf("database summary = %#v, want 18 bytes", database)
}
backups, ok := summary.DatabaseBackups.(databasestore.Measurement)
if !ok {
t.Fatalf("backup summary = %#v, want databasestore.Measurement", summary.DatabaseBackups)
}
if backups.SizeBytes == nil || *backups.SizeBytes != 13 {
t.Fatalf("backup summary = %#v, want 13 bytes", backups)
}
}
func TestStorageOverviewDoesNotSuppressDatabaseUnderFormerDefaultGoCache(t *testing.T) {
root := t.TempDir()
formerDefaultCache := filepath.Join(root, "cache", "go-build")
databasePath := filepath.Join(formerDefaultCache, "kandev.db")
writeStorageTestFile(t, databasePath, 11)
adoptedCache := filepath.Join(t.TempDir(), "adopted-go-build")
userCache := filepath.Join(t.TempDir(), "user-go-build")
t.Setenv("GOCACHE", userCache)
settings, store := newStorageMaintenanceStores(t)
if _, err := settings.AdoptGoCachePath(context.Background(), adoptedCache); err != nil {
t.Fatalf("AdoptGoCachePath: %v", err)
}
overview := &storageOverview{
settings: settings,
quarantine: store,
database: databasestore.New(databasestore.Config{
Driver: "sqlite", DatabasePath: databasePath, ExistingRoots: storageExistingMeasurementRoots(root),
}),
workspaceFactory: func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
return workspaces.New(workspaces.Config{
TasksRoot: filepath.Join(root, "tasks"), TrashRoot: filepath.Join(root, "trash"),
Inventory: overviewWorkspaceInventory{}, Store: store,
GracePeriod: time.Duration(current.OrphanGraceHours) * time.Hour,
Retention: time.Duration(current.QuarantineRetentionHours) * time.Hour,
})
},
goCache: gocache.New(gocache.Config{
HomeDir: root, TrashDir: filepath.Join(root, "trash"), Settings: settings, Store: store,
}),
docker: dockerstore.NewProvider(&overviewDockerClient{}, overviewContainerInventory{}, settings),
homeDir: root,
}
summary, err := overview.Summary(context.Background())
if err != nil {
t.Fatalf("Summary: %v", err)
}
database, ok := summary.Database.(databasestore.Measurement)
if !ok {
t.Fatalf("database summary = %#v, want databasestore.Measurement", summary.Database)
}
if !database.IncludedInTotal {
t.Fatalf("database attribution = %#v, want included because the effective cache roots are elsewhere", database)
}
}
func TestStorageOverviewReportsProgressForEachSource(t *testing.T) {
settings, _ := newStorageMaintenanceStores(t)
docker := dockerstore.NewProvider(
&overviewDockerClient{}, overviewContainerInventory{}, settings,
)
events := make(chan storagepkg.OverviewProgress, 16)
overview := &storageOverview{
settings: settings,
quarantine: failingQuarantineSummarizer{err: errors.New("quarantine unavailable")},
workspaceAnalyze: func(context.Context, storagepkg.StorageMaintenanceSettings) (workspaces.Analysis, error) {
return workspaces.Analysis{TotalBytes: 10}, nil
},
goCacheAnalyze: func(context.Context) (gocache.Analysis, error) {
return gocache.Analysis{SizeBytes: 20}, nil
},
docker: docker,
}
if _, err := overview.SummaryWithProgress(context.Background(), func(progress storagepkg.OverviewProgress) {
events <- progress
}); err != nil {
t.Fatalf("SummaryWithProgress: %v", err)
}
seen := make(map[string]map[storagepkg.SourceStateName]bool)
for len(events) > 0 {
progress := <-events
if seen[progress.Source] == nil {
seen[progress.Source] = make(map[storagepkg.SourceStateName]bool)
}
seen[progress.Source][progress.State] = true
}
for _, source := range []string{
storagepkg.StorageSourceWorkspaces,
storagepkg.StorageSourceGoCache,
storagepkg.StorageSourceQuarantine,
storagepkg.StorageSourceTemporaryArtifacts,
storagepkg.StorageSourceSystemTemporary,
storagepkg.StorageSourceDocker,
storagepkg.StorageSourceDatabase,
storagepkg.StorageSourceDatabaseBackups,
} {
if !seen[source][storagepkg.SourceStateScanning] {
t.Fatalf("source %q did not report scanning progress: %#v", source, seen[source])
}
}
if !seen[storagepkg.StorageSourceQuarantine][storagepkg.SourceStateFailed] {
t.Fatalf("quarantine source did not report failure: %#v", seen[storagepkg.StorageSourceQuarantine])
}
}
func writeStorageTestFile(t *testing.T, path string, size int64) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(size); err != nil {
_ = file.Close()
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
}
func TestStorageProgressReporterCompletionPreservesFilesystemCounters(t *testing.T) {
var events []storagepkg.OverviewProgress
reporter := newStorageProgressReporter(func(progress storagepkg.OverviewProgress) {
events = append(events, progress)
})
reporter.filesystem(storagepkg.StorageSourceWorkspaces)(filescan.Progress{
Phase: filescan.PartitionCompleted, CompletedPartitions: 2, TotalPartitions: 3,
BytesScanned: 42,
})
reporter.complete(storagepkg.StorageSourceWorkspaces, map[string]any{"total_bytes": int64(42)}, nil)
if len(events) != 2 {
t.Fatalf("progress event count = %d, want 2", len(events))
}
completion := events[1]
if completion.CompletedItems != 2 || completion.BytesScanned != 42 {
t.Fatalf("completion progress = %#v, want filesystem counters", completion)
}
if completion.TotalItems == nil || *completion.TotalItems != 3 {
t.Fatalf("completion total_items = %v, want 3", completion.TotalItems)
}
}
func TestStorageCleanupProvidersIncludeWorkspaceDependencyCleanup(t *testing.T) {
settings, store := newStorageMaintenanceStores(t)
home := t.TempDir()
workspaceFactory := func(storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
return workspaces.New(workspaces.Config{TasksRoot: filepath.Join(home, "tasks"), Store: store})
}
providers := storageCleanupProviders(settings, workspaceFactory, nil, nil, nil, nil)
for _, provider := range providers {
if provider.Name() == workspaceDependenciesProviderName {
return
}
}
t.Fatalf("storage cleanup providers did not include %s", workspaceDependenciesProviderName)
}
func TestStorageCleanupProvidersIncludeArchivedManagedBranches(t *testing.T) {
const providerName = "archived_managed_branches"
providers := storageCleanupProviders(nil, nil, nil, nil, nil, nil)
for _, provider := range providers {
if provider.Name() == providerName {
return
}
}
t.Fatalf("storage cleanup providers did not include %s", providerName)
}
type recordingArchivedBranchMaintainer struct {
limit int
receipt worktree.BranchCleanupReceipt
}
func (m *recordingArchivedBranchMaintainer) MaintainArchivedBranches(
_ context.Context, limit int,
) (worktree.BranchCleanupReceipt, error) {
m.limit = limit
return m.receipt, nil
}
func TestArchivedManagedBranchesProviderUsesBoundedManagerReceipt(t *testing.T) {
maintainer := &recordingArchivedBranchMaintainer{receipt: worktree.BranchCleanupReceipt{
Attempted: 2, Deleted: 1, Retained: 1,
RetainedReasons: map[worktree.BranchRetentionReason]int{worktree.RetainedNotIntegrated: 1},
}}
provider := archivedManagedBranchesCleanupProvider{maintainer: maintainer}
result, err := provider.Cleanup(context.Background())
if err != nil {
t.Fatalf("archived branch cleanup: %v", err)
}
if maintainer.limit != worktree.ArchivedBranchMaintenanceBatchLimit {
t.Fatalf("maintenance limit = %d, want %d", maintainer.limit, worktree.ArchivedBranchMaintenanceBatchLimit)
}
if result["attempted"] != float64(2) || result["deleted"] != float64(1) || result["retained"] != float64(1) {
t.Fatalf("maintenance result = %#v", result)
}
}
func TestWorkspaceDependencyCleanupProviderIsDefaultOff(t *testing.T) {
settings, store := newStorageMaintenanceStores(t)
home := t.TempDir()
factoryCalls := 0
workspaceFactory := func(storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
factoryCalls++
return workspaces.New(workspaces.Config{TasksRoot: filepath.Join(home, "tasks"), Store: store})
}
var dependencyProvider storagepkg.CleanupProvider
for _, provider := range storageCleanupProviders(settings, workspaceFactory, nil, nil, nil, nil) {
if provider.Name() == workspaceDependenciesProviderName {
dependencyProvider = provider
break
}
}
if dependencyProvider == nil {
t.Fatal("workspace dependency provider is missing")
}
if result, err := dependencyProvider.Cleanup(context.Background()); err != nil || result != nil {
t.Fatalf("default-off cleanup = (%#v, %v), want no-op", result, err)
}
if factoryCalls != 0 {
t.Fatalf("workspace factory calls = %d, want 0 while disabled", factoryCalls)
}
enabled := storagepkg.DefaultSettings()
enabled.Workspaces.DependencyCleanupEnabled = true
if _, err := settings.SaveSettings(context.Background(), enabled); err != nil {
t.Fatalf("enable dependency cleanup: %v", err)
}
if _, err := dependencyProvider.Cleanup(context.Background()); err == nil {
t.Fatal("enabled dependency cleanup unexpectedly succeeded without complete inventory")
}
if factoryCalls != 1 {
t.Fatalf("workspace factory calls = %d, want 1 after enabling", factoryCalls)
}
}
func TestStorageOverviewStartsIndependentMeasurementsTogether(t *testing.T) {
home := t.TempDir()
for _, dir := range []string{filepath.Join(home, "tasks"), filepath.Join(home, "trash")} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
t.Setenv("GOCACHE", filepath.Join(home, "go-cache"))
settings, store := newStorageMaintenanceStores(t)
workspaceStarted, workspaceRelease := make(chan struct{}), make(chan struct{})
goCacheStarted, goCacheRelease := make(chan struct{}), make(chan struct{})
quarantine := &blockingOverviewQuarantine{
started: make(chan struct{}), release: make(chan struct{}),
}
dockerClient := &blockingOverviewDockerClient{
started: make(chan struct{}), release: make(chan struct{}),
}
var releaseOnce sync.Once
releaseMeasurements := func() {
releaseOnce.Do(func() {
close(workspaceRelease)
close(goCacheRelease)
close(quarantine.release)
close(dockerClient.release)
})
}
t.Cleanup(releaseMeasurements)
docker := dockerstore.NewProvider(dockerClient, overviewContainerInventory{}, settings)
overview := &storageOverview{
settings: settings, quarantine: quarantine,
workspaceAnalyze: func(context.Context, storagepkg.StorageMaintenanceSettings) (workspaces.Analysis, error) {
close(workspaceStarted)
<-workspaceRelease
return workspaces.Analysis{}, nil
},
goCacheAnalyze: func(context.Context) (gocache.Analysis, error) {
close(goCacheStarted)
<-goCacheRelease
return gocache.Analysis{}, nil
},
workspaceFactory: func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
return workspaces.New(workspaces.Config{
TasksRoot: filepath.Join(home, "tasks"), TrashRoot: filepath.Join(home, "trash"),
Inventory: overviewWorkspaceInventory{}, Store: store,
GracePeriod: time.Duration(current.OrphanGraceHours) * time.Hour,
Retention: time.Duration(current.QuarantineRetentionHours) * time.Hour,
})
},
goCache: gocache.New(gocache.Config{
HomeDir: home, TrashDir: filepath.Join(home, "trash"), Settings: settings, Store: store,
}),
docker: docker, homeDir: home,
}
resultCh := make(chan struct {
summary storagepkg.Summary
err error
}, 1)
go func() {
summary, err := overview.Summary(context.Background())
resultCh <- struct {
summary storagepkg.Summary
err error
}{summary: summary, err: err}
}()
for _, measurement := range []struct {
name string
started <-chan struct{}
}{
{name: "workspace", started: workspaceStarted},
{name: "Go cache", started: goCacheStarted},
{name: "quarantine", started: quarantine.started},
{name: "Docker", started: dockerClient.started},
} {
select {
case <-measurement.started:
case <-time.After(time.Second):
t.Fatalf("%s measurement did not start before the barrier was released", measurement.name)
}
}
releaseMeasurements()
select {
case result := <-resultCh:
if result.err != nil {
t.Fatalf("Summary: %v", result.err)
}
case <-time.After(time.Second):
t.Fatal("Summary did not finish")
}
}
type blockingOverviewQuarantine struct {
started chan struct{}
release chan struct{}
}
func (s *blockingOverviewQuarantine) SummarizeQuarantine(context.Context) (storagepkg.QuarantineSummary, error) {
close(s.started)
<-s.release
return storagepkg.QuarantineSummary{}, nil
}
type blockingOverviewDockerClient struct {
started chan struct{}
release chan struct{}
}
func (c *blockingOverviewDockerClient) Ping(context.Context) error { return nil }
func (c *blockingOverviewDockerClient) ListContainers(context.Context, map[string]string) ([]agentdocker.ContainerInfo, error) {
return nil, nil
}
func (c *blockingOverviewDockerClient) RemoveContainer(context.Context, string, bool) error {
return nil
}
func (c *blockingOverviewDockerClient) DiskUsage(context.Context) (agentdocker.DiskUsage, error) {
close(c.started)
<-c.release
return agentdocker.DiskUsage{}, nil
}
func (c *blockingOverviewDockerClient) PruneBuildCache(context.Context, agentdocker.BuildCachePruneOptions) (agentdocker.PruneResult, error) {
return agentdocker.PruneResult{}, nil
}
func (c *blockingOverviewDockerClient) PruneUnusedImages(context.Context, time.Time) (agentdocker.PruneResult, error) {
return agentdocker.PruneResult{}, nil
}
type failingQuarantineSummarizer struct{ err error }
func (s failingQuarantineSummarizer) SummarizeQuarantine(context.Context) (storagepkg.QuarantineSummary, error) {
return storagepkg.QuarantineSummary{}, s.err
}
type overviewWorkspaceInventory struct{}
func (overviewWorkspaceInventory) LoadWorkspaceInventory(context.Context) (workspaces.Inventory, error) {
return workspaces.Inventory{Complete: true}, nil
}
type overviewContainerInventory struct{}
func (overviewContainerInventory) ContainerTaskRemovable(context.Context, string) (bool, error) {
return false, nil
}
type overviewDockerClient struct{ usage agentdocker.DiskUsage }
func (c *overviewDockerClient) Ping(context.Context) error { return nil }
func (c *overviewDockerClient) ListContainers(context.Context, map[string]string) ([]agentdocker.ContainerInfo, error) {
return nil, nil
}
func (c *overviewDockerClient) RemoveContainer(context.Context, string, bool) error { return nil }
func (c *overviewDockerClient) DiskUsage(context.Context) (agentdocker.DiskUsage, error) {
return c.usage, nil
}
func (c *overviewDockerClient) PruneBuildCache(context.Context, agentdocker.BuildCachePruneOptions) (agentdocker.PruneResult, error) {
return agentdocker.PruneResult{}, nil
}
func (c *overviewDockerClient) PruneUnusedImages(context.Context, time.Time) (agentdocker.PruneResult, error) {
return agentdocker.PruneResult{}, nil
}
func TestWorkspaceQuarantineControllerRestoresTaskUsingCurrentSettings(t *testing.T) {
settings, store := newStorageMaintenanceStores(t)
want := storagepkg.DefaultSettings()
want.OrphanGraceHours = 48
if _, err := settings.SaveSettings(context.Background(), want); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
var captured storagepkg.StorageMaintenanceSettings
controller := &workspaceQuarantineController{
settings: settings,
factory: func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
captured = current
return workspaces.New(workspaces.Config{Store: store})
},
}
recovery := controller.RestoreTask(context.Background(), "task-1")
if captured.OrphanGraceHours != want.OrphanGraceHours {
t.Fatalf("factory settings orphan_grace_hours = %d, want %d", captured.OrphanGraceHours, want.OrphanGraceHours)
}
if recovery.TaskID != "task-1" || recovery.Status != "not_found" {
t.Fatalf("recovery = %#v, want task-1 not_found", recovery)
}
}
func TestWorkspaceQuarantineControllerMapsRestoreConflict(t *testing.T) {
home := t.TempDir()
tasksRoot := filepath.Join(home, "tasks")
original := filepath.Join(tasksRoot, "task-1")
quarantined := filepath.Join(home, "trash", "tasks", "entry-1")
for _, path := range []string{original, quarantined} {
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
}
settings, store := newStorageMaintenanceStores(t)
entry := storagepkg.QuarantineEntry{
ID: "entry-1", ResourceType: storagepkg.ResourceTypeTaskWorkspace,
OriginalPath: original, QuarantinePath: quarantined,
State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC(), DeleteAfter: time.Now().UTC().Add(time.Hour),
}
if err := store.CreateQuarantineEntry(context.Background(), &entry); err != nil {
t.Fatal(err)
}
controller := &workspaceQuarantineController{
settings: settings, store: store,
factory: func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
return workspaces.New(workspaces.Config{
TasksRoot: tasksRoot, TrashRoot: filepath.Join(home, "trash"), Store: store,
GracePeriod: time.Duration(current.OrphanGraceHours) * time.Hour,
Retention: time.Duration(current.QuarantineRetentionHours) * time.Hour,
})
},
}
_, err := controller.Restore(context.Background(), entry.ID)
if !errors.Is(err, storagepkg.ErrConflict) {
t.Fatalf("Restore error = %v, want storage ErrConflict", err)
}
}
func TestQuarantineControllerRestoresGoCache(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(quarantined, "artifact"), []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, time.Now().UTC().Add(time.Hour))
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
restored, err := controller.Restore(context.Background(), entry.ID)
if err != nil {
t.Fatalf("Restore: %v", err)
}
if restored.State != storagepkg.QuarantineStateRestored {
t.Fatalf("state = %q, want restored", restored.State)
}
if _, err := os.Stat(filepath.Join(original, "artifact")); err != nil {
t.Fatalf("restored cache artifact: %v", err)
}
}
func TestQuarantineControllerRestoresGoCacheOverEmptyReplacement(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(quarantined, "artifact"), []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
current, err := settings.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
current.GoCache.Enabled = true
if _, err := settings.SaveSettings(context.Background(), current); err != nil {
t.Fatal(err)
}
provider := gocache.New(gocache.Config{
HomeDir: home, TrashDir: filepath.Join(home, "trash"), Settings: settings,
})
if _, err := provider.ExecutionEnvironment(context.Background()); err != nil {
t.Fatalf("create managed replacement: %v", err)
}
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, time.Now().UTC().Add(time.Hour))
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
restored, err := controller.Restore(context.Background(), entry.ID)
if err != nil {
t.Fatalf("Restore: %v", err)
}
if restored.State != storagepkg.QuarantineStateRestored {
t.Fatalf("state = %q, want restored", restored.State)
}
if data, err := os.ReadFile(filepath.Join(original, "artifact")); err != nil || string(data) != "cache" {
t.Fatalf("restored cache artifact: data=%q err=%v", data, err)
}
}
func TestQuarantineControllerRetainsGoCacheWhileTaskActivityIsRunning(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
artifact := filepath.Join(quarantined, "artifact")
if err := os.WriteFile(artifact, []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
current, err := settings.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
current.GoCache.Enabled = true
if _, err := settings.SaveSettings(context.Background(), current); err != nil {
t.Fatal(err)
}
provider := gocache.New(gocache.Config{
HomeDir: home, TrashDir: filepath.Join(home, "trash"), Settings: settings,
})
if _, err := provider.ExecutionEnvironment(context.Background()); err != nil {
t.Fatalf("create managed replacement: %v", err)
}
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, time.Now().UTC().Add(time.Hour))
coordinator := activity.NewCoordinator(activity.Options{})
taskLease, err := coordinator.AcquireTask(context.Background(), activity.KindExecutionRunning)
if err != nil {
t.Fatal(err)
}
defer taskLease.Release()
controller := &workspaceQuarantineController{
settings: settings, store: store, homeDir: home, activity: coordinator,
}
_, err = controller.Restore(context.Background(), entry.ID)
var busy *storagepkg.BusyError
if !errors.As(err, &busy) {
t.Fatalf("Restore error = %v, want BusyError", err)
}
if busy.ForceAvailable || len(busy.Resources) != 1 || busy.Resources[0].Label == "" {
t.Fatalf("busy response = %#v, want labeled non-overridable resource", busy)
}
if _, err := os.Stat(artifact); err != nil {
t.Fatalf("quarantined cache changed while task active: %v", err)
}
}
func TestQuarantineControllerRestoresAdoptedExternalGoCache(t *testing.T) {
root := t.TempDir()
home := filepath.Join(root, "home")
original := filepath.Join(root, "external", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
for _, path := range []string{home, original, quarantined} {
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(quarantined, "artifact"), []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Remove(original); err != nil {
t.Fatalf("remove recreated cache destination: %v", err)
}
settings, store := newStorageMaintenanceStores(t)
if _, err := settings.AdoptGoCachePath(context.Background(), original); err != nil {
t.Fatalf("AdoptGoCachePath: %v", err)
}
entry := createGoCacheQuarantineEntry(
t, store, original, quarantined, time.Now().UTC().Add(time.Hour),
)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
restored, err := controller.Restore(context.Background(), entry.ID)
if err != nil {
t.Fatalf("Restore: %v", err)
}
if restored.State != storagepkg.QuarantineStateRestored {
t.Fatalf("state = %q, want restored", restored.State)
}
if _, err := os.Stat(filepath.Join(original, "artifact")); err != nil {
t.Fatalf("restored external cache artifact: %v", err)
}
}
func TestQuarantineControllerRetriesFailedGoCacheRestore(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(quarantined, "artifact"), []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, time.Now().UTC().Add(time.Hour))
markGoCacheQuarantineFailed(t, store, entry.ID)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
restored, err := controller.Restore(context.Background(), entry.ID)
if err != nil {
t.Fatalf("Restore: %v", err)
}
if restored.State != storagepkg.QuarantineStateRestored {
t.Fatalf("state = %q, want restored", restored.State)
}
if _, err := os.Stat(filepath.Join(original, "artifact")); err != nil {
t.Fatalf("restored cache artifact: %v", err)
}
}
func TestQuarantineControllerRetriesFailedGoCacheDelete(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, time.Now().UTC().Add(-time.Hour))
markGoCacheQuarantineFailed(t, store, entry.ID)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
deleted, err := controller.PermanentDelete(context.Background(), entry.ID, "DELETE")
if err != nil {
t.Fatalf("PermanentDelete: %v", err)
}
if deleted.State != storagepkg.QuarantineStateDeleted {
t.Fatalf("state = %q, want deleted", deleted.State)
}
if _, err := os.Stat(quarantined); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("quarantine path still exists: %v", err)
}
}
func TestQuarantineControllerForceDeletesProtectedGoCache(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(quarantined, "artifact"), []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, time.Now().UTC().Add(time.Hour))
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
if _, err := controller.PermanentDeleteForce(context.Background(), entry.ID, "DELETE"); !errors.Is(err, storagepkg.ErrForceDeleteConfirmation) {
t.Fatalf("wrong force confirmation error = %v, want %v", err, storagepkg.ErrForceDeleteConfirmation)
}
deleted, err := controller.PermanentDeleteForce(context.Background(), entry.ID, "DELETE ALL NOW")
if err != nil {
t.Fatalf("PermanentDeleteForce: %v", err)
}
if deleted.State != storagepkg.QuarantineStateDeleted {
t.Fatalf("state = %q, want deleted", deleted.State)
}
if _, err := os.Stat(quarantined); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("quarantine path still exists: %v", err)
}
}
func TestQuarantineControllerPurgeEligibleReportsProtectedAndDeleted(t *testing.T) {
home := t.TempDir()
settings, store := newStorageMaintenanceStores(t)
protected := storagepkg.QuarantineEntry{
ID: "protected-workspace", ResourceType: storagepkg.ResourceTypeTaskWorkspace,
OriginalPath: filepath.Join(home, "tasks", "protected"),
QuarantinePath: filepath.Join(home, "trash", "tasks", "protected-workspace"),
SizeBytes: 17, State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC(), DeleteAfter: time.Now().UTC().Add(time.Hour),
}
if err := os.MkdirAll(protected.QuarantinePath, 0o700); err != nil {
t.Fatal(err)
}
if err := store.CreateQuarantineEntry(context.Background(), &protected); err != nil {
t.Fatal(err)
}
eligible := createGoCacheQuarantineEntryWithID(
t, store, home, "eligible-cache", time.Now().UTC().Add(-time.Hour),
)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
result, err := controller.Purge(context.Background(), storagepkg.QuarantinePurgeScopeEligible, "DELETE ELIGIBLE")
if err != nil {
t.Fatalf("Purge: %v", err)
}
if result.Considered != 2 || result.Deleted != 1 || result.Protected != 1 || result.Failed != 0 {
t.Fatalf("purge result = %#v, want considered=2 deleted=1 protected=1 failed=0", result)
}
if result.DeletedBytes != eligible.SizeBytes || result.ProtectedBytes != protected.SizeBytes {
t.Fatalf("purge bytes = deleted:%d protected:%d, want deleted:%d protected:%d", result.DeletedBytes, result.ProtectedBytes, eligible.SizeBytes, protected.SizeBytes)
}
if _, err := os.Stat(eligible.QuarantinePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("eligible quarantine path still exists: %v", err)
}
if _, err := os.Stat(protected.QuarantinePath); err != nil {
t.Fatalf("protected quarantine path changed: %v", err)
}
}
func TestQuarantineCleanupProviderPurgesEligibleEntries(t *testing.T) {
purger := &recordingQuarantinePurger{}
provider := quarantineCleanupProvider{purger: purger}
result, err := provider.Cleanup(context.Background())
if err != nil {
t.Fatalf("Cleanup: %v", err)
}
if purger.scope != storagepkg.QuarantinePurgeScopeEligible || purger.confirmation != "DELETE ELIGIBLE" {
t.Fatalf("purge request = scope:%q confirmation:%q", purger.scope, purger.confirmation)
}
if result["deleted"] != float64(2) || result["protected"] != float64(1) {
t.Fatalf("provider result = %#v, want deleted=2 protected=1", result)
}
}
func TestStorageCleanupProvidersIncludesQuarantineProvider(t *testing.T) {
providers := storageCleanupProviders(nil, nil, nil, nil, &recordingQuarantinePurger{}, nil)
if len(providers) != 8 {
t.Fatalf("provider count = %d, want 8", len(providers))
}
if providers[0].Name() != "quarantine" {
t.Fatalf("first provider = %q, want quarantine", providers[0].Name())
}
if providers[1].Name() != archivedManagedBranchesProviderName || providers[2].Name() != "workspaces" ||
providers[3].Name() != workspaceDependenciesProviderName || providers[4].Name() != "go_cache" {
t.Fatalf("provider order = %q, %q, %q, %q, want archived branches, workspaces, workspace_dependencies, go_cache",
providers[1].Name(), providers[2].Name(), providers[3].Name(), providers[4].Name())
}
}
type recordingQuarantinePurger struct {
scope storagepkg.QuarantinePurgeScope
confirmation string
}
func (p *recordingQuarantinePurger) Purge(
_ context.Context,
scope storagepkg.QuarantinePurgeScope,
confirmation string,
) (storagepkg.QuarantinePurgeResult, error) {
p.scope = scope
p.confirmation = confirmation
return storagepkg.QuarantinePurgeResult{Deleted: 2, Protected: 1}, nil
}
func TestQuarantineControllerDoesNotTreatPopulatedReplacementAsRestoredPayload(t *testing.T) {
states := []storagepkg.QuarantineState{
storagepkg.QuarantineStateQuarantined,
storagepkg.QuarantineStateFailed,
}
for _, state := range states {
t.Run(string(state), func(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(original, 0o700); err != nil {
t.Fatal(err)
}
artifact := filepath.Join(original, "replacement-artifact")
if err := os.WriteFile(artifact, []byte("active cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := createGoCacheQuarantineEntry(
t, store, original, quarantined, time.Now().UTC().Add(-time.Hour),
)
if state == storagepkg.QuarantineStateFailed {
markGoCacheQuarantineFailed(t, store, entry.ID)
}
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
_, err := controller.Restore(context.Background(), entry.ID)
if !errors.Is(err, storagepkg.ErrConflict) {
t.Fatalf("Restore error = %v, want storage ErrConflict", err)
}
stored, err := store.GetQuarantineEntry(context.Background(), entry.ID)
if err != nil {
t.Fatal(err)
}
if stored.State != state {
t.Fatalf("state = %q, want unchanged %q", stored.State, state)
}
if data, err := os.ReadFile(artifact); err != nil || string(data) != "active cache" {
t.Fatalf("replacement cache changed: data=%q err=%v", data, err)
}
})
}
}
func TestQuarantineControllerDoesNotMarkMissingPayloadPlaceholderRestored(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
settings, store := newStorageMaintenanceStores(t)
current, err := settings.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
current.GoCache.Enabled = true
if _, err := settings.SaveSettings(context.Background(), current); err != nil {
t.Fatal(err)
}
provider := gocache.New(gocache.Config{
HomeDir: home, TrashDir: filepath.Join(home, "trash"), Settings: settings,
})
if _, err := provider.ExecutionEnvironment(context.Background()); err != nil {
t.Fatalf("create managed replacement: %v", err)
}
entry := createGoCacheQuarantineEntry(
t, store, original, quarantined, time.Now().UTC().Add(time.Hour),
)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
_, err = controller.Restore(context.Background(), entry.ID)
if !errors.Is(err, storagepkg.ErrConflict) {
t.Fatalf("Restore error = %v, want storage ErrConflict", err)
}
stored, err := store.GetQuarantineEntry(context.Background(), entry.ID)
if err != nil {
t.Fatal(err)
}
if stored.State != storagepkg.QuarantineStateQuarantined {
t.Fatalf("state = %q, want quarantined", stored.State)
}
}
func TestQuarantineControllerReportsPersistenceAndFailsClosedAfterRollbackFailure(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
entry := storagepkg.QuarantineEntry{
ID: "entry-cache", ResourceType: storagepkg.ResourceTypeGoCache,
OriginalPath: original, QuarantinePath: quarantined,
State: storagepkg.QuarantineStateQuarantined, Metadata: []byte(`{"ownership":"managed"}`),
}
store := &failingTransitionQuarantineStore{entry: entry, err: errors.New("database unavailable")}
renameCalls := 0
controller := &workspaceQuarantineController{
store: store, homeDir: home,
rename: func(oldPath, newPath string) error {
renameCalls++
if renameCalls == 2 {
return errors.New("rollback blocked")
}
return os.Rename(oldPath, newPath)
},
}
_, err := controller.Restore(context.Background(), entry.ID)
if err == nil || !strings.Contains(err.Error(), "database unavailable") ||
!strings.Contains(err.Error(), "rollback blocked") {
t.Fatalf("Restore error = %v, want persistence and rollback failures", err)
}
if _, err := os.Stat(original); err != nil {
t.Fatalf("restored data missing after failed rollback: %v", err)
}
if _, err := os.Stat(quarantined); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("quarantine path exists after failed rollback: %v", err)
}
store.err = nil
_, err = controller.Restore(context.Background(), entry.ID)
if !errors.Is(err, storagepkg.ErrConflict) {
t.Fatalf("ambiguous retry error = %v, want storage ErrConflict", err)
}
if store.entry.State != storagepkg.QuarantineStateQuarantined {
t.Fatalf("retry state = %q, want quarantined", store.entry.State)
}
}
type failingTransitionQuarantineStore struct {
entry storagepkg.QuarantineEntry
err error
}
type removePayloadOnGetStore struct {
delegate *storagepkg.Store
path string
removed bool
}
func (s *removePayloadOnGetStore) GetQuarantineEntry(
ctx context.Context,
id string,
) (storagepkg.QuarantineEntry, error) {
entry, err := s.delegate.GetQuarantineEntry(ctx, id)
if err != nil || s.removed {
return entry, err
}
if err := os.RemoveAll(s.path); err != nil {
return storagepkg.QuarantineEntry{}, err
}
s.removed = true
return entry, nil
}
func (s *removePayloadOnGetStore) ListQuarantineEntries(
ctx context.Context,
includeTerminal bool,
) ([]storagepkg.QuarantineEntry, error) {
return s.delegate.ListQuarantineEntries(ctx, includeTerminal)
}
func (s *removePayloadOnGetStore) TransitionQuarantineEntry(
ctx context.Context,
id string,
next storagepkg.QuarantineState,
lastError string,
) (storagepkg.QuarantineEntry, error) {
return s.delegate.TransitionQuarantineEntry(ctx, id, next, lastError)
}
func (s *failingTransitionQuarantineStore) GetQuarantineEntry(
context.Context, string,
) (storagepkg.QuarantineEntry, error) {
return s.entry, nil
}
func (s *failingTransitionQuarantineStore) ListQuarantineEntries(
context.Context, bool,
) ([]storagepkg.QuarantineEntry, error) {
return []storagepkg.QuarantineEntry{s.entry}, nil
}
func (s *failingTransitionQuarantineStore) TransitionQuarantineEntry(
_ context.Context,
_ string,
next storagepkg.QuarantineState,
lastError string,
) (storagepkg.QuarantineEntry, error) {
if s.err != nil {
return storagepkg.QuarantineEntry{}, s.err
}
s.entry.State = next
s.entry.LastError = lastError
return s.entry, nil
}
func TestQuarantineControllerFailedGoCacheRetryRejectsSymlinkedRestorePath(t *testing.T) {
root := t.TempDir()
home := filepath.Join(root, "home")
external := filepath.Join(root, "external")
if err := os.MkdirAll(filepath.Join(external, "go-build"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(home, 0o700); err != nil {
t.Fatal(err)
}
linkedParent := filepath.Join(root, "linked-external")
if err := os.Symlink(external, linkedParent); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
original := filepath.Join(linkedParent, "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
settings, store := newStorageMaintenanceStores(t)
if _, err := settings.AdoptGoCachePath(context.Background(), original); err != nil {
t.Fatalf("AdoptGoCachePath: %v", err)
}
entry := createGoCacheQuarantineEntry(
t, store, original, quarantined, time.Now().UTC().Add(time.Hour),
)
markGoCacheQuarantineFailed(t, store, entry.ID)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
_, err := controller.Restore(context.Background(), entry.ID)
if !errors.Is(err, storagepkg.ErrValidation) {
t.Fatalf("Restore error = %v, want storage ErrValidation", err)
}
stored, err := store.GetQuarantineEntry(context.Background(), entry.ID)
if err != nil {
t.Fatal(err)
}
if stored.State != storagepkg.QuarantineStateFailed {
t.Fatalf("state = %q, want failed", stored.State)
}
}
func TestQuarantineControllerPermanentlyDeletesGoCache(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, time.Now().UTC().Add(-time.Hour))
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
deleted, err := controller.PermanentDelete(context.Background(), entry.ID, "DELETE")
if err != nil {
t.Fatalf("PermanentDelete: %v", err)
}
if deleted.State != storagepkg.QuarantineStateDeleted {
t.Fatalf("state = %q, want deleted", deleted.State)
}
if _, err := os.Stat(quarantined); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("quarantine path still exists: %v", err)
}
}
func TestQuarantineControllerPermanentlyDeletesMissingGoCachePayload(t *testing.T) {
for _, test := range []struct {
name string
force bool
}{
{name: "eligible"},
{name: "forced", force: true},
} {
t.Run(test.name, func(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(original, 0o700); err != nil {
t.Fatal(err)
}
artifact := filepath.Join(original, "replacement-artifact")
if err := os.WriteFile(artifact, []byte("active cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
deleteAfter := time.Now().UTC().Add(-time.Hour)
if test.force {
deleteAfter = time.Now().UTC().Add(time.Hour)
}
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, deleteAfter)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
var deleted storagepkg.QuarantineEntry
var err error
if test.force {
deleted, err = controller.PermanentDeleteForce(
context.Background(), entry.ID, storagepkg.QuarantineConfirmationForce,
)
} else {
deleted, err = controller.PermanentDelete(
context.Background(), entry.ID, storagepkg.QuarantineConfirmationDelete,
)
}
if err != nil {
t.Fatalf("delete missing payload: %v", err)
}
if deleted.State != storagepkg.QuarantineStateDeleted {
t.Fatalf("state = %q, want deleted", deleted.State)
}
if data, err := os.ReadFile(artifact); err != nil || string(data) != "active cache" {
t.Fatalf("replacement cache changed: data=%q err=%v", data, err)
}
if _, err := os.Stat(quarantined); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing quarantine payload became present: %v", err)
}
stored, err := store.GetQuarantineEntry(context.Background(), entry.ID)
if err != nil {
t.Fatal(err)
}
if stored.State != storagepkg.QuarantineStateDeleted {
t.Fatalf("stored state = %q, want deleted", stored.State)
}
})
}
}
func TestQuarantineControllerPurgeReportsZeroBytesForMissingGoCachePayload(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "missing-payload")
if err := os.MkdirAll(original, 0o700); err != nil {
t.Fatal(err)
}
artifact := filepath.Join(original, "replacement-artifact")
if err := os.WriteFile(artifact, []byte("active cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := storagepkg.QuarantineEntry{
ID: "missing-payload",
ResourceType: storagepkg.ResourceTypeGoCache,
OriginalPath: original,
QuarantinePath: quarantined,
SizeBytes: 42,
State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC().Add(-2 * time.Hour),
DeleteAfter: time.Now().UTC().Add(-time.Hour),
Metadata: json.RawMessage(`{"ownership":"managed"}`),
}
if err := store.CreateQuarantineEntry(context.Background(), &entry); err != nil {
t.Fatalf("create missing-payload entry: %v", err)
}
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
result, err := controller.Purge(
context.Background(), storagepkg.QuarantinePurgeScopeEligible,
storagepkg.QuarantineConfirmationEligible,
)
if err != nil {
t.Fatalf("Purge: %v", err)
}
if result.Deleted != 1 || result.Failed != 0 || result.DeletedBytes != 0 {
t.Fatalf("purge result = %#v, want one deleted entry, zero failures, zero deleted bytes", result)
}
if data, err := os.ReadFile(artifact); err != nil || string(data) != "active cache" {
t.Fatalf("replacement cache changed: data=%q err=%v", data, err)
}
stored, err := store.GetQuarantineEntry(context.Background(), entry.ID)
if err != nil {
t.Fatal(err)
}
if stored.State != storagepkg.QuarantineStateDeleted {
t.Fatalf("stored state = %q, want deleted", stored.State)
}
}
func TestQuarantineControllerPurgeReportsBytesFromDeletionOutcome(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "removed-before-delete")
if err := os.MkdirAll(original, 0o700); err != nil {
t.Fatal(err)
}
liveArtifact := filepath.Join(original, "replacement-artifact")
if err := os.WriteFile(liveArtifact, []byte("active cache"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(quarantined, "old-artifact"), []byte("old cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := storagepkg.QuarantineEntry{
ID: "removed-before-delete",
ResourceType: storagepkg.ResourceTypeGoCache,
OriginalPath: original,
QuarantinePath: quarantined,
SizeBytes: 42,
State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC().Add(-2 * time.Hour),
DeleteAfter: time.Now().UTC().Add(-time.Hour),
Metadata: json.RawMessage(`{"ownership":"managed"}`),
}
if err := store.CreateQuarantineEntry(context.Background(), &entry); err != nil {
t.Fatalf("create entry: %v", err)
}
deletingStore := &removePayloadOnGetStore{delegate: store, path: quarantined}
controller := &workspaceQuarantineController{settings: settings, store: deletingStore, homeDir: home}
result, err := controller.Purge(
context.Background(), storagepkg.QuarantinePurgeScopeEligible,
storagepkg.QuarantineConfirmationEligible,
)
if err != nil {
t.Fatalf("Purge: %v", err)
}
if result.Deleted != 1 || result.Failed != 0 || result.DeletedBytes != 0 {
t.Fatalf("purge result = %#v, want one deleted entry and zero deleted bytes", result)
}
if data, err := os.ReadFile(liveArtifact); err != nil || string(data) != "active cache" {
t.Fatalf("live cache changed: data=%q err=%v", data, err)
}
}
func TestQuarantineControllerDeletesHistoricalAdoptedGoCacheAfterReadoption(t *testing.T) {
root := t.TempDir()
home := filepath.Join(root, "home")
original := filepath.Join(root, "first-cache")
current := filepath.Join(root, "second-cache")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
if _, err := settings.AdoptGoCachePath(context.Background(), current); err != nil {
t.Fatalf("AdoptGoCachePath: %v", err)
}
entry := storagepkg.QuarantineEntry{
ID: "entry-cache", ResourceType: storagepkg.ResourceTypeGoCache,
OriginalPath: original, QuarantinePath: quarantined,
State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC().Add(-2 * time.Hour),
DeleteAfter: time.Now().UTC().Add(-time.Hour),
Metadata: json.RawMessage(`{"ownership":"adopted"}`),
}
if err := store.CreateQuarantineEntry(context.Background(), &entry); err != nil {
t.Fatal(err)
}
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
deleted, err := controller.PermanentDelete(context.Background(), entry.ID, "DELETE")
if err != nil {
t.Fatalf("PermanentDelete: %v", err)
}
if deleted.State != storagepkg.QuarantineStateDeleted {
t.Fatalf("state = %q, want deleted", deleted.State)
}
if _, err := os.Stat(quarantined); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("historical quarantine path still exists: %v", err)
}
}
func TestQuarantineControllerRejectsEarlyGoCacheDeleteAndKeepsQuarantine(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := createGoCacheQuarantineEntry(
t, store, original, quarantined, time.Now().UTC().Add(time.Hour),
)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
_, err := controller.PermanentDelete(context.Background(), entry.ID, "DELETE")
if !errors.Is(err, storagepkg.ErrConflict) {
t.Fatalf("PermanentDelete error = %v, want storage ErrConflict", err)
}
if _, err := os.Stat(quarantined); err != nil {
t.Fatalf("quarantine path changed before deadline: %v", err)
}
got, err := store.GetQuarantineEntry(context.Background(), entry.ID)
if err != nil {
t.Fatal(err)
}
if got.State != storagepkg.QuarantineStateQuarantined {
t.Fatalf("quarantine state = %q, want quarantined", got.State)
}
}
func createGoCacheQuarantineEntry(
t *testing.T,
store *storagepkg.Store,
original string,
quarantined string,
deleteAfter time.Time,
) storagepkg.QuarantineEntry {
t.Helper()
homeDir := filepath.Dir(filepath.Dir(filepath.Dir(quarantined)))
ownership := "adopted"
if filepath.Clean(original) == filepath.Join(homeDir, "cache", "go-build") {
ownership = "managed"
}
metadata, err := json.Marshal(map[string]string{"ownership": ownership})
if err != nil {
t.Fatal(err)
}
entry := storagepkg.QuarantineEntry{
ID: "entry-cache", ResourceType: storagepkg.ResourceTypeGoCache,
OriginalPath: original, QuarantinePath: quarantined,
State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC().Add(-2 * time.Hour), DeleteAfter: deleteAfter,
Metadata: metadata,
}
if err := store.CreateQuarantineEntry(context.Background(), &entry); err != nil {
t.Fatal(err)
}
return entry
}
func createGoCacheQuarantineEntryWithID(
t *testing.T,
store *storagepkg.Store,
home string,
id string,
deleteAfter time.Time,
) storagepkg.QuarantineEntry {
t.Helper()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", id)
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(quarantined, "artifact"), []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
ownership := "managed"
metadata, err := json.Marshal(map[string]string{"ownership": ownership})
if err != nil {
t.Fatal(err)
}
entry := storagepkg.QuarantineEntry{
ID: id, ResourceType: storagepkg.ResourceTypeGoCache,
OriginalPath: original, QuarantinePath: quarantined,
State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC().Add(-2 * time.Hour), DeleteAfter: deleteAfter,
Metadata: metadata,
}
if err := store.CreateQuarantineEntry(context.Background(), &entry); err != nil {
t.Fatal(err)
}
return entry
}
func markGoCacheQuarantineFailed(t *testing.T, store *storagepkg.Store, id string) {
t.Helper()
if _, err := store.TransitionQuarantineEntry(
context.Background(), id, storagepkg.QuarantineStateFailed, "retryable failure",
); err != nil {
t.Fatalf("mark quarantine failed: %v", err)
}
}
func newStorageMaintenanceStores(t *testing.T) (*storagepkg.SettingsStore, *storagepkg.Store) {
t.Helper()
connection, err := sqlx.Open("sqlite3", filepath.Join(t.TempDir(), "storage.db"))
if err != nil {
t.Fatal(err)
}
connection.SetMaxOpenConns(1)
pool := db.NewPool(connection, connection)
t.Cleanup(func() { _ = pool.Close() })
rawSettings, err := systemsettings.NewStore(pool)
if err != nil {
t.Fatal(err)
}
store, err := storagepkg.NewStore(pool)
if err != nil {
t.Fatal(err)
}
return storagepkg.NewSettingsStore(rawSettings), store
}