From 5a15774dc2aca6c38099b4254befb04a44bc87c0 Mon Sep 17 00:00:00 2001 From: Jake Goldsborough Date: Sat, 3 Oct 2026 08:46:46 +0200 Subject: [PATCH] feat(cli): add dv prune to remove stale containers Problem: dv agents pile up. Each stopped container keeps its writable layer (often 1-3GB), and nothing removes them, so a machine ends up with dozens of agents nobody has run in weeks. Removing them by hand with plain docker skips dv's config cleanup and hooks, and it can't tell an abandoned agent from one holding work that was never pushed. Solution: dv prune lists dv-owned containers (com.dv.owner=dv or recorded in config, any image) that are stopped and last ran longer ago than --older-than (default 14d; 2w and 36h also work). Last run is State.FinishedAt, or Created for a container that never started. - Without --yes it only lists candidates, oldest first. - With --yes each candidate is started briefly and its git checkouts (the effective workdir and each plugin) are checked for uncommitted changes, stashes and commits on no remote. Containers with work, or that can't be checked, are kept and reported; the rest are removed through dv remove, so config cleanup and hooks run. - The selected agent, the default container and --keep names are never pruned. Suitable for a scheduled `dv prune --yes` (systemd timer or cron). --- README.md | 11 ++ internal/cli/prune.go | 279 +++++++++++++++++++++++++++++++++++++ internal/cli/prune_test.go | 170 ++++++++++++++++++++++ internal/cli/root.go | 1 + 4 files changed, 461 insertions(+) create mode 100644 internal/cli/prune.go create mode 100644 internal/cli/prune_test.go diff --git a/README.md b/README.md index 3d5133b..c3a532e 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,17 @@ Remove the container and optionally the image. dv remove [--image] [--name NAME] ``` +### dv prune +Remove dv containers (any image) that are stopped and have not run for longer than `--older-than` (default `14d`; also `2w`, `36h`). Without `--yes` it only lists them. + +```bash +dv prune [--older-than 14d] [--keep NAME ...] [--yes] +``` + +- With `--yes`, each candidate is started briefly and its git checkouts (the workdir and each plugin) are checked. A container with uncommitted changes, stashes or commits not on any remote is kept and its changes are listed; `dv extract` the work or `dv remove` it yourself. +- The selected agent, the default container and `--keep` names are never pruned. Removal goes through `dv remove`, so its hooks run. +- To prune on a schedule, run `dv prune --yes` from a systemd user timer or cron. + ### Agent management Manage multiple containers for the selected image; selection is stored in XDG config. These are the preferred top-level commands; the old `dv agent` group has been removed. diff --git a/internal/cli/prune.go b/internal/cli/prune.go new file mode 100644 index 0000000..765245c --- /dev/null +++ b/internal/cli/prune.go @@ -0,0 +1,279 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os/exec" + "sort" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "dv/internal/config" + "dv/internal/docker" + "dv/internal/xdg" +) + +// pruneContainer is a container as `docker inspect` reports it. +type pruneContainer struct { + name string + state string + owner string + createdAt time.Time + finishedAt time.Time +} + +// lastUsed is when the container last stopped, or when it was created if it +// never ran. +func (c pruneContainer) lastUsed() time.Time { + if c.finishedAt.IsZero() || c.finishedAt.Year() <= 1 { + return c.createdAt + } + return c.finishedAt +} + +type pruneCandidate struct { + pruneContainer + idle time.Duration +} + +// Seams for tests. +var ( + pruneInspect = dockerPruneInspect + pruneUnsavedWork = containerUnsavedWork + pruneRemove = func(cmd *cobra.Command, name, configDir string) error { + return runRemoveInConfig(cmd, []string{name}, true, false, configDir) + } + pruneNow = time.Now +) + +var pruneCmd = &cobra.Command{ + Use: "prune", + Short: "Remove dv containers that have not run for a while", + Long: `Remove dv containers that are stopped and have not run for longer than +--older-than (default 14d). Without --yes, only lists what would go. + +With --yes, each candidate is started briefly and its git checkouts (the +workdir and its plugins) are checked first. A container with uncommitted +changes, stashes or commits not on a remote is kept and reported; extract +that work or remove it with 'dv remove'. The selected and default agents +and names given to --keep are never pruned.`, + Args: cobra.NoArgs, + RunE: runPrune, +} + +func runPrune(cmd *cobra.Command, _ []string) error { + olderThanRaw, _ := cmd.Flags().GetString("older-than") + olderThan, err := parseAge(olderThanRaw) + if err != nil { + return err + } + yes, _ := cmd.Flags().GetBool("yes") + keep, _ := cmd.Flags().GetStringSlice("keep") + + configDir, err := xdg.ConfigDir() + if err != nil { + return err + } + cfg, err := config.LoadOrCreate(configDir) + if err != nil { + return err + } + containers, err := pruneInspect(cmd.Context()) + if err != nil { + return err + } + protected := append([]string{currentAgentName(cfg), cfg.DefaultContainer}, keep...) + candidates := selectPruneCandidates(cfg, containers, olderThan, pruneNow(), protected) + + out := cmd.OutOrStdout() + if len(candidates) == 0 { + fmt.Fprintf(out, "No dv containers stopped for more than %s\n", olderThanRaw) + return nil + } + if !yes { + fmt.Fprintf(out, "Would check for unsaved work, then remove (run with --yes):\n") + for _, c := range candidates { + fmt.Fprintf(out, " %s (last ran %s ago)\n", c.name, humanAge(c.idle)) + } + return nil + } + + var removed, kept []string + var failures []error + for _, c := range candidates { + work, err := pruneUnsavedWork(cmd.Context(), cfg, c.name) + if err != nil { + kept = append(kept, c.name) + fmt.Fprintf(cmd.ErrOrStderr(), "Keeping %s: could not check for unsaved work: %v\n", c.name, err) + continue + } + if work != "" { + kept = append(kept, c.name) + fmt.Fprintf(out, "Keeping %s: unsaved work\n%s\n", c.name, indent(work, " ")) + continue + } + if err := pruneRemove(cmd, c.name, configDir); err != nil { + failures = append(failures, fmt.Errorf("%s: %w", c.name, err)) + continue + } + removed = append(removed, c.name) + } + fmt.Fprintf(out, "Pruned %d container(s), kept %d with unsaved work or unchecked\n", len(removed), len(kept)) + return errors.Join(failures...) +} + +// selectPruneCandidates returns the dv containers that are stopped, last ran +// longer ago than olderThan, and are not protected, oldest first. +func selectPruneCandidates(cfg config.Config, containers []pruneContainer, olderThan time.Duration, now time.Time, protected []string) []pruneCandidate { + skip := map[string]bool{} + for _, name := range protected { + if name = strings.TrimSpace(name); name != "" { + skip[name] = true + } + } + var out []pruneCandidate + for _, c := range containers { + _, recorded := cfg.ContainerImages[c.name] + if c.owner != "dv" && !recorded { + continue + } + if skip[c.name] || (c.state != "exited" && c.state != "created") { + continue + } + last := c.lastUsed() + if last.IsZero() { + continue + } + idle := now.Sub(last) + if idle <= olderThan { + continue + } + out = append(out, pruneCandidate{pruneContainer: c, idle: idle}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].idle > out[j].idle }) + return out +} + +func dockerPruneInspect(ctx context.Context) ([]pruneContainer, error) { + ids, err := exec.CommandContext(ctx, "docker", "ps", "-aq").Output() + if err != nil { + return nil, fmt.Errorf("docker ps: %w", err) + } + fields := strings.Fields(string(ids)) + if len(fields) == 0 { + return nil, nil + } + format := `{{.Name}}{{"\t"}}{{.State.Status}}{{"\t"}}{{index .Config.Labels "com.dv.owner"}}{{"\t"}}{{.Created}}{{"\t"}}{{.State.FinishedAt}}` + args := append([]string{"inspect", "--format", format}, fields...) + raw, err := exec.CommandContext(ctx, "docker", args...).Output() + if err != nil { + return nil, fmt.Errorf("docker inspect: %w", err) + } + return parsePruneInspect(string(raw)), nil +} + +func parsePruneInspect(raw string) []pruneContainer { + var out []pruneContainer + for _, line := range strings.Split(strings.TrimSpace(raw), "\n") { + parts := strings.Split(line, "\t") + if len(parts) != 5 { + continue + } + created, _ := time.Parse(time.RFC3339Nano, parts[3]) + finished, _ := time.Parse(time.RFC3339Nano, parts[4]) + out = append(out, pruneContainer{ + name: strings.TrimPrefix(parts[0], "/"), + state: parts[1], + owner: parts[2], + createdAt: created, + finishedAt: finished, + }) + } + return out +} + +// pruneWorkScript reports, for the workdir and each plugin checkout, what +// would be lost with the container: uncommitted changes, stashes and +// commits on no remote. +const pruneWorkScript = ` +for d in "$1" "$1"/plugins/*; do + [ -e "$d/.git" ] || continue + status=$(git -C "$d" status --porcelain --untracked-files=normal 2>&1 | head -5) + stash=$(git -C "$d" stash list 2>&1 | head -3) + unpushed=$(git -C "$d" log --branches --not --remotes --oneline 2>&1 | head -5) + if [ -n "$status$stash$unpushed" ]; then + echo "$d:" + [ -n "$status" ] && echo "$status" | sed 's/^/ /' + [ -n "$stash" ] && echo "$stash" | sed 's/^/ stash: /' + [ -n "$unpushed" ] && echo "$unpushed" | sed 's/^/ unpushed: /' + fi +done +exit 0 +` + +// containerUnsavedWork starts the container if needed, runs pruneWorkScript +// in it and stops it again. An empty result means nothing would be lost. +func containerUnsavedWork(ctx context.Context, cfg config.Config, name string) (string, error) { + imgCfg, err := resolveImageConfig(cfg, name) + if err != nil { + return "", err + } + workdir := config.EffectiveWorkdir(cfg, imgCfg, name) + if !docker.Running(name) { + if out, err := exec.CommandContext(ctx, "docker", "start", name).CombinedOutput(); err != nil { + return "", fmt.Errorf("docker start: %w: %s", err, strings.TrimSpace(string(out))) + } + defer func() { + _ = docker.StopContext(context.WithoutCancel(ctx), name, nil, nil) + }() + } + out, err := docker.ExecOutputContext(ctx, name, "/", nil, []string{"bash", "-c", pruneWorkScript, "prune", workdir}) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// parseAge accepts Go durations plus days and weeks: 36h, 14d, 2w. +func parseAge(raw string) (time.Duration, error) { + s := strings.TrimSpace(raw) + for suffix, unit := range map[string]time.Duration{"d": 24 * time.Hour, "w": 7 * 24 * time.Hour} { + if n, ok := strings.CutSuffix(s, suffix); ok { + v, err := strconv.Atoi(n) + if err != nil || v < 0 { + return 0, fmt.Errorf("invalid age %q", raw) + } + return time.Duration(v) * unit, nil + } + } + d, err := time.ParseDuration(s) + if err != nil || d < 0 { + return 0, fmt.Errorf("invalid age %q (use e.g. 14d, 2w, 36h)", raw) + } + return d, nil +} + +func humanAge(d time.Duration) string { + if d >= 48*time.Hour { + return fmt.Sprintf("%d days", int(d.Hours()/24)) + } + return fmt.Sprintf("%d hours", int(d.Hours())) +} + +func indent(s, prefix string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = prefix + l + } + return strings.Join(lines, "\n") +} + +func init() { + pruneCmd.Flags().String("older-than", "14d", "Prune containers that last ran longer ago than this (e.g. 14d, 2w, 36h)") + pruneCmd.Flags().BoolP("yes", "y", false, "Remove the containers instead of listing them") + pruneCmd.Flags().StringSlice("keep", nil, "Container names never to prune (repeatable)") +} diff --git a/internal/cli/prune_test.go b/internal/cli/prune_test.go new file mode 100644 index 0000000..c4d797f --- /dev/null +++ b/internal/cli/prune_test.go @@ -0,0 +1,170 @@ +package cli + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "dv/internal/config" +) + +var pruneTestNow = time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) + +func daysAgo(n int) time.Time { return pruneTestNow.Add(-time.Duration(n) * 24 * time.Hour) } + +func pruneNames(candidates []pruneCandidate) []string { + names := []string{} + for _, c := range candidates { + names = append(names, c.name) + } + return names +} + +func TestSelectPruneCandidates(t *testing.T) { + cfg := config.Default() + cfg.ContainerImages["legacy"] = "discourse" + containers := []pruneContainer{ + {name: "old", state: "exited", owner: "dv", finishedAt: daysAgo(30)}, + {name: "older", state: "exited", owner: "dv", finishedAt: daysAgo(60)}, + {name: "recent", state: "exited", owner: "dv", finishedAt: daysAgo(3)}, + {name: "running", state: "running", owner: "dv", finishedAt: daysAgo(90)}, + {name: "not-dv", state: "exited", finishedAt: daysAgo(90)}, + {name: "legacy", state: "exited", finishedAt: daysAgo(20)}, + {name: "never-ran", state: "created", owner: "dv", createdAt: daysAgo(40), finishedAt: time.Time{}}, + {name: "selected", state: "exited", owner: "dv", finishedAt: daysAgo(90)}, + {name: "kept", state: "exited", owner: "dv", finishedAt: daysAgo(90)}, + } + got := pruneNames(selectPruneCandidates(cfg, containers, 14*24*time.Hour, pruneTestNow, []string{"selected", "kept", ""})) + want := []string{"older", "never-ran", "old", "legacy"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } +} + +func TestParsePruneInspectUsesCreatedWhenNeverRun(t *testing.T) { + raw := "/agent\texited\tdv\t2026-08-01T10:00:00.5Z\t0001-01-01T00:00:00Z\n" + + "/other\texited\t\t2026-08-01T10:00:00Z\t2026-09-01T10:00:00.123456789Z\n" + got := parsePruneInspect(raw) + if len(got) != 2 || got[0].name != "agent" || got[0].owner != "dv" || got[1].owner != "" { + t.Fatalf("parsed = %+v", got) + } + if want := time.Date(2026, 8, 1, 10, 0, 0, 500_000_000, time.UTC); !got[0].lastUsed().Equal(want) { + t.Fatalf("lastUsed = %v, want created %v", got[0].lastUsed(), want) + } + if want := time.Date(2026, 9, 1, 10, 0, 0, 123456789, time.UTC); !got[1].lastUsed().Equal(want) { + t.Fatalf("lastUsed = %v, want finished %v", got[1].lastUsed(), want) + } +} + +func TestParseAge(t *testing.T) { + for raw, want := range map[string]time.Duration{ + "14d": 14 * 24 * time.Hour, + "2w": 14 * 24 * time.Hour, + "36h": 36 * time.Hour, + "0d": 0, + } { + got, err := parseAge(raw) + if err != nil || got != want { + t.Fatalf("parseAge(%q) = %v, %v; want %v", raw, got, err, want) + } + } + for _, raw := range []string{"", "d", "-3d", "soon", "-1h"} { + if _, err := parseAge(raw); err == nil { + t.Fatalf("parseAge(%q) succeeded, want error", raw) + } + } +} + +func pruneTestCommand(args ...string) (*cobra.Command, *strings.Builder, *strings.Builder) { + stdout := &strings.Builder{} + stderr := &strings.Builder{} + cmd := &cobra.Command{Use: "prune", RunE: runPrune} + cmd.Flags().AddFlagSet(pruneCmd.Flags()) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetContext(context.Background()) + if err := cmd.ParseFlags(args); err != nil { + panic(err) + } + return cmd, stdout, stderr +} + +func stubPrune(t *testing.T, containers []pruneContainer, work map[string]string, checkErr map[string]error) *[]string { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("DV_AGENT", "") + oldInspect, oldWork, oldRemove, oldNow := pruneInspect, pruneUnsavedWork, pruneRemove, pruneNow + t.Cleanup(func() { + pruneInspect, pruneUnsavedWork, pruneRemove, pruneNow = oldInspect, oldWork, oldRemove, oldNow + }) + removed := &[]string{} + pruneInspect = func(context.Context) ([]pruneContainer, error) { return containers, nil } + pruneUnsavedWork = func(_ context.Context, _ config.Config, name string) (string, error) { + return work[name], checkErr[name] + } + pruneRemove = func(_ *cobra.Command, name, _ string) error { + *removed = append(*removed, name) + return nil + } + pruneNow = func() time.Time { return pruneTestNow } + return removed +} + +func TestPruneDryRunRemovesNothing(t *testing.T) { + removed := stubPrune(t, []pruneContainer{{name: "old", state: "exited", owner: "dv", finishedAt: daysAgo(30)}}, nil, nil) + cmd, stdout, _ := pruneTestCommand() + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if len(*removed) != 0 { + t.Fatalf("removed %v in a dry run", *removed) + } + if !strings.Contains(stdout.String(), "old (last ran 30 days ago)") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestPruneKeepsContainersWithUnsavedOrUncheckedWork(t *testing.T) { + containers := []pruneContainer{ + {name: "clean", state: "exited", owner: "dv", finishedAt: daysAgo(30)}, + {name: "dirty", state: "exited", owner: "dv", finishedAt: daysAgo(30)}, + {name: "broken", state: "exited", owner: "dv", finishedAt: daysAgo(30)}, + } + removed := stubPrune(t, containers, + map[string]string{"dirty": "/var/www/discourse:\n M app/models/post.rb"}, + map[string]error{"broken": errors.New("docker start failed")}) + cmd, stdout, stderr := pruneTestCommand("--yes") + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(*removed, []string{"clean"}) { + t.Fatalf("removed = %v, want [clean]", *removed) + } + if !strings.Contains(stdout.String(), "Keeping dirty: unsaved work") || !strings.Contains(stdout.String(), "app/models/post.rb") { + t.Fatalf("stdout = %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "Keeping broken: could not check") { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestPruneHonorsKeepAndOlderThan(t *testing.T) { + containers := []pruneContainer{ + {name: "a", state: "exited", owner: "dv", finishedAt: daysAgo(10)}, + {name: "b", state: "exited", owner: "dv", finishedAt: daysAgo(10)}, + {name: "c", state: "exited", owner: "dv", finishedAt: daysAgo(3)}, + } + removed := stubPrune(t, containers, nil, nil) + cmd, _, _ := pruneTestCommand("--yes", "--older-than", "1w", "--keep", "b") + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(*removed, []string{"a"}) { + t.Fatalf("removed = %v, want [a]", *removed) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c8e1e63..47b0c3e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -163,6 +163,7 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(restartCmd) rootCmd.AddCommand(resetCmd) rootCmd.AddCommand(removeCmd) + rootCmd.AddCommand(pruneCmd) rootCmd.AddCommand(exposeCmd) rootCmd.AddCommand(tunnelCmd) rootCmd.AddCommand(mailCmd)