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)