blumeops/mise-tasks/prune-ringtail-generations
Erich Blume f6e392b80c
All checks were successful
Deploy Fly.io Proxy / deploy (push) Successful in 1m45s
C1: SHA-pin tooling dependencies (2026-04 cycle) (#344)
## Summary

Monthly tooling dependency refresh, with a one-time conversion from version-tag pins (`rev = "vX.Y.Z"`, `image:tag`, `>=`) to SHA / digest pins everywhere.

## Changes

- **prek hooks**: all `rev = "vX.Y.Z"` → commit SHA + `# vX.Y.Z` comment. Bumped trufflehog (3.94.0→3.95.2), kingfisher (1.91.0→1.97.0), ruff (0.15.7→0.15.12), shfmt (3.13.0→3.13.1), prettier (3.8.1→3.8.3), actionlint (1.7.11→1.7.12).
- **fly/Dockerfile**: tag pins → `image@sha256:...` digest pins. Bumped nginx (1.29.6→1.30.0-alpine), tailscale (v1.94.1→v1.94.2 — still inside the safe pre-1.96.5 range), alloy (v1.14.1→v1.16.0).
- **mise-tasks**: PEP 723 inline deps converted from `>=` to `==` (PEP 508 doesn't support hashes inline). All scripts pinned to current latest: rich 15.0.0, typer 0.25.0, pyyaml 6.0.3, httpx 0.28.1.
- **prek `additional_dependencies`**: ansible-lint==26.4.0, ansible-core==2.20.5.
- **taplo-lint**: pass `--no-schema`. Upstream's `--default-schema-catalogs` returns a format taplo v0.9.3 can't parse — we don't validate against TOML schemas anyway, so this turns off the broken catalog fetch.
- **docs/update-tooling-dependencies**: documents the SHA-pin convention, `docker buildx imagetools inspect` for digest lookup, and `prek clean` before re-verifying (cache grows to several GiB).

Forgejo workflow `actions/checkout@v6.0.2` was already at the latest SHA — no change.

## Test plan

- [x] `prek run --all-files` passes after `prek clean`
- [x] `deploy-fly` workflow builds and deploys the new fly image on merge
- [x] `fly status -a blumeops-proxy` healthy after deploy
- [x] Spot-check a few mise tasks (`mise run blumeops-tasks`, `mise run docs-check-links`) to confirm pinned deps resolve cleanly

Reviewed-on: #344
2026-04-30 16:51:43 -07:00

166 lines
5.1 KiB
Text
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["rich==15.0.0", "typer==0.25.0"]
# ///
#MISE description="Prune old NixOS generations on ringtail, preserving rollback safety"
#MISE alias="prg"
#USAGE flag "--dry-run" help="Show what would be deleted without deleting"
#USAGE flag "--keep <keep>" default="5" help="Number of most recent generations to keep (default 5)"
"""Prune old NixOS system generations on ringtail.
Keeps the N most recent generations (default 5), plus the most recent generation
whose kernel matches the currently booted kernel. This ensures at least one
rollback target that won't require a reboot.
After pruning, runs nix-collect-garbage to free unreferenced store paths.
Usage:
mise run prune-ringtail-generations # keep 5 + kernel-safe gen
mise run prune-ringtail-generations --keep 3 # keep 3 + kernel-safe gen
mise run prune-ringtail-generations --dry-run # preview only
"""
import re
import subprocess
import sys
from dataclasses import dataclass
from typing import Annotated
from rich.console import Console
from rich.table import Table
console = Console()
@dataclass
class Generation:
number: int
profile_path: str
kernel_path: str
def ssh(cmd: str) -> str:
"""Run a command on ringtail via SSH and return stdout."""
result = subprocess.run(
["ssh", "ringtail", cmd],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def get_generations() -> list[Generation]:
"""List all system generations with their kernel store paths."""
# Single SSH call: for each generation, print "gen_number<TAB>profile_path<TAB>kernel_path"
output = ssh(
"command bash -c '"
'for p in /nix/var/nix/profiles/system-*-link; do '
' [ -e "$p" ] || continue; '
' num=$(basename "$p" | grep -oP "\\d+"); '
' kern=$(readlink "$p/kernel"); '
' printf "%s\\t%s\\t%s\\n" "$num" "$p" "$kern"; '
"done'"
)
if not output:
return []
generations = []
for line in output.splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
gen_num, profile_path, kernel_path = int(parts[0]), parts[1], parts[2]
generations.append(Generation(gen_num, profile_path, kernel_path))
# Sort newest-first
generations.sort(key=lambda g: g.number, reverse=True)
return generations
def main(
dry_run: Annotated[bool, "dry_run"] = False,
keep: Annotated[int, "keep"] = 5,
) -> None:
console.print(f"[bold]Scanning ringtail NixOS generations...[/bold]")
booted_kernel = ssh("readlink /run/booted-system/kernel")
console.print(f"Booted kernel: [cyan]{booted_kernel}[/cyan]")
generations = get_generations()
if not generations:
console.print("[yellow]No generations found.[/yellow]")
return
# Build the keep set: top N newest + most recent kernel-matching gen
keep_set: set[int] = set()
# Keep the N most recent
for gen in generations[:keep]:
keep_set.add(gen.number)
# Find and keep the most recent generation matching booted kernel
kernel_gen: Generation | None = None
for gen in generations:
if gen.kernel_path == booted_kernel:
kernel_gen = gen
keep_set.add(gen.number)
break
to_delete = [g for g in generations if g.number not in keep_set]
# Display a summary table
table = Table(title="System Generations")
table.add_column("Gen", style="bold")
table.add_column("Kernel Match", justify="center")
table.add_column("Action")
for gen in generations:
matches_booted = gen.kernel_path == booted_kernel
kernel_col = "[green]yes[/green]" if matches_booted else "no"
if gen.number not in keep_set:
action = "[red]delete[/red]"
elif kernel_gen and gen.number == kernel_gen.number and gen.number not in {g.number for g in generations[:keep]}:
action = "[blue]keep (kernel safety)[/blue]"
else:
action = f"[green]keep (top {keep})[/green]"
table.add_row(str(gen.number), kernel_col, action)
console.print(table)
if kernel_gen is None:
console.print(
"[yellow]Warning: no generation matches booted kernel. "
"Rollback will require a reboot.[/yellow]"
)
if not to_delete:
console.print("[green]Nothing to prune.[/green]")
return
delete_nums = " ".join(str(g.number) for g in to_delete)
if dry_run:
console.print(f"[yellow]Dry run:[/yellow] would delete generations: {delete_nums}")
console.print("[yellow]Dry run:[/yellow] would run nix-collect-garbage")
return
console.print(f"Deleting generations: {delete_nums}")
ssh(f"sudo nix-env --delete-generations {delete_nums} -p /nix/var/nix/profiles/system")
console.print("Running nix-collect-garbage...")
ssh("sudo nix-collect-garbage")
console.print("[green]Done.[/green]")
if __name__ == "__main__":
try:
import typer
typer.run(main)
except ImportError:
main()