Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,22 @@ tasks:
- rm -f internal/infra/vm/runtimebin/VERSION internal/infra/vm/runtimebin/LICENSE-GPL
- rm -f internal/infra/vm/runtimebin/sha256sums.txt

firmware-clean:
desc: Remove the entire firmware download cache
cmds:
- rm -rf "{{.BBOX_CACHE_HOME}}/broodbox/firmware"
vars:
BBOX_CACHE_HOME:
sh: |
base="${XDG_CACHE_HOME:-}"
if [ -z "$base" ]; then
case "$(uname -s)" in
Darwin) base="$HOME/Library/Caches" ;;
*) base="$HOME/.cache" ;;
esac
fi
echo "$base"

# --- OCI guest images ---

image-base:
Expand Down
64 changes: 62 additions & 2 deletions internal/infra/vm/firmware.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ const (
maxFirmwareExtractSize = 128 << 20
// maxFirmwareEntries caps the number of tar entries to prevent inode exhaustion.
maxFirmwareEntries = 1000
// firmwareTempPrefix is the shared prefix of transient download entries
// created directly under cacheRoot (firmware-*.tar.gz archives and
// firmware-extract-* dirs). pruneStaleFirmwareVersions skips any entry
// with this prefix so leftover temps from a crashed run are never
// mistaken for version directories. Both os.CreateTemp and os.MkdirTemp
// patterns below reference it to keep the coupling explicit.
firmwareTempPrefix = "firmware-"
)

type FirmwareResolution struct {
Expand Down Expand Up @@ -193,7 +200,7 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
}
url := firmwareURL(version, osName, candidate)

tmpArchive, err := os.CreateTemp(cacheRoot, "firmware-*.tar.gz")
tmpArchive, err := os.CreateTemp(cacheRoot, firmwareTempPrefix+"*.tar.gz")
if err != nil {
return FirmwareResolution{}, fmt.Errorf("create firmware temp archive: %w", err)
}
Expand All @@ -220,7 +227,7 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
continue
}

tmpDir, err := os.MkdirTemp(cacheRoot, "firmware-extract-")
tmpDir, err := os.MkdirTemp(cacheRoot, firmwareTempPrefix+"extract-")
if err != nil {
cleanupArchive()
return FirmwareResolution{}, fmt.Errorf("create firmware temp dir: %w", err)
Expand Down Expand Up @@ -276,6 +283,11 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
return FirmwareResolution{}, err
}

// Fresh download succeeded — reclaim cache dirs left by older
// firmware versions. Only the version pinned in go.mod is ever
// used, so previous version dirs (tens of MB each) are dead weight.
pruneStaleFirmwareVersions(ctx, cacheRoot, version)

slog.DebugContext(ctx, "firmware downloaded", "dir", filepath.Dir(finalFwPath), "version", version, "arch", candidate)
return FirmwareResolution{
Dir: filepath.Dir(finalFwPath),
Expand All @@ -294,6 +306,54 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
return FirmwareResolution{}, lastErr
}

// pruneStaleFirmwareVersions removes firmware cache version directories under
// cacheRoot that don't match keepVersion. Each go-microvm version bump creates
// a new <cacheRoot>/<version>/ directory, but only the pinned version is ever
// used, so older ones accumulate unbounded.
//
// It must be called only after a successful fresh download, with the firmware
// lock held (no concurrent writers). Failures are logged and never propagated:
// the firmware is already cached successfully, so pruning is strictly
// best-effort cleanup. The lock file and transient temp entries
// (firmware-*.tar.gz archives, firmware-extract-* dirs) are left untouched.
func pruneStaleFirmwareVersions(ctx context.Context, cacheRoot, keepVersion string) {
entries, err := os.ReadDir(cacheRoot)
if err != nil {
slog.DebugContext(ctx, "firmware prune: cannot scan cache root", "root", cacheRoot, "error", err)
return
}

for _, entry := range entries {
// Only version directories are pruned; this skips .firmware.lock
// and any leftover firmware-*.tar.gz temp archives (plain files).
if !entry.IsDir() {
continue
}
name := entry.Name()
if name == keepVersion {
continue
}
// Skip transient temp dirs created in cacheRoot during download.
if strings.HasPrefix(name, firmwareTempPrefix) {
continue
}

stalePath := filepath.Join(cacheRoot, name)
if err := os.RemoveAll(stalePath); err != nil {
// "not exist" is legitimately quiet; unexpected removal failures
// (permissions drift, read-only mount) are surfaced at Warn so
// they don't silently fill the cache disk over many version bumps.
if !errors.Is(err, os.ErrNotExist) {
slog.WarnContext(ctx, "firmware prune: failed to remove stale version",
"path", stalePath, "error", err)
}
continue
}
slog.DebugContext(ctx, "firmware prune: removed stale version",
"path", stalePath, "version", name)
}
}

