586 lines
18 KiB
Go
586 lines
18 KiB
Go
// Package gocache owns Kandev's opt-in local Go build cache.
|
|
package gocache
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/kandev/kandev/internal/system/storage"
|
|
"github.com/kandev/kandev/internal/system/storage/filescan"
|
|
)
|
|
|
|
var (
|
|
// ErrNotOwned is returned when the managed path lacks Kandev's marker.
|
|
ErrNotOwned = errors.New("go cache is not owned by Kandev")
|
|
ErrAdoptionConfirmation = errors.New("go cache adoption requires ADOPT confirmation")
|
|
)
|
|
|
|
const (
|
|
markerName = ".go-build.kandev-owned"
|
|
markerContent = "kandev-managed-go-cache\n"
|
|
)
|
|
|
|
// SettingsSource returns the persisted install-wide storage settings.
|
|
type SettingsSource interface {
|
|
GetSettings(ctx context.Context) (storage.StorageMaintenanceSettings, error)
|
|
}
|
|
|
|
// QuarantineStore persists cache rotations before filesystem mutation.
|
|
type QuarantineStore interface {
|
|
CreateQuarantineEntry(ctx context.Context, entry *storage.QuarantineEntry) error
|
|
TransitionQuarantineEntry(ctx context.Context, id string, next storage.QuarantineState, lastError string) (storage.QuarantineEntry, error)
|
|
ListQuarantineEntries(ctx context.Context, includeTerminal bool) ([]storage.QuarantineEntry, error)
|
|
}
|
|
|
|
// Config contains the provider's install-owned paths and persistence dependencies.
|
|
type Config struct {
|
|
HomeDir string
|
|
TrashDir string
|
|
Settings SettingsSource
|
|
Store QuarantineStore
|
|
Scanner *filescan.Limiter
|
|
OnProgress func(filescan.Progress)
|
|
}
|
|
|
|
// Provider manages the single Go cache selected by persisted settings.
|
|
type Provider struct {
|
|
config Config
|
|
}
|
|
|
|
// Analysis describes the configured cache without changing it.
|
|
type Analysis struct {
|
|
Path string `json:"path"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
Owned bool `json:"owned"`
|
|
Enabled bool `json:"enabled"`
|
|
UnmanagedPath string `json:"unmanaged_path,omitempty"`
|
|
// A nil size means that the distinct user cache was not measured. A pointer
|
|
// preserves an explicitly measured zero in the successful response.
|
|
UnmanagedSizeBytes *int64 `json:"unmanaged_size_bytes,omitempty"`
|
|
}
|
|
|
|
// CleanupResult describes one cache rotation.
|
|
type CleanupResult struct {
|
|
Path string `json:"path"`
|
|
Skipped bool `json:"skipped"`
|
|
Reason string `json:"reason,omitempty"`
|
|
BytesBefore int64 `json:"bytes_before"`
|
|
BytesAfter int64 `json:"bytes_after"`
|
|
ReclaimedBytes int64 `json:"reclaimed_bytes"`
|
|
QuarantineEntry *storage.QuarantineEntry `json:"quarantine_entry"`
|
|
}
|
|
|
|
// New creates a managed Go-cache provider.
|
|
func New(config Config) *Provider {
|
|
return &Provider{config: config}
|
|
}
|
|
|
|
// ExecutionEnvironment returns variables injected into new local executions.
|
|
func (p *Provider) ExecutionEnvironment(ctx context.Context) (map[string]string, error) {
|
|
settings, err := p.loadSettings(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !settings.GoCache.Enabled {
|
|
return nil, nil
|
|
}
|
|
cachePath, adopted, err := p.cachePath(settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := p.validateCachePath(cachePath); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(cachePath, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create managed Go cache: %w", err)
|
|
}
|
|
if !adopted {
|
|
if err := writeMarker(cachePath); err != nil {
|
|
return nil, fmt.Errorf("create Go-cache ownership marker: %w", err)
|
|
}
|
|
}
|
|
return map[string]string{"GOCACHE": cachePath}, nil
|
|
}
|
|
|
|
// Analyze reports the selected cache's current usage.
|
|
func (p *Provider) Analyze(ctx context.Context) (Analysis, error) {
|
|
settings, err := p.loadSettings(ctx)
|
|
if err != nil {
|
|
return Analysis{}, err
|
|
}
|
|
cachePath, adopted, err := p.cachePath(settings)
|
|
if err != nil {
|
|
return Analysis{}, err
|
|
}
|
|
if err := p.validateCachePath(cachePath); err != nil {
|
|
return Analysis{}, err
|
|
}
|
|
owned := adopted || hasValidMarker(cachePath)
|
|
scanner := p.config.Scanner
|
|
if scanner == nil {
|
|
scanner = filescan.NewLimiter(4)
|
|
}
|
|
measurementRoots := []filescan.Root{
|
|
{
|
|
Path: cachePath, MissingOK: true, SymlinkPolicy: filescan.RejectSymlinks,
|
|
Exclude: func(path string, _ fs.DirEntry) bool { return path == markerPath(cachePath) },
|
|
},
|
|
}
|
|
unmanagedPath, hasUnmanagedPath := defaultGoCachePath()
|
|
if hasUnmanagedPath && unmanagedPath != cachePath {
|
|
measurementRoots = append(measurementRoots, filescan.Root{
|
|
Path: unmanagedPath, MissingOK: true, SymlinkPolicy: filescan.SkipSymlinks,
|
|
})
|
|
}
|
|
measurements := scanner.Measure(ctx, measurementRoots, p.config.OnProgress)
|
|
if len(measurements) != len(measurementRoots) {
|
|
return Analysis{}, errors.New("go-cache scanner returned an invalid result")
|
|
}
|
|
if err := measurements[0].Err; err != nil {
|
|
return Analysis{}, fmt.Errorf("measure Go cache: %w", err)
|
|
}
|
|
analysis := Analysis{
|
|
Path: cachePath, SizeBytes: measurements[0].Bytes, Owned: owned, Enabled: settings.GoCache.Enabled,
|
|
}
|
|
if !hasUnmanagedPath || unmanagedPath == cachePath {
|
|
return analysis, nil
|
|
}
|
|
if err := measurements[1].Err; err != nil {
|
|
return Analysis{}, fmt.Errorf("measure Go cache: %w", err)
|
|
}
|
|
analysis.UnmanagedPath = unmanagedPath
|
|
unmanagedSizeBytes := measurements[1].Bytes
|
|
analysis.UnmanagedSizeBytes = &unmanagedSizeBytes
|
|
return analysis, nil
|
|
}
|
|
|
|
func (p *Provider) AnalyzeWithProgress(
|
|
ctx context.Context,
|
|
onProgress func(filescan.Progress),
|
|
) (Analysis, error) {
|
|
copy := *p
|
|
copy.config.OnProgress = onProgress
|
|
return copy.Analyze(ctx)
|
|
}
|
|
|
|
// MeasurementRoots returns every filesystem root included by Analyze for the
|
|
// supplied settings. Callers use these roots to avoid attributing one file to
|
|
// multiple storage categories.
|
|
func (p *Provider) MeasurementRoots(settings storage.StorageMaintenanceSettings) ([]string, error) {
|
|
cachePath, _, err := p.cachePath(settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
roots := []string{cachePath}
|
|
unmanagedPath, hasUnmanagedPath := defaultGoCachePath()
|
|
if hasUnmanagedPath && unmanagedPath != cachePath {
|
|
roots = append(roots, unmanagedPath)
|
|
}
|
|
return roots, nil
|
|
}
|
|
|
|
func defaultGoCachePath() (string, bool) {
|
|
if configured := os.Getenv("GOCACHE"); configured != "" {
|
|
if configured == "off" || !filepath.IsAbs(configured) {
|
|
return "", false
|
|
}
|
|
return filepath.Clean(configured), true
|
|
}
|
|
cacheDir, err := os.UserCacheDir()
|
|
if err != nil || !filepath.IsAbs(cacheDir) {
|
|
return "", false
|
|
}
|
|
return filepath.Join(cacheDir, "go-build"), true
|
|
}
|
|
|
|
// Cleanup rotates an above-threshold cache into Kandev trash.
|
|
func (p *Provider) Cleanup(ctx context.Context) (CleanupResult, error) {
|
|
return p.cleanup(ctx, false)
|
|
}
|
|
|
|
// CleanupExplicit rotates an above-threshold cache even when scheduled maintenance is disabled.
|
|
func (p *Provider) CleanupExplicit(ctx context.Context) (CleanupResult, error) {
|
|
return p.cleanup(ctx, true)
|
|
}
|
|
|
|
func (p *Provider) cleanup(ctx context.Context, explicit bool) (CleanupResult, error) {
|
|
settings, err := p.loadSettings(ctx)
|
|
if err != nil {
|
|
return CleanupResult{}, err
|
|
}
|
|
cachePath, adopted, err := p.cachePath(settings)
|
|
if err != nil {
|
|
return CleanupResult{}, err
|
|
}
|
|
result := CleanupResult{Path: cachePath}
|
|
if !settings.GoCache.Enabled && !explicit {
|
|
return result, nil
|
|
}
|
|
if err := p.validateCacheAndTrash(cachePath); err != nil {
|
|
return result, err
|
|
}
|
|
if !adopted && !hasValidMarker(cachePath) {
|
|
return result, ErrNotOwned
|
|
}
|
|
result.BytesBefore, err = directorySize(cachePath)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if result.BytesBefore <= settings.GoCache.MaxBytes {
|
|
result.BytesAfter = result.BytesBefore
|
|
return result, nil
|
|
}
|
|
entry, err := p.rotate(ctx, cachePath, result.BytesBefore, settings.QuarantineRetentionHours, adopted)
|
|
if err != nil {
|
|
var activeErr *storage.ActiveQuarantineIntentError
|
|
if errors.As(err, &activeErr) {
|
|
result.BytesAfter = result.BytesBefore
|
|
result.Skipped = true
|
|
result.Reason = "active_quarantine"
|
|
return result, nil
|
|
}
|
|
return result, err
|
|
}
|
|
result.QuarantineEntry = entry
|
|
result.ReclaimedBytes = result.BytesBefore
|
|
return result, nil
|
|
}
|
|
|
|
// ValidateAdoption verifies an explicitly confirmed external cache path.
|
|
func (p *Provider) ValidateAdoption(_ context.Context, path, confirmation string) error {
|
|
if confirmation != "ADOPT" {
|
|
return ErrAdoptionConfirmation
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("adopted Go-cache path must be absolute: %q", path)
|
|
}
|
|
path = filepath.Clean(path)
|
|
if path == filepath.VolumeName(path)+string(filepath.Separator) {
|
|
return errors.New("filesystem root cannot be adopted as a Go cache")
|
|
}
|
|
trashRoot := filepath.Clean(p.config.TrashDir)
|
|
if !filepath.IsAbs(trashRoot) {
|
|
return fmt.Errorf("go-cache trash path must be absolute: %q", trashRoot)
|
|
}
|
|
if pathsOverlap(path, trashRoot) {
|
|
return errors.New("adopted Go cache and Kandev trash must not contain each other")
|
|
}
|
|
if err := p.validateCacheAndTrash(path); err != nil {
|
|
return err
|
|
}
|
|
return probeAtomicRename(path, trashRoot)
|
|
}
|
|
|
|
func (p *Provider) loadSettings(ctx context.Context) (storage.StorageMaintenanceSettings, error) {
|
|
if p.config.Settings == nil {
|
|
return storage.StorageMaintenanceSettings{}, errors.New("go-cache settings source is required")
|
|
}
|
|
settings, err := p.config.Settings.GetSettings(ctx)
|
|
if err != nil {
|
|
return storage.StorageMaintenanceSettings{}, fmt.Errorf("load Go-cache settings: %w", err)
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
func (p *Provider) cachePath(settings storage.StorageMaintenanceSettings) (string, bool, error) {
|
|
path := settings.GoCache.AdoptedPath
|
|
adopted := path != ""
|
|
if !adopted {
|
|
path = filepath.Join(p.config.HomeDir, "cache", "go-build")
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return "", false, fmt.Errorf("managed Go-cache path must be absolute: %q", path)
|
|
}
|
|
return filepath.Clean(path), adopted, nil
|
|
}
|
|
|
|
func (p *Provider) rotate(
|
|
ctx context.Context,
|
|
cachePath string,
|
|
sizeBytes int64,
|
|
retentionHours int,
|
|
adopted bool,
|
|
) (*storage.QuarantineEntry, error) {
|
|
if p.config.Store == nil {
|
|
return nil, errors.New("go-cache quarantine store is required")
|
|
}
|
|
trashRoot := filepath.Clean(p.config.TrashDir)
|
|
if !filepath.IsAbs(trashRoot) {
|
|
return nil, fmt.Errorf("go-cache trash path must be absolute: %q", trashRoot)
|
|
}
|
|
if err := p.validateCacheAndTrash(cachePath); err != nil {
|
|
return nil, err
|
|
}
|
|
quarantineDir := filepath.Join(trashRoot, "go-cache")
|
|
anchor, err := storage.CommonPath(p.config.HomeDir, cachePath, trashRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := storage.ValidateNoSymlinkPath(anchor, quarantineDir); err != nil {
|
|
return nil, fmt.Errorf("validate Go-cache quarantine directory: %w", err)
|
|
}
|
|
if err := os.MkdirAll(quarantineDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create Go-cache trash: %w", err)
|
|
}
|
|
if _, err := storage.ReleaseFailedQuarantineIntent(
|
|
ctx, p.config.Store, storage.ResourceTypeGoCache, cachePath,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
id := uuid.NewString()
|
|
ownership := "managed"
|
|
if adopted {
|
|
ownership = "adopted"
|
|
}
|
|
metadata, _ := json.Marshal(map[string]string{"ownership": ownership})
|
|
entry := &storage.QuarantineEntry{
|
|
ID: id,
|
|
ResourceType: storage.ResourceTypeGoCache,
|
|
OriginalPath: cachePath,
|
|
QuarantinePath: filepath.Join(quarantineDir, id),
|
|
SizeBytes: sizeBytes,
|
|
State: storage.QuarantineStateQuarantined,
|
|
QuarantinedAt: now,
|
|
DeleteAfter: now.Add(time.Duration(retentionHours) * time.Hour),
|
|
Metadata: metadata,
|
|
}
|
|
if err := p.config.Store.CreateQuarantineEntry(ctx, entry); err != nil {
|
|
return nil, fmt.Errorf("persist Go-cache quarantine intent: %w", err)
|
|
}
|
|
if err := os.Rename(cachePath, entry.QuarantinePath); err != nil {
|
|
_, _ = p.config.Store.TransitionQuarantineEntry(ctx, id, storage.QuarantineStateFailed, err.Error())
|
|
return nil, fmt.Errorf("quarantine Go cache: %w", err)
|
|
}
|
|
if err := p.validateCachePath(cachePath); err != nil {
|
|
return nil, fmt.Errorf("validate replacement Go cache: %w", err)
|
|
}
|
|
if err := os.MkdirAll(cachePath, 0o755); err != nil {
|
|
return nil, fmt.Errorf("recreate managed Go cache: %w", err)
|
|
}
|
|
if !adopted {
|
|
if err := writeMarker(cachePath); err != nil {
|
|
return nil, fmt.Errorf("restore Go-cache ownership marker: %w", err)
|
|
}
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
func writeMarker(cachePath string) error {
|
|
marker := markerPath(cachePath)
|
|
info, err := os.Lstat(marker)
|
|
if err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
return ErrNotOwned
|
|
}
|
|
existing, readErr := os.ReadFile(marker)
|
|
if readErr != nil || string(existing) != markerContent {
|
|
return ErrNotOwned
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("inspect Go-cache ownership marker: %w", err)
|
|
}
|
|
return os.WriteFile(marker, []byte(markerContent), 0o600)
|
|
}
|
|
|
|
func hasValidMarker(cachePath string) bool {
|
|
path := markerPath(cachePath)
|
|
info, err := os.Lstat(path)
|
|
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
return false
|
|
}
|
|
marker, err := os.ReadFile(path)
|
|
return err == nil && string(marker) == markerContent
|
|
}
|
|
|
|
func markerPath(cachePath string) string {
|
|
return filepath.Join(cachePath, markerName)
|
|
}
|
|
|
|
// RemoveRestorePlaceholder removes the empty cache directory recreated after
|
|
// rotation. Managed caches must contain only their bound ownership marker;
|
|
// adopted caches must be completely empty.
|
|
func RemoveRestorePlaceholder(cachePath string, adopted bool) (bool, error) {
|
|
placeholder, err := IsRestorePlaceholder(cachePath, adopted)
|
|
if err != nil || !placeholder {
|
|
return placeholder, err
|
|
}
|
|
if !adopted {
|
|
if err := os.Remove(markerPath(cachePath)); err != nil {
|
|
return false, fmt.Errorf("remove Go-cache restore marker: %w", err)
|
|
}
|
|
}
|
|
if err := os.Remove(cachePath); err != nil {
|
|
return false, fmt.Errorf("remove Go-cache restore placeholder: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// IsRestorePlaceholder reports whether cachePath is the empty replacement
|
|
// created after a cache rotation, without modifying it.
|
|
func IsRestorePlaceholder(cachePath string, adopted bool) (bool, error) {
|
|
info, err := os.Lstat(cachePath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return false, nil
|
|
}
|
|
entries, err := os.ReadDir(cachePath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("inspect Go-cache restore placeholder: %w", err)
|
|
}
|
|
if adopted {
|
|
return len(entries) == 0, nil
|
|
}
|
|
return len(entries) == 1 && entries[0].Name() == markerName && hasValidMarker(cachePath), nil
|
|
}
|
|
|
|
func (p *Provider) validateCachePath(cachePath string) error {
|
|
anchor, err := storage.CommonPath(p.config.HomeDir, cachePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := storage.ValidateNoSymlinkPath(anchor, cachePath); err != nil {
|
|
return fmt.Errorf("validate Go-cache path: %w", err)
|
|
}
|
|
return rejectSymlink(cachePath)
|
|
}
|
|
|
|
func (p *Provider) validateCacheAndTrash(cachePath string) error {
|
|
trashRoot := filepath.Clean(p.config.TrashDir)
|
|
anchor, err := storage.CommonPath(p.config.HomeDir, cachePath, trashRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for name, path := range map[string]string{"cache": cachePath, "trash": trashRoot} {
|
|
if err := storage.ValidateNoSymlinkPath(anchor, path); err != nil {
|
|
return fmt.Errorf("validate Go-cache %s path: %w", name, err)
|
|
}
|
|
}
|
|
return rejectSymlink(cachePath)
|
|
}
|
|
|
|
func rejectSymlink(path string) error {
|
|
info, err := os.Lstat(path) // codeql[go/path-injection] path is constrained by ValidateNoSymlinkPath.
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("inspect Go-cache path: %w", err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("go-cache path must not be a symlink: %s", path)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("go-cache path is not a directory: %s", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func directorySize(root string) (int64, error) {
|
|
return directorySizeWithSymlinkPolicy(root, false)
|
|
}
|
|
|
|
func directorySizeNoFollow(root string) (int64, error) {
|
|
return directorySizeWithSymlinkPolicy(root, true)
|
|
}
|
|
|
|
func directorySizeWithSymlinkPolicy(root string, skipSymlinks bool) (int64, error) {
|
|
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
|
|
return 0, nil
|
|
} else if err != nil {
|
|
return 0, fmt.Errorf("inspect Go-cache path: %w", err)
|
|
}
|
|
var total int64
|
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
if skipSymlinks {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("symlink found in Go cache: %s", entry.Name())
|
|
}
|
|
if path == markerPath(root) {
|
|
return nil
|
|
}
|
|
if entry.Type().IsRegular() {
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
total += info.Size()
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("measure Go cache: %w", err)
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func pathsOverlap(first, second string) bool {
|
|
return pathContains(first, second) || pathContains(second, first)
|
|
}
|
|
|
|
func pathContains(parent, child string) bool {
|
|
rel, err := filepath.Rel(parent, child)
|
|
return err == nil && rel != ".." && !filepath.IsAbs(rel) && !startsWithParent(rel)
|
|
}
|
|
|
|
func startsWithParent(rel string) bool {
|
|
return len(rel) >= 3 && rel[:3] == ".."+string(filepath.Separator)
|
|
}
|
|
|
|
func probeAtomicRename(cachePath, trashRoot string) error {
|
|
anchor, err := storage.CommonPath(cachePath, trashRoot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root, err := os.OpenRoot(anchor)
|
|
if err != nil {
|
|
return fmt.Errorf("open Go-cache adoption root: %w", err)
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
|
|
cacheRel, err := filepath.Rel(anchor, cachePath)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve Go-cache probe path: %w", err)
|
|
}
|
|
trashRel, err := filepath.Rel(anchor, trashRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve Go-cache trash path: %w", err)
|
|
}
|
|
if err := root.MkdirAll(trashRel, 0o700); err != nil {
|
|
return fmt.Errorf("create Go-cache trash: %w", err)
|
|
}
|
|
probeName := ".kandev-adoption-probe-" + uuid.NewString()
|
|
source := filepath.Join(cacheRel, probeName)
|
|
probe, err := root.OpenFile(source, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return fmt.Errorf("create adoption probe: %w", err)
|
|
}
|
|
if err := probe.Close(); err != nil {
|
|
_ = root.Remove(source)
|
|
return fmt.Errorf("close adoption probe: %w", err)
|
|
}
|
|
destination := filepath.Join(trashRel, probeName)
|
|
if err := root.Rename(source, destination); err != nil {
|
|
_ = root.Remove(source)
|
|
return fmt.Errorf("adopted Go cache must support atomic rename into Kandev trash: %w", err)
|
|
}
|
|
if err := root.Remove(destination); err != nil {
|
|
return fmt.Errorf("remove adoption probe: %w", err)
|
|
}
|
|
return nil
|
|
}
|