Skip to content
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ cocoon
│ ├── rm SNAPSHOT [SNAPSHOT...] Delete snapshot(s)
│ ├── export [flags] SNAPSHOT Export snapshot to portable archive (or stdout)
│ └── import [flags] [FILE] Import snapshot from archive (or stdin)
├── gc Remove unreferenced blobs and VM dirs
├── gc [flags] Remove unreferenced blobs, VM dirs; --snapshot for LRU snapshot eviction
├── version Show version, revision, and build time
└── completion [bash|zsh|fish|powershell]
```
Expand Down Expand Up @@ -831,6 +831,76 @@ State changes are detected via **fsnotify** on the VM index file (sub-second lat

This ensures blobs referenced by running VMs or saved snapshots are never deleted.

### Snapshot LRU Eviction

Bare `cocoon gc` only reclaims **orphans** (on-disk data with no DB record) and **stale pending** records (crashed mid-Create, older than 24h). To also evict healthy snapshots by access recency, pass `--snapshot`:

| Flag | Effect |
| -------------------- | ----------------------------------------------------------------------------------------------- |
| `--snapshot` | Enable LRU eviction. Bare flag = evict **every** non-pending snapshot. |
| `--snapshot-keep N` | Keep at most N most-recently-accessed snapshots. |
| `--snapshot-age DUR` | Evict snapshots last accessed before this duration (e.g. `720h` for 30d). |
| `--snapshot-size SZ` | Evict oldest snapshots until total size ≤ this (e.g. `100GB`). |
| `--snapshot-dry-run` | Log which snapshots would be LRU-evicted; act on nothing. **Snapshot-only — orphans and other GC modules still execute.** |

Sub-flags combine as union of evictions (intersection of kept) — a snapshot is kept only if it passes **every** active criterion. All sub-flags require `--snapshot`; negative values are rejected.

`LastAccessedAt` is updated on `Restore`, `vm clone` (via `DataDir`), `snapshot export`, and `snapshot import` (set to creation time). `Inspect` and `list` do not count as access.

```bash
# Preview what 30-day eviction would remove (snapshot-only — other GC modules still run)
cocoon gc --snapshot --snapshot-age=720h --snapshot-dry-run

# Production: weekly cleanup, keep 50 newest within 7 days
cocoon gc --snapshot --snapshot-age=168h --snapshot-keep=50

# Cap storage at 100GB
cocoon gc --snapshot --snapshot-size=100GB

# Nuke all snapshots (dev / test reset)
cocoon gc --snapshot
```

### Scheduled Snapshot GC

`cocoon gc` is a one-shot, lock-safe operation — drive periodic execution from a systemd timer or cron. Recommended template (systemd):

```ini
# /etc/systemd/system/cocoon-gc.service
[Unit]
Description=Cocoon snapshot GC (LRU eviction)

[Service]
Type=oneshot
ExecStart=/usr/local/bin/cocoon gc --snapshot --snapshot-age=168h --snapshot-keep=50
StandardOutput=journal
StandardError=journal
```

```ini
# /etc/systemd/system/cocoon-gc.timer
[Unit]
Description=Run cocoon snapshot GC daily