func findSystemFirmware(version, osName, arch string) (FirmwareResolution, error) {
path, err := findFirmwareInDirs(systemFirmwareDirs(), firmwareLibNames(osName))
if err != nil {
Expand Down
63 changes: 63 additions & 0 deletions internal/infra/vm/firmware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package vm
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
Expand Down Expand Up @@ -897,3 +898,65 @@ func TestDownloadToFile_HTTPError(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected status")
}

func TestPruneStaleFirmwareVersions(t *testing.T) {
t.Parallel()

cacheRoot := t.TempDir()
keep := "v0.0.8"

// Two stale version dirs, the current one, plus entries that must be
// preserved: the lock file and transient download temp entries.
staleA := filepath.Join(cacheRoot, "v0.0.6", "linux-amd64")
staleB := filepath.Join(cacheRoot, "v0.0.7", "linux-amd64")
current := filepath.Join(cacheRoot, keep, "linux-amd64")
for _, d := range []string{staleA, staleB, current} {
require.NoError(t, os.MkdirAll(d, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(d, "firmware.json"), []byte("{}"), 0o600))
}
lockPath := filepath.Join(cacheRoot, ".firmware.lock")
require.NoError(t, os.WriteFile(lockPath, nil, 0o600))
tmpArchive := filepath.Join(cacheRoot, "firmware-123.tar.gz")
require.NoError(t, os.WriteFile(tmpArchive, []byte("x"), 0o600))
tmpExtract := filepath.Join(cacheRoot, "firmware-extract-456")
require.NoError(t, os.MkdirAll(tmpExtract, 0o755))

pruneStaleFirmwareVersions(context.Background(), cacheRoot, keep)

// Stale version dirs removed.
for _, d := range []string{filepath.Join(cacheRoot, "v0.0.6"), filepath.Join(cacheRoot, "v0.0.7")} {
_, err := os.Stat(d)
assert.True(t, os.IsNotExist(err), "stale version %s should be removed", filepath.Base(d))
}
// Current version and non-version entries preserved.
_, err := os.Stat(current)
assert.NoError(t, err, "current version dir must be preserved")
_, err = os.Stat(lockPath)
assert.NoError(t, err, "lock file must be preserved")
_, err = os.Stat(tmpArchive)
assert.NoError(t, err, "transient temp archive must be preserved")
_, err = os.Stat(tmpExtract)
assert.NoError(t, err, "transient extract dir must be preserved")
}

func TestPruneStaleFirmwareVersions_NoStaleVersions(t *testing.T) {
t.Parallel()

cacheRoot := t.TempDir()
keep := "v0.0.8"
current := filepath.Join(cacheRoot, keep, "linux-amd64")
require.NoError(t, os.MkdirAll(current, 0o755))

// Must be a no-op when only the current version is present.
pruneStaleFirmwareVersions(context.Background(), cacheRoot, keep)

_, err := os.Stat(current)
assert.NoError(t, err, "current version dir must be preserved")
}

func TestPruneStaleFirmwareVersions_MissingCacheRoot(t *testing.T) {
t.Parallel()

// Must not panic when the cache root does not exist.
pruneStaleFirmwareVersions(context.Background(), filepath.Join(t.TempDir(), "nope"), "v0.0.8")
}
Loading