blumeops/mise-tasks/dns-acme-cleanup

112 lines
3.5 KiB
Text
Raw Normal View History

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
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: https://forge.eblu.me/eblume/blumeops/pulls/344
2026-04-30 16:51:43 -07:00
# dependencies = ["httpx==0.28.1", "rich==15.0.0", "typer==0.25.0"]
# ///
#MISE description="Delete orphaned ACME challenge TXT records in eblu.me"
#USAGE flag "--dry-run" help="List orphans without deleting"
"""Clean up orphaned _acme-challenge TXT records in the eblu.me zone.
Workaround for libdns/gandi v1.1.0: its DeleteRecords compares unquoted
certmagic values to Gandi-quoted stored values, so cleanup is a silent
no-op. Without this script, the rrset grows by ~2 values per successful
Caddy renewal cycle.
In healthy steady state these records should be absent. Run alongside
PAT rotation, or any time after Caddy ACME activity.
"""
import os
import subprocess
from typing import Annotated
import httpx
import typer
from rich.console import Console
from rich.table import Table
DOMAIN = "eblu.me"
RRSET = "_acme-challenge.ops"
GANDI_API = "https://api.gandi.net/v5/livedns"
OP_PAT_REF = "op://blumeops/gandi - blumeops/pat"
def resolve_token(console: Console) -> str:
env_token = os.environ.get("GANDI_PERSONAL_ACCESS_TOKEN", "").strip()
if env_token:
return env_token
console.print("[dim]Reading Gandi PAT from 1Password...[/dim]")
try:
result = subprocess.run(
["op", "read", OP_PAT_REF],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError) as e:
console.print(f"[red]Failed to read PAT from 1Password:[/red] {e}")
raise typer.Exit(1)
app = typer.Typer(add_completion=False)
@app.command()
def main(
dry_run: Annotated[
bool,
typer.Option("--dry-run", help="List orphans without deleting"),
] = False,
) -> None:
"""Delete orphan _acme-challenge TXT records in eblu.me."""
console = Console()
token = resolve_token(console)
url = f"{GANDI_API}/domains/{DOMAIN}/records/{RRSET}/TXT"
headers = {"Authorization": f"Bearer {token}"}
with httpx.Client(timeout=15, headers=headers) as client:
resp = client.get(url)
if resp.status_code == 404:
console.print(
f"[green]Clean — {RRSET}.{DOMAIN} TXT rrset is absent.[/green]"
)
raise typer.Exit(0)
resp.raise_for_status()
values = resp.json().get("rrset_values", [])
if not values:
console.print(
f"[green]Clean — {RRSET}.{DOMAIN} TXT rrset is empty.[/green]"
)
raise typer.Exit(0)
table = Table(title=f"Orphan ACME challenge values: {RRSET}.{DOMAIN}")
table.add_column("#", justify="right")
table.add_column("Value")
for i, v in enumerate(values, 1):
table.add_row(str(i), v)
console.print(table)
console.print(f"\n[bold]{len(values)}[/bold] orphan(s).")
if dry_run:
console.print("\n[dim]Dry run — no records deleted.[/dim]")
raise typer.Exit(0)
del_resp = client.delete(url)
if del_resp.status_code == 204:
console.print(
f"[green]Deleted {RRSET}.{DOMAIN} TXT "
f"({len(values)} values).[/green]"
)
else:
console.print(
f"[red]Delete failed: HTTP {del_resp.status_code}[/red]\n"
f"{del_resp.text[:300]}"
)
raise typer.Exit(1)
if __name__ == "__main__":
app()