[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
Unit=cocoon-gc.service

[Install]
WantedBy=timers.target
```

Enable: `systemctl enable --now cocoon-gc.timer`.

For cron, drop a one-liner into `/etc/cron.daily/cocoon-gc`:

```sh
#!/bin/sh
exec /usr/local/bin/cocoon gc --snapshot --snapshot-age=168h --snapshot-keep=50
```

## OS Images

Pre-built OCI VM images (Ubuntu 22.04, 24.04) are published to GHCR and auto-built by GitHub Actions when `os-image/` changes:
Expand Down
4 changes: 2 additions & 2 deletions cmd/core/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ func InitBridgeNetwork(conf *config.Config, bridgeDev string) (network.Network,
return p, nil
}

func InitSnapshot(conf *config.Config) (snapshot.Snapshot, error) {
s, err := localfile.New(conf)
func InitSnapshot(conf *config.Config, opts ...localfile.Option) (snapshot.Snapshot, error) {
s, err := localfile.New(conf, opts...)
if err != nil {
return nil, fmt.Errorf("init snapshot backend: %w", err)
}
Expand Down
16 changes: 11 additions & 5 deletions cmd/others/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ type Actions interface {

// Commands builds system command set (gc, version, completion).
func Commands(h Actions) []*cobra.Command {
gcCmd := &cobra.Command{
Use: "gc",
Short: "Remove unreferenced blobs, boot files, VM dirs, and optionally evict snapshots",
RunE: h.GC,
}
gcCmd.Flags().Bool("snapshot", false, "evict snapshots by LRU; bare flag = all non-pending, refine with --snapshot-keep/age/size")
gcCmd.Flags().Int("snapshot-keep", 0, "keep at most N most-recently-accessed snapshots (requires --snapshot)")
gcCmd.Flags().Duration("snapshot-age", 0, "evict snapshots last accessed before this duration, e.g. 720h (requires --snapshot)")
gcCmd.Flags().String("snapshot-size", "", "evict oldest snapshots until total size ≤ this, e.g. 100GB (requires --snapshot)")
gcCmd.Flags().Bool("snapshot-dry-run", false, "log which snapshots would be LRU-evicted without acting (requires --snapshot; does NOT cover other GC modules)")
return []*cobra.Command{
{
Use: "gc",
Short: "Remove unreferenced blobs, boot files, and VM dirs",
RunE: h.GC,
},
gcCmd,
{
Use: "version",
Short: "Show version, git revision, and build timestamp",
Expand Down
47 changes: 46 additions & 1 deletion cmd/others/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package others
import (
"fmt"

"github.com/docker/go-units"
"github.com/projecteru2/core/log"
"github.com/spf13/cobra"

cmdcore "github.com/cocoonstack/cocoon/cmd/core"
"github.com/cocoonstack/cocoon/gc"
"github.com/cocoonstack/cocoon/network/bridge"
"github.com/cocoonstack/cocoon/snapshot/localfile"
"github.com/cocoonstack/cocoon/version"
)

Expand All @@ -22,6 +24,10 @@ func (h Handler) GC(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
policy, err := parseSnapshotPolicy(cmd)
if err != nil {
return err
}
backends, err := cmdcore.InitImageBackends(ctx, conf)
if err != nil {
return err
Expand All @@ -30,7 +36,7 @@ func (h Handler) GC(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
snapBackend, err := cmdcore.InitSnapshot(conf)
snapBackend, err := cmdcore.InitSnapshot(conf, localfile.WithGCPolicy(policy))
if err != nil {
return err
}
Expand Down Expand Up @@ -61,3 +67,42 @@ func (h Handler) Version(_ *cobra.Command, _ []string) error {
fmt.Print(version.String())
return nil
}

func parseSnapshotPolicy(cmd *cobra.Command) (localfile.EvictionPolicy, error) {
enabled, _ := cmd.Flags().GetBool("snapshot")
keep, _ := cmd.Flags().GetInt("snapshot-keep")
age, _ := cmd.Flags().GetDuration("snapshot-age")
sizeStr, _ := cmd.Flags().GetString("snapshot-size")
dryRun, _ := cmd.Flags().GetBool("snapshot-dry-run")

if keep < 0 {
return localfile.EvictionPolicy{}, fmt.Errorf("--snapshot-keep must be >= 0, got %d", keep)
}
if age < 0 {
return localfile.EvictionPolicy{}, fmt.Errorf("--snapshot-age must be >= 0, got %s", age)
}

var size int64
if sizeStr != "" {
n, err := units.RAMInBytes(sizeStr)
if err != nil {
return localfile.EvictionPolicy{}, fmt.Errorf("--snapshot-size %q: %w", sizeStr, err)
}
if n < 0 {
return localfile.EvictionPolicy{}, fmt.Errorf("--snapshot-size must be >= 0, got %s", sizeStr)
}
size = n
}

if !enabled && (keep > 0 || age > 0 || size > 0 || dryRun) {
return localfile.EvictionPolicy{}, fmt.Errorf("--snapshot-keep/age/size/dry-run requires --snapshot")
}
Comment thread
CMGS marked this conversation as resolved.

return localfile.EvictionPolicy{
Enabled: enabled,
DryRun: dryRun,
KeepLast: keep,
MaxAge: age,
MaxSize: size,
}, nil
}
100 changes: 100 additions & 0 deletions cmd/others/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package others

import (
"strings"
"testing"

"github.com/spf13/cobra"
)

func buildGCCmd() *cobra.Command {
cmd := &cobra.Command{Use: "gc"}
cmd.Flags().Bool("snapshot", false, "")
cmd.Flags().Int("snapshot-keep", 0, "")
cmd.Flags().Duration("snapshot-age", 0, "")
cmd.Flags().String("snapshot-size", "", "")
cmd.Flags().Bool("snapshot-dry-run", false, "")
return cmd
}

func TestParseSnapshotPolicy_Defaults(t *testing.T) {
cmd := buildGCCmd()
if err := cmd.ParseFlags(nil); err != nil {
t.Fatal(err)
}
p, err := parseSnapshotPolicy(cmd)
if err != nil {
t.Fatalf("default flags: %v", err)
}
if p.Enabled {
t.Errorf("Enabled should default false")
}
}

func TestParseSnapshotPolicy_NegativeRejected(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"negative keep", []string{"--snapshot", "--snapshot-keep=-1"}, "--snapshot-keep"},
{"negative age", []string{"--snapshot", "--snapshot-age=-1h"}, "--snapshot-age"},
{"negative size", []string{"--snapshot", "--snapshot-size=-100"}, "--snapshot-size"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
cmd := buildGCCmd()
if err := cmd.ParseFlags(tt.args); err != nil {
t.Fatal(err)
}
_, err := parseSnapshotPolicy(cmd)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.want) {
t.Errorf("err %q should mention %s", err, tt.want)
}
})
}
}

func TestParseSnapshotPolicy_SubFlagRequiresSnapshot(t *testing.T) {
cases := [][]string{
{"--snapshot-keep=5"},
{"--snapshot-age=24h"},
{"--snapshot-size=10GB"},
{"--snapshot-dry-run"},
}
for _, args := range cases {
t.Run(args[0], func(t *testing.T) {
cmd := buildGCCmd()
if err := cmd.ParseFlags(args); err != nil {
t.Fatal(err)
}
_, err := parseSnapshotPolicy(cmd)
if err == nil || !strings.Contains(err.Error(), "requires --snapshot") {
t.Errorf("want 'requires --snapshot' error, got %v", err)
}
})
}
}

func TestParseSnapshotPolicy_HappyPath(t *testing.T) {
cmd := buildGCCmd()
if err := cmd.ParseFlags([]string{
"--snapshot",
"--snapshot-keep=10",
"--snapshot-age=720h",
"--snapshot-size=100GB",
"--snapshot-dry-run",
}); err != nil {
t.Fatal(err)
}
p, err := parseSnapshotPolicy(cmd)
if err != nil {
t.Fatalf("happy path: %v", err)
}
if !p.Enabled || !p.DryRun || p.KeepLast != 10 || p.MaxAge == 0 || p.MaxSize == 0 {
t.Errorf("policy not populated: %+v", p)
}
}
6 changes: 3 additions & 3 deletions gc/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type Module[S any] struct {
ReadDB func(ctx context.Context) (S, error)

// Resolve returns IDs to delete; others holds snapshots from peer modules (cast for cross-module analysis, e.g. VMs pinning images).
Resolve func(snap S, others map[string]any) []string
Resolve func(ctx context.Context, snap S, others map[string]any) []string

// Collect removes the given IDs (called while the lock is held).
Collect func(ctx context.Context, ids []string) error
Expand All @@ -29,12 +29,12 @@ func (m Module[S]) readSnapshot(ctx context.Context) (any, error) {
return m.ReadDB(ctx)
}

func (m Module[S]) resolveTargets(snap any, others map[string]any) []string {
func (m Module[S]) resolveTargets(ctx context.Context, snap any, others map[string]any) []string {
typed, ok := snap.(S)
if !ok {
return nil
}
return m.Resolve(typed, others)
return m.Resolve(ctx, typed, others)
}

func (m Module[S]) collect(ctx context.Context, ids []string) error {
Expand Down
2 changes: 1 addition & 1 deletion gc/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (o *Orchestrator) Run(ctx context.Context) error {
// Phase 2: resolve deletion targets (cross-module via snapshots).
targets := make(map[string][]string)
for _, m := range locked {
if ids := m.resolveTargets(snapshots[m.getName()], snapshots); len(ids) > 0 {
if ids := m.resolveTargets(ctx, snapshots[m.getName()], snapshots); len(ids) > 0 {
targets[m.getName()] = ids
}
}
Expand Down
2 changes: 1 addition & 1 deletion gc/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ type runner interface {
getName() string
getLocker() lock.Locker
readSnapshot(ctx context.Context) (any, error)
resolveTargets(snap any, others map[string]any) []string
resolveTargets(ctx context.Context, snap any, others map[string]any) []string
collect(ctx context.Context, ids []string) error
}
2 changes: 1 addition & 1 deletion hypervisor/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (b *Backend) BuildGCModule() gc.Module[VMGCSnapshot] {
}
return snap, nil
},
Resolve: func(snap VMGCSnapshot, _ map[string]any) []string {
Resolve: func(_ context.Context, snap VMGCSnapshot, _ map[string]any) []string {
// "db" holds vms.json/vms.lock — exclude from orphan scan when RootDir == RunDir.
reserved := map[string]struct{}{"db": {}}
runOrphans := utils.FilterUnreferenced(snap.runDirs, snap.vmIDs, reserved)
Expand Down
2 changes: 1 addition & 1 deletion images/gchelper.go → images/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func BuildGCModule[I any](cfg GCModuleConfig[I]) gc.Module[ImageGCSnapshot] {
}
return snap, nil
},
Resolve: func(snap ImageGCSnapshot, others map[string]any) []string {
Resolve: func(_ context.Context, snap ImageGCSnapshot, others map[string]any) []string {
used := gc.Collect(others, gc.BlobIDs)
allRefs := utils.MergeSets(snap.refs, used)
candidates := utils.FilterUnreferenced(snap.diskIDs, allRefs)
Expand Down
2 changes: 1 addition & 1 deletion network/bridge/gc_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func GCModule(rootDir string) gc.Module[bridgeSnapshot] {
}
return snap, nil
},
Resolve: func(snap bridgeSnapshot, others map[string]any) []string {
Resolve: func(_ context.Context, snap bridgeSnapshot, others map[string]any) []string {
active := gc.Collect(others, gc.VMIDs)

// Build set of 8-char prefixes from active VM IDs.
Expand Down
2 changes: 1 addition & 1 deletion network/bridge/gc_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func GCModule(rootDir string) gc.Module[bridgeSnapshot] {
ReadDB: func(_ context.Context) (bridgeSnapshot, error) {
return bridgeSnapshot{}, nil
},
Resolve: func(_ bridgeSnapshot, _ map[string]any) []string {
Resolve: func(_ context.Context, _ bridgeSnapshot, _ map[string]any) []string {
return nil
},
Collect: func(_ context.Context, _ []string) error {
Expand Down
2 changes: 1 addition & 1 deletion network/cni/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (c *CNI) GCModule() gc.Module[cniSnapshot] {
}
return snap, nil
},
Resolve: func(snap cniSnapshot, others map[string]any) []string {
Resolve: func(_ context.Context, snap cniSnapshot, others map[string]any) []string {
active := gc.Collect(others, gc.VMIDs)
candidates := maps.Clone(snap.dbVMIDs)
for _, name := range snap.netnsNames {
Expand Down
Loading
Loading