Files
kandev/apps/backend/internal/backendapp/storage_maintenance.go
T

871 lines
30 KiB
Go

package backendapp
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/kandev/kandev/internal/agent/runtime/activity"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/system/jobs"
systemmetrics "github.com/kandev/kandev/internal/system/metrics"
systemsettings "github.com/kandev/kandev/internal/system/settings"
storagepkg "github.com/kandev/kandev/internal/system/storage"
"github.com/kandev/kandev/internal/system/storage/dockerstore"
"github.com/kandev/kandev/internal/system/storage/gocache"
"github.com/kandev/kandev/internal/system/storage/tempartifacts"
"github.com/kandev/kandev/internal/system/storage/workspaces"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/worktree"
)
const workspaceDependenciesProviderName = "workspace_dependencies"
type storageComposition struct {
handler *storagepkg.Handler
runtime *storagepkg.Runtime
workspaceRestorer *workspaceQuarantineController
tempArtifacts *tempartifacts.Registry
}
func provideStorageComposition(
cfg *config.Config,
pool *db.Pool,
tracker *jobs.Tracker,
lifecycleMgr *lifecycle.Manager,
worktreeMgr *worktree.Manager,
taskSvc *taskservice.Service,
log *logger.Logger,
logError func(string, error),
) (*storageComposition, error) {
rawSettings, err := systemsettings.NewStore(pool)
if err != nil {
return nil, fmt.Errorf("initialize storage settings: %w", err)
}
settings := storagepkg.NewSettingsStore(rawSettings)
store, err := storagepkg.NewStore(pool)
if err != nil {
return nil, fmt.Errorf("initialize storage store: %w", err)
}
tempArtifacts := tempartifacts.NewRegistry(tempartifacts.Config{
Store: store, TempRoot: os.TempDir(),
})
if err := tempArtifacts.Reconcile(context.Background()); err != nil {
logError("reconcile temporary artifact registry", err)
}
tempProvider := tempartifacts.NewProvider(tempartifacts.ProviderConfig{
Registry: tempArtifacts, Store: store, HomeDir: cfg.ResolvedHomeDir(),
TrashDir: filepath.Join(cfg.ResolvedHomeDir(), "trash"),
})
if err := tempProvider.Reconcile(context.Background()); err != nil {
logError("reconcile temporary artifact quarantine", err)
}
coordinator := activity.NewCoordinator(activity.Options{})
taskSvc.SetTaskResourceCleanupActivityGate(&taskCleanupActivityGate{coordinator: coordinator})
goCache := gocache.New(gocache.Config{
HomeDir: cfg.ResolvedHomeDir(), TrashDir: filepath.Join(cfg.ResolvedHomeDir(), "trash"),
Settings: settings, Store: store,
})
lifecycleMgr.SetActivityCoordinator(coordinator)
lifecycleMgr.SetManagedGoCacheEnvironmentProvider(goCache)
if worktreeMgr != nil {
worktreeMgr.SetScriptEnvironmentProvider(goCache)
}
inventory := &storageInventory{reader: pool.Reader(), worktrees: worktreeMgr, lifecycle: lifecycleMgr}
workspaceFactory := newWorkspaceFactory(cfg, store, inventory, worktreeMgr)
dockerClient := &lazyStorageDocker{provider: lifecycleMgr.DockerClientProvider(), activity: coordinator}
dockerProvider := dockerstore.NewProvider(
dockerClient, &containerInventory{reader: pool.Reader()}, settings,
)
overview := &storageOverview{
settings: settings, quarantine: store, workspaceFactory: workspaceFactory, goCache: goCache,
docker: dockerProvider, dockerClient: dockerClient, dockerHost: cfg.Docker.Host,
homeDir: cfg.ResolvedHomeDir(), tempArtifacts: tempProvider,
}
cachedOverview := storagepkg.NewOverviewCache(overview)
quarantine := &workspaceQuarantineController{
settings: settings, store: store, factory: workspaceFactory, homeDir: cfg.ResolvedHomeDir(),
activity: coordinator, temporary: tempProvider,
}
providers := storageCleanupProviders(settings, workspaceFactory, goCache, dockerProvider, quarantine, tempProvider)
if taskSvc.AttachmentService() == nil && taskSvc.AttachmentRepository() != nil {
attachmentSvc, attachmentErr := taskservice.NewAttachmentService(
taskSvc.AttachmentRepository(), cfg.ResolvedHomeDir(), taskSvc.AuthorizeWorkspaceAccess, log,
)
if attachmentErr != nil {
return nil, fmt.Errorf("initialize prompt attachment storage: %w", attachmentErr)
}
taskSvc.SetAttachmentService(attachmentSvc)
}
if attachmentSvc := taskSvc.AttachmentService(); attachmentSvc != nil {
if lifecycleMgr != nil {
lifecycleMgr.SetAttachmentReader(attachmentSvc)
}
providers = append(providers, attachmentCleanupProvider{service: attachmentSvc})
}
runner := storagepkg.NewRunner(storagepkg.RunnerConfig{
Activity: coordinator, Store: store, Providers: providers, Overview: cachedOverview,
})
scheduler := storagepkg.NewScheduler(settings, runner, storagepkg.SchedulerOptions{})
runtime := storagepkg.NewRuntime(storagepkg.RuntimeConfig{
Scheduler: scheduler, Settings: settings, Worker: taskSvc,
Reconciler: &workspaceReconciler{settings: settings, factory: workspaceFactory},
})
operations := storagepkg.NewOperations(storagepkg.OperationsConfig{
Settings: settings, Store: store, Jobs: tracker, Activity: coordinator,
Providers: providers, Overview: cachedOverview, GoCache: goCache, Quarantine: quarantine,
})
handler := storagepkg.NewHandler(storagepkg.HandlerConfig{
Settings: settings, Runs: store, Quarantine: store, Overview: cachedOverview,
DiskCapacity: func(ctx context.Context, path string) (storagepkg.DiskCapacity, error) {
capacity, err := systemmetrics.DiskUsage(ctx, path)
if err != nil {
return storagepkg.DiskCapacity{}, err
}
return storagepkg.DiskCapacity{
TotalBytes: capacity.TotalBytes, UsedBytes: capacity.UsedBytes,
AvailableBytes: capacity.AvailableBytes, UsedPercent: capacity.UsedPercent,
}, nil
},
DiskPath: cfg.ResolvedHomeDir(),
Mutations: operations, OnSettingsChanged: runtime.ApplySettings, LogError: logError,
})
return &storageComposition{
handler: handler, runtime: runtime, workspaceRestorer: quarantine, tempArtifacts: tempArtifacts,
}, nil
}
type taskCleanupActivityGate struct {
coordinator *activity.Coordinator
}
type attachmentCleanupProvider struct {
service *taskservice.AttachmentService
}
func (p attachmentCleanupProvider) Name() string { return "prompt_attachments" }
func (p attachmentCleanupProvider) Cleanup(ctx context.Context) (map[string]any, error) {
deleted, err := p.service.CleanupExpired(ctx)
return map[string]any{"deleted": deleted}, err
}
func (g *taskCleanupActivityGate) AcquireTaskResourceCleanup(
ctx context.Context,
) (taskservice.TaskResourceCleanupActivityLease, error) {
return g.coordinator.AcquireTask(ctx, activity.KindCleanupScript)
}
type workspaceFactory func(storagepkg.StorageMaintenanceSettings) *workspaces.Provider
func newWorkspaceFactory(
cfg *config.Config,
store *storagepkg.Store,
inventory workspaces.InventorySource,
pruner workspaces.WorktreePruner,
) workspaceFactory {
return func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider {
return workspaces.New(workspaces.Config{
TasksRoot: filepath.Join(cfg.ResolvedHomeDir(), "tasks"),
TrashRoot: filepath.Join(cfg.ResolvedHomeDir(), "trash"),
Inventory: inventory, Store: store, Pruner: pruner,
GracePeriod: time.Duration(current.OrphanGraceHours) * time.Hour,
Retention: time.Duration(current.QuarantineRetentionHours) * time.Hour,
})
}
}
type quarantineSummarizer interface {
SummarizeQuarantine(context.Context) (storagepkg.QuarantineSummary, error)
}
type storageOverview struct {
settings *storagepkg.SettingsStore
quarantine quarantineSummarizer
workspaceFactory workspaceFactory
workspaceAnalyze func(context.Context, storagepkg.StorageMaintenanceSettings) (workspaces.Analysis, error)
goCache *gocache.Provider
goCacheAnalyze func(context.Context) (gocache.Analysis, error)
docker *dockerstore.Provider
tempArtifacts *tempartifacts.Provider
dockerClient *lazyStorageDocker
dockerHost string
homeDir string
}
func (o *storageOverview) Summary(ctx context.Context) (storagepkg.Summary, error) {
settings, err := o.settings.GetSettings(ctx)
if err != nil {
return storagepkg.Summary{}, err
}
var (
workspaceSummary workspaces.Analysis
workspaceErr error
goCacheSummary gocache.Analysis
goCacheErr error
quarantineSummary storagepkg.QuarantineSummary
quarantineErr error
dockerSummary dockerstore.Analysis
tempSummary tempartifacts.Analysis
tempErr error
)
var measurements sync.WaitGroup
measurements.Add(4)
workspaceAnalyze := o.workspaceAnalyze
if workspaceAnalyze == nil {
workspaceAnalyze = func(ctx context.Context, settings storagepkg.StorageMaintenanceSettings) (workspaces.Analysis, error) {
return o.workspaceFactory(settings).Analyze(ctx)
}
}
goCacheAnalyze := o.goCacheAnalyze
if goCacheAnalyze == nil {
goCacheAnalyze = o.goCache.Analyze
}
go func() {
defer measurements.Done()
workspaceSummary, workspaceErr = workspaceAnalyze(ctx, settings)
}()
go func() {
defer measurements.Done()
goCacheSummary, goCacheErr = goCacheAnalyze(ctx)
}()
go func() {
defer measurements.Done()
quarantineSummary, quarantineErr = o.quarantine.SummarizeQuarantine(ctx)
}()
go func() {
defer measurements.Done()
dockerSummary = o.docker.Analyze(ctx)
}()
if o.tempArtifacts != nil {
measurements.Add(1)
go func() {
defer measurements.Done()
tempSummary, tempErr = o.tempArtifacts.Analyze(ctx)
}()
}
measurements.Wait()
return storagepkg.Summary{
Workspaces: summaryValue(workspaceSummary, workspaceErr),
GoCache: summaryValue(goCacheSummary, goCacheErr),
Quarantine: summaryValue(quarantineSummary, quarantineErr),
TemporaryArtifacts: summaryValue(tempSummary, tempErr),
Docker: map[string]any{
"available": dockerSummary.Available, "build_cache_bytes": dockerSummary.BuildCacheBytes,
"image_layer_bytes": dockerSummary.ImageLayerBytes,
"unused_image_bytes": dockerSummary.UnusedImageBytes, "warnings": dockerSummary.Warnings,
"managed_container_count": dockerSummary.ManagedContainerCount,
"managed_container_bytes": dockerSummary.ManagedContainerBytes,
},
}, nil
}
func (o *storageOverview) Capabilities(
ctx context.Context,
settings storagepkg.StorageMaintenanceSettings,
) storagepkg.Capabilities {
return o.SettingsCapabilities(ctx, settings)
}
func (o *storageOverview) SettingsCapabilities(
ctx context.Context,
settings storagepkg.StorageMaintenanceSettings,
) storagepkg.Capabilities {
goPath := settings.GoCache.AdoptedPath
if goPath == "" {
goPath = filepath.Join(o.homeDir, "cache", "go-build")
}
dockerAvailable := o.dockerClient != nil && o.dockerClient.Ping(ctx) == nil
return storagepkg.Capabilities{
ManagedGoCachePath: goPath, GoCacheAdoptionAvailable: true,
TemporaryArtifactsAvailable: o.tempArtifacts != nil,
DockerAvailable: dockerAvailable, DockerHost: o.dockerHost,
HostGlobalDockerCleanup: dockerAvailable && settings.Docker.DedicatedDaemonAcknowledged,
}
}
func summaryValue(value any, err error) any {
if err == nil {
return value
}
return map[string]any{"available": false, "warning": err.Error()}
}
type namedCleanupProvider struct {
name string
cleanup func(context.Context) (map[string]any, error)
}
type quarantinePurger interface {
Purge(context.Context, storagepkg.QuarantinePurgeScope, string) (storagepkg.QuarantinePurgeResult, error)
}
type quarantineCleanupProvider struct {
purger quarantinePurger
}
func (p quarantineCleanupProvider) Name() string { return "quarantine" }
func (p quarantineCleanupProvider) Cleanup(ctx context.Context) (map[string]any, error) {
result, err := p.purger.Purge(ctx, storagepkg.QuarantinePurgeScopeEligible, storagepkg.QuarantineConfirmationEligible)
return toMap(result), err
}
type goCacheCleanupProvider struct {
provider *gocache.Provider
}
func (p goCacheCleanupProvider) Name() string { return "go_cache" }
func (p goCacheCleanupProvider) Cleanup(ctx context.Context) (map[string]any, error) {
result, err := p.provider.Cleanup(ctx)
return toMap(result), err
}
func (p goCacheCleanupProvider) CleanupExplicit(ctx context.Context) (map[string]any, error) {
result, err := p.provider.CleanupExplicit(ctx)
return toMap(result), err
}
func (p namedCleanupProvider) Name() string { return p.name }
func (p namedCleanupProvider) Cleanup(ctx context.Context) (map[string]any, error) {
return p.cleanup(ctx)
}
func storageCleanupProviders(
settings *storagepkg.SettingsStore,
workspaceFactory workspaceFactory,
goCache *gocache.Provider,
docker *dockerstore.Provider,
quarantine quarantinePurger,
temporary ...storagepkg.CleanupProvider,
) []storagepkg.CleanupProvider {
providers := []storagepkg.CleanupProvider{
quarantineCleanupProvider{purger: quarantine},
workspaceCleanupAdapter(settings, workspaceFactory),
workspaceDependencyCleanupAdapter(settings, workspaceFactory),
goCacheCleanupProvider{provider: goCache},
dockerContainerCleanupAdapter(settings, docker),
dockerBuildCacheCleanupAdapter(settings, docker),
dockerImageCleanupAdapter(settings, docker),
}
return append(providers, temporary...)
}
func workspaceDependencyCleanupAdapter(
settings *storagepkg.SettingsStore,
factory workspaceFactory,
) storagepkg.CleanupProvider {
return namedCleanupProvider{name: workspaceDependenciesProviderName, cleanup: func(ctx context.Context) (map[string]any, error) {
current, err := settings.GetSettings(ctx)
if err != nil || !current.Workspaces.DependencyCleanupEnabled {
return nil, err
}
result, err := factory(current).CleanupDependencies(ctx)
return toMap(result), err
}}
}
func workspaceCleanupAdapter(
settings *storagepkg.SettingsStore,
factory workspaceFactory,
) storagepkg.CleanupProvider {
return namedCleanupProvider{name: "workspaces", cleanup: func(ctx context.Context) (map[string]any, error) {
current, err := settings.GetSettings(ctx)
if err != nil || !current.Workspaces.Enabled {
return nil, err
}
result, err := factory(current).Cleanup(ctx)
return toMap(result), err
}}
}
func dockerContainerCleanupAdapter(
settings *storagepkg.SettingsStore,
provider *dockerstore.Provider,
) storagepkg.CleanupProvider {
return namedCleanupProvider{name: "kandev_containers", cleanup: func(ctx context.Context) (map[string]any, error) {
current, err := settings.GetSettings(ctx)
if err != nil || !current.KandevContainers.Enabled {
return nil, err
}
return toMap(provider.CleanupContainers(ctx)), nil
}}
}
func dockerBuildCacheCleanupAdapter(
settings *storagepkg.SettingsStore,
provider *dockerstore.Provider,
) storagepkg.CleanupProvider {
return namedCleanupProvider{name: "docker_build_cache", cleanup: func(ctx context.Context) (map[string]any, error) {
current, err := settings.GetSettings(ctx)
if err != nil || !current.Docker.BuildCacheEnabled {
return nil, err
}
result, err := provider.PruneBuildCache(ctx)
return toMap(result), err
}}
}
func dockerImageCleanupAdapter(
settings *storagepkg.SettingsStore,
provider *dockerstore.Provider,
) storagepkg.CleanupProvider {
return namedCleanupProvider{name: "docker_unused_images", cleanup: func(ctx context.Context) (map[string]any, error) {
current, err := settings.GetSettings(ctx)
if err != nil || !current.Docker.UnusedImagesEnabled {
return nil, err
}
result, err := provider.PruneUnusedImages(ctx)
return toMap(result), err
}}
}
func toMap(value any) map[string]any {
encoded, _ := json.Marshal(value)
result := make(map[string]any)
_ = json.Unmarshal(encoded, &result)
return result
}
type workspaceReconciler struct {
settings *storagepkg.SettingsStore
factory workspaceFactory
}
func (r *workspaceReconciler) Reconcile(ctx context.Context) error {
settings, err := r.settings.GetSettings(ctx)
if err != nil {
return err
}
_, err = r.factory(settings).Reconcile(ctx)
return err
}
type workspaceQuarantineController struct {
settings *storagepkg.SettingsStore
store quarantineEntryStore
factory workspaceFactory
homeDir string
activity *activity.Coordinator
temporary *tempartifacts.Provider
rename func(string, string) error
}
type quarantineEntryStore interface {
GetQuarantineEntry(context.Context, string) (storagepkg.QuarantineEntry, error)
ListQuarantineEntries(context.Context, bool) ([]storagepkg.QuarantineEntry, error)
TransitionQuarantineEntry(
context.Context, string, storagepkg.QuarantineState, string,
) (storagepkg.QuarantineEntry, error)
}
func (c *workspaceQuarantineController) Purge(
ctx context.Context,
scope storagepkg.QuarantinePurgeScope,
confirmation string,
) (storagepkg.QuarantinePurgeResult, error) {
result := storagepkg.QuarantinePurgeResult{Scope: scope}
force := false
switch scope {
case storagepkg.QuarantinePurgeScopeEligible:
if confirmation != storagepkg.QuarantineConfirmationEligible {
return result, fmt.Errorf("%w: eligible quarantine purge requires %s confirmation", storagepkg.ErrValidation, storagepkg.QuarantineConfirmationEligible)
}
case storagepkg.QuarantinePurgeScopeAll:
if confirmation != storagepkg.QuarantineConfirmationForce {
return result, storagepkg.ErrForceDeleteConfirmation
}
force = true
default:
return result, fmt.Errorf("%w: unknown quarantine purge scope %q", storagepkg.ErrValidation, scope)
}
entries, err := c.store.ListQuarantineEntries(ctx, false)
if err != nil {
return result, err
}
now := time.Now().UTC()
var purgeErrs []error
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return result, err
}
result.Considered++
if !force && now.Before(entry.DeleteAfter) {
result.Protected++
result.ProtectedBytes += entry.SizeBytes
continue
}
var deleted storagepkg.QuarantineEntry
if force {
deleted, err = c.PermanentDeleteForce(ctx, entry.ID, confirmation)
} else {
deleted, err = c.PermanentDelete(ctx, entry.ID, storagepkg.QuarantineConfirmationDelete)
}
if err != nil {
result.Failed++
result.FailedBytes += entry.SizeBytes
result.Failures = append(result.Failures, storagepkg.QuarantinePurgeFailure{ID: entry.ID, Error: err.Error()})
purgeErrs = append(purgeErrs, fmt.Errorf("%s: %w", entry.ID, err))
continue
}
result.Deleted++
result.DeletedBytes += deleted.SizeBytes
}
return result, errors.Join(purgeErrs...)
}
const (
goCacheOwnershipManaged = "managed"
goCacheOwnershipAdopted = "adopted"
)
func (c *workspaceQuarantineController) RestoreTask(
ctx context.Context,
taskID string,
) workspaces.WorkspaceRecovery {
current, err := c.settings.GetSettings(ctx)
if err != nil {
return workspaces.WorkspaceRecovery{TaskID: taskID, Status: "failed", Message: err.Error()}
}
return c.factory(current).RestoreTask(ctx, taskID)
}
func (c *workspaceQuarantineController) Restore(
ctx context.Context,
id string,
) (storagepkg.QuarantineEntry, error) {
entry, err := c.store.GetQuarantineEntry(ctx, id)
if err != nil {
return storagepkg.QuarantineEntry{}, err
}
if entry.ResourceType == storagepkg.ResourceTypeGoCache {
return c.restoreGoCache(ctx, entry)
}
if entry.ResourceType == storagepkg.ResourceTypeTemporaryArtifact {
if c.temporary == nil {
return storagepkg.QuarantineEntry{}, errors.New("temporary artifact provider is unavailable")
}
return c.temporary.Restore(ctx, id)
}
if entry.ResourceType != storagepkg.ResourceTypeTaskWorkspace {
return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: unsupported quarantine resource %q", storagepkg.ErrValidation, entry.ResourceType)
}
settings, err := c.settings.GetSettings(ctx)
if err != nil {
return storagepkg.QuarantineEntry{}, err
}
restored, err := c.factory(settings).Restore(ctx, id)
if errors.Is(err, workspaces.ErrRestoreConflict) {
return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: %v", storagepkg.ErrConflict, err)
}
return restored, err
}
func (c *workspaceQuarantineController) PermanentDelete(
ctx context.Context,
id string,
confirmation string,
) (storagepkg.QuarantineEntry, error) {
return c.permanentDelete(ctx, id, confirmation, false)
}
func (c *workspaceQuarantineController) PermanentDeleteForce(
ctx context.Context,
id string,
confirmation string,
) (storagepkg.QuarantineEntry, error) {
return c.permanentDelete(ctx, id, confirmation, true)
}
func (c *workspaceQuarantineController) permanentDelete(
ctx context.Context,
id string,
confirmation string,
force bool,
) (storagepkg.QuarantineEntry, error) {
entry, err := c.store.GetQuarantineEntry(ctx, id)
if err != nil {
return storagepkg.QuarantineEntry{}, err
}
if entry.ResourceType == storagepkg.ResourceTypeGoCache {
if force {
return c.deleteGoCacheForce(ctx, entry, confirmation)
}
return c.deleteGoCache(ctx, entry, confirmation)
}
if entry.ResourceType == storagepkg.ResourceTypeTemporaryArtifact {
if c.temporary == nil {
return storagepkg.QuarantineEntry{}, errors.New("temporary artifact provider is unavailable")
}
if force {
return c.temporary.PermanentDeleteForce(ctx, id, confirmation)
}
return c.temporary.PermanentDelete(ctx, id, confirmation)
}
if entry.ResourceType != storagepkg.ResourceTypeTaskWorkspace {
return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: unsupported quarantine resource %q", storagepkg.ErrValidation, entry.ResourceType)
}
settings, err := c.settings.GetSettings(ctx)
if err != nil {
return storagepkg.QuarantineEntry{}, err
}
if force {
return c.factory(settings).PermanentDeleteForce(ctx, id, confirmation)
}
return c.factory(settings).PermanentDelete(ctx, id, confirmation)
}
func (c *workspaceQuarantineController) restoreGoCache(
ctx context.Context,
entry storagepkg.QuarantineEntry,
) (storagepkg.QuarantineEntry, error) {
if err := c.validateGoCacheEntry(ctx, entry); err != nil {
return storagepkg.QuarantineEntry{}, err
}
lease, err := c.acquireGoCacheMaintenance(ctx)
if err != nil {
return storagepkg.QuarantineEntry{}, err
}
if lease != nil {
defer lease.Release()
}
if err := c.rejectAmbiguousMissingGoCachePayload(entry); err != nil {
return storagepkg.QuarantineEntry{}, err
}
if err := c.prepareGoCacheRestoreDestination(entry); err != nil {
return storagepkg.QuarantineEntry{}, err
}
if err := c.renamePath(entry.QuarantinePath, entry.OriginalPath); err != nil {
return storagepkg.QuarantineEntry{}, fmt.Errorf("restore Go cache: %w", err)
}
return c.persistGoCacheRestore(ctx, entry)
}
func (c *workspaceQuarantineController) prepareGoCacheRestoreDestination(
entry storagepkg.QuarantineEntry,
) error {
if err := c.validateGoCacheRestorePath(entry.OriginalPath); err != nil {
return err
}
if _, err := os.Lstat(entry.OriginalPath); err == nil {
ownership, ownershipErr := goCacheEntryOwnership(entry)
if ownershipErr != nil {
return ownershipErr
}
removed, removeErr := gocache.RemoveRestorePlaceholder(
entry.OriginalPath, ownership == goCacheOwnershipAdopted,
)
if removeErr != nil {
return removeErr
}
if !removed {
return fmt.Errorf("%w: Go-cache restore destination already exists", storagepkg.ErrConflict)
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect Go-cache restore destination: %w", err)
}
if err := os.MkdirAll(filepath.Dir(entry.OriginalPath), 0o700); err != nil {
return fmt.Errorf("create Go-cache restore parent: %w", err)
}
return nil
}
func (c *workspaceQuarantineController) persistGoCacheRestore(
ctx context.Context,
entry storagepkg.QuarantineEntry,
) (storagepkg.QuarantineEntry, error) {
restored, err := c.store.TransitionQuarantineEntry(
ctx, entry.ID, storagepkg.QuarantineStateRestored, "",
)
if err != nil {
persistErr := fmt.Errorf("persist Go-cache restore: %w", err)
if rollbackErr := c.renamePath(entry.OriginalPath, entry.QuarantinePath); rollbackErr != nil {
return storagepkg.QuarantineEntry{}, errors.Join(
persistErr, fmt.Errorf("rollback Go-cache restore: %w", rollbackErr),
)
}
return storagepkg.QuarantineEntry{}, persistErr
}
return restored, nil
}
func (c *workspaceQuarantineController) renamePath(oldPath, newPath string) error {
if c.rename != nil {
return c.rename(oldPath, newPath)
}
return os.Rename(oldPath, newPath)
}
func (c *workspaceQuarantineController) acquireGoCacheMaintenance(
ctx context.Context,
) (*activity.MaintenanceLease, error) {
if c.activity == nil {
return nil, nil
}
lease, busy, err := c.activity.TryAcquireMaintenance(ctx, 0)
if errors.Is(err, activity.ErrBusy) {
return nil, &storagepkg.BusyError{
Resources: activity.BusyResourcesForKinds(busy),
ForceAvailable: false,
}
}
return lease, err
}
func (c *workspaceQuarantineController) deleteGoCache(
ctx context.Context,
entry storagepkg.QuarantineEntry,
confirmation string,
) (storagepkg.QuarantineEntry, error) {
return c.deleteGoCacheWithRetention(ctx, entry, confirmation, false)
}
func (c *workspaceQuarantineController) deleteGoCacheForce(
ctx context.Context,
entry storagepkg.QuarantineEntry,
confirmation string,
) (storagepkg.QuarantineEntry, error) {
return c.deleteGoCacheWithRetention(ctx, entry, confirmation, true)
}
func (c *workspaceQuarantineController) deleteGoCacheWithRetention(
ctx context.Context,
entry storagepkg.QuarantineEntry,
confirmation string,
force bool,
) (storagepkg.QuarantineEntry, error) {
if force {
if confirmation != storagepkg.QuarantineConfirmationForce {
return storagepkg.QuarantineEntry{}, storagepkg.ErrForceDeleteConfirmation
}
} else if confirmation != storagepkg.QuarantineConfirmationDelete {
return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: quarantine deletion requires DELETE confirmation", storagepkg.ErrValidation)
}
if err := c.validateGoCacheEntry(ctx, entry); err != nil {
return storagepkg.QuarantineEntry{}, err
}
if !force && time.Now().UTC().Before(entry.DeleteAfter) {
return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: quarantine retention deadline has not elapsed", storagepkg.ErrConflict)
}
if err := c.rejectAmbiguousMissingGoCachePayload(entry); err != nil {
return storagepkg.QuarantineEntry{}, err
}
if err := os.RemoveAll(entry.QuarantinePath); err != nil {
return storagepkg.QuarantineEntry{}, fmt.Errorf("delete quarantined Go cache: %w", err)
}
deleted, err := c.store.TransitionQuarantineEntry(
context.WithoutCancel(ctx), entry.ID, storagepkg.QuarantineStateDeleted, "",
)
if err != nil {
return storagepkg.QuarantineEntry{}, fmt.Errorf("persist Go-cache deletion: %w", err)
}
return deleted, nil
}
func (c *workspaceQuarantineController) validateGoCacheEntry(
_ context.Context,
entry storagepkg.QuarantineEntry,
) error {
if entry.State != storagepkg.QuarantineStateQuarantined &&
entry.State != storagepkg.QuarantineStateFailed {
return fmt.Errorf("%w: Go-cache quarantine entry is %q", storagepkg.ErrConflict, entry.State)
}
expectedQuarantine := filepath.Join(c.homeDir, "trash", "go-cache", entry.ID)
if filepath.Clean(entry.QuarantinePath) != filepath.Clean(expectedQuarantine) {
return fmt.Errorf("%w: Go-cache quarantine paths do not match managed storage", storagepkg.ErrValidation)
}
ownership, err := goCacheEntryOwnership(entry)
if err != nil {
return err
}
switch ownership {
case goCacheOwnershipManaged:
expectedOriginal := filepath.Join(c.homeDir, "cache", "go-build")
if filepath.Clean(entry.OriginalPath) != filepath.Clean(expectedOriginal) {
return fmt.Errorf("%w: managed Go-cache original path does not match owned storage", storagepkg.ErrValidation)
}
case goCacheOwnershipAdopted:
original := filepath.Clean(entry.OriginalPath)
if !filepath.IsAbs(original) || original == filepath.VolumeName(original)+string(filepath.Separator) {
return fmt.Errorf("%w: adopted Go-cache original path is unsafe", storagepkg.ErrValidation)
}
default:
return fmt.Errorf("%w: unknown Go-cache ownership policy %q", storagepkg.ErrValidation, ownership)
}
if err := storagepkg.ValidateNoSymlinkPath(c.homeDir, entry.QuarantinePath); err != nil {
return fmt.Errorf("%w: validate Go-cache quarantine path: %v", storagepkg.ErrValidation, err)
}
return nil
}
func goCacheEntryOwnership(entry storagepkg.QuarantineEntry) (string, error) {
var metadata struct {
Ownership string `json:"ownership"`
}
if err := json.Unmarshal(entry.Metadata, &metadata); err != nil || metadata.Ownership == "" {
return "", fmt.Errorf("%w: invalid Go-cache quarantine ownership metadata", storagepkg.ErrValidation)
}
return metadata.Ownership, nil
}
func (c *workspaceQuarantineController) rejectAmbiguousMissingGoCachePayload(
entry storagepkg.QuarantineEntry,
) error {
if entry.State != storagepkg.QuarantineStateFailed &&
entry.State != storagepkg.QuarantineStateQuarantined {
return nil
}
if _, err := os.Lstat(entry.OriginalPath); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return fmt.Errorf("inspect failed Go-cache original path: %w", err)
}
if _, err := os.Lstat(entry.QuarantinePath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect failed Go-cache quarantine path: %w", err)
}
if err := c.validateGoCacheRestorePath(entry.OriginalPath); err != nil {
return err
}
ownership, err := goCacheEntryOwnership(entry)
if err != nil {
return err
}
placeholder, err := gocache.IsRestorePlaceholder(
entry.OriginalPath, ownership == goCacheOwnershipAdopted,
)
if err != nil {
return fmt.Errorf("inspect Go-cache restore placeholder: %w", err)
}
if placeholder {
return fmt.Errorf(
"%w: quarantined Go-cache payload is missing and the original path is only a rotation placeholder",
storagepkg.ErrConflict,
)
}
return fmt.Errorf(
"%w: quarantined Go-cache payload is missing and the populated original cannot be proven restored",
storagepkg.ErrConflict,
)
}
func (c *workspaceQuarantineController) validateGoCacheRestorePath(path string) error {
anchor, err := storagepkg.CommonPath(c.homeDir, path)
if err != nil {
return fmt.Errorf("%w: resolve Go-cache restore safety anchor: %v", storagepkg.ErrValidation, err)
}
if err := storagepkg.ValidateNoSymlinkPath(anchor, path); err != nil {
return fmt.Errorf("%w: validate Go-cache restore path: %v", storagepkg.ErrValidation, err)
}
return nil
}