diff --git a/docs/changelog.d/feature-docs-review-last-reviewed.feature.md b/docs/changelog.d/feature-docs-review-last-reviewed.feature.md new file mode 100644 index 0000000..66823ed --- /dev/null +++ b/docs/changelog.d/feature-docs-review-last-reviewed.feature.md @@ -0,0 +1 @@ +Add `docs-review` mise task that sorts docs by `last-reviewed` frontmatter date, prioritizing never-reviewed cards. Updated the review-documentation how-to to match. diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index d9870c0..bf52db6 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -1,5 +1,6 @@ --- title: Architecture +last-reviewed: 2026-02-09 tags: - explanation - architecture @@ -37,48 +38,33 @@ Two always-on devices form the infrastructure backbone: ## Network Layer -[[tailscale]] provides the network fabric: +[[tailscale]] provides the network fabric. All devices join a single tailnet (`tail8d86e.ts.net`) connected via WireGuard tunnels — no port forwarding or public IPs on homelab devices. ACLs control which devices and services can talk to each other, and MagicDNS provides `*.tail8d86e.ts.net` hostnames. -- All devices on tailnet `tail8d86e.ts.net` -- ACLs control access between devices and services -- MagicDNS provides `*.tail8d86e.ts.net` hostnames -- No port forwarding or public IPs on homelab devices -- Selected services exposed publicly via [[flyio-proxy]] (Fly.io → Tailscale tunnel) +## Routing Layer -## Service Routing +Three layers of reverse proxying expose services at different scopes: -Three DNS domains route to services: - -| Domain | Mechanism | Reachable from | -|--------|-----------|----------------| -| `*.eblu.me` | [[flyio-proxy]] (Fly.io → Tailscale tunnel) | Public internet | -| `*.ops.eblu.me` | Caddy reverse proxy on indri | k8s pods, containers, tailnet clients | +| Domain | Proxy | Reachable from | +|--------|-------|----------------| | `*.tail8d86e.ts.net` | Tailscale MagicDNS | Tailnet clients only | +| `*.ops.eblu.me` | [[caddy]] on indri | k8s pods, containers, tailnet clients | +| `*.eblu.me` | [[flyio-proxy]] on Fly.io | Public internet | -See [[routing]] for details on when to use which. +**Tailscale** is the base layer — every service gets a MagicDNS hostname. The [[tailscale-operator]] gives Kubernetes services their own Tailscale Ingress endpoints. + +**[[caddy]]** runs natively on [[indri]] and provides a unified `*.ops.eblu.me` wildcard with TLS (Let's Encrypt via DNS-01/Gandi). It proxies to both local services (Forgejo, Zot, Jellyfin) and Kubernetes services (via their Tailscale Ingress endpoints). Access is restricted by Tailscale ACLs — only `tag:homelab` and `autogroup:admin` can reach Caddy. + +**[[flyio-proxy]]** runs on Fly.io for select services that need public internet access. Traffic hits Fly.io's Anycast edge, terminates TLS, and tunnels back to the homelab over Tailscale. Only services explicitly tagged `tag:flyio-target` are reachable — a compromised proxy cannot route to arbitrary services on the tailnet. + +See [[routing]] for the full service URL table and port map. ## Compute Layer -Services run in two places: +Services run in two places on [[indri]]: -### Native on Indri (Ansible) +**Native (Ansible)** — services that need host-level access run directly on macOS, managed via Ansible roles in `ansible/roles/`. See [[indri]] for the full list. -Some services run directly on macOS: -- [[forgejo]] - Git forge (needs filesystem access) -- [[zot]] - Container registry (k8s depends on it) -- [[jellyfin]] - Media server (needs VideoToolbox hardware transcoding) -- [[borgmatic]] - Backups (needs host filesystem access) - -Managed via Ansible roles in `ansible/roles/`. - -### Kubernetes (ArgoCD) - -Most services run in minikube on indri: -- [[grafana]], [[prometheus]], [[loki]] - Observability -- [[miniflux]], [[navidrome]], [[kiwix]] - Applications -- [[postgresql]] - Shared database (CloudNativePG) - -Managed via ArgoCD from `argocd/manifests/`. +**Kubernetes (ArgoCD)** — most services run in minikube, managed via ArgoCD from `argocd/manifests/`. See [[apps]] for the application registry. ## Data Flow @@ -120,9 +106,10 @@ Managed via ArgoCD from `argocd/manifests/`. └─────────────┘ ``` -[[alloy]] runs in two places: +[[alloy]] runs in three places: - On indri: collects host metrics and logs - In k8s: collects pod logs and service probes +- On [[flyio-proxy]]: tails nginx access logs and derives request metrics See [[observability]] for details. diff --git a/docs/how-to/knowledgebase/review-documentation.md b/docs/how-to/knowledgebase/review-documentation.md index 6dce129..7455596 100644 --- a/docs/how-to/knowledgebase/review-documentation.md +++ b/docs/how-to/knowledgebase/review-documentation.md @@ -10,15 +10,36 @@ tags: How to periodically review and maintain the BlumeOps knowledge base. -## Quick Random Review +## Review by Staleness -Select a random documentation card for review: +Show docs sorted by when they were last reviewed (most stale first): ```bash -mise run docs-review-random +mise run docs-review ``` -This displays a random card with a review checklist to guide your assessment. +This reads the `last-reviewed` frontmatter field from each card. Cards without the field are treated as never-reviewed and appear at the top. The script shows a staleness table and then displays the most stale card with a review checklist. + +To show more entries in the table: + +```bash +mise run docs-review -- --limit 30 +``` + +### Marking a Card as Reviewed + +After reviewing a card, add or update the `last-reviewed` field in its frontmatter: + +```yaml +--- +title: Some Card +last-reviewed: 2026-02-09 +tags: + - reference +--- +``` + +Commit this change alongside any fixes you make during the review. ## Review Checklist @@ -72,14 +93,6 @@ cd pulumi/gandi && pulumi preview If changes are pending, investigate whether docs or infrastructure is stale. -## When to Review - -Consider running `mise run docs-review-random` during: - -- Start of work sessions (quick maintenance) -- After major infrastructure changes (verify docs reflect reality) -- When learning the system (random exploration) - ## Making Changes If a card needs updates: diff --git a/docs/tutorials/ai-assistance-guide.md b/docs/tutorials/ai-assistance-guide.md index 186f1c3..5f34db2 100644 --- a/docs/tutorials/ai-assistance-guide.md +++ b/docs/tutorials/ai-assistance-guide.md @@ -99,7 +99,7 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail | `docs-check-filenames` | Check for duplicate doc filenames | | `docs-review-stale` | Report docs by last-modified date, highlight stale ones | | `docs-review-tags` | Print frontmatter tag inventory across all docs | -| `docs-review-random` | Select a random doc card for review | +| `docs-review` | Review the most stale doc by last-reviewed date | | `indri-runner-logs` | View Forgejo workflow logs from local runner | For ArgoCD operations, use the `argocd` CLI directly: diff --git a/mise-tasks/docs-review b/mise-tasks/docs-review new file mode 100755 index 0000000..cb94a14 --- /dev/null +++ b/mise-tasks/docs-review @@ -0,0 +1,169 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["pyyaml>=6.0", "rich>=13.0.0", "typer>=0.9.0"] +# /// +#MISE description="Review the most stale documentation card by last-reviewed date" +"""Review the most stale documentation card by last-reviewed date. + +Scans all markdown files in docs/ (excluding changelog.d/) and sorts them +by the ``last-reviewed`` frontmatter field. Docs without the field are +treated as never-reviewed and float to the top. Displays a staleness +table and then shows the most stale doc with a review checklist. + +After reviewing, update the card's frontmatter: + + last-reviewed: YYYY-MM-DD + +Usage: mise run docs-review [-- --limit 10] +""" + +import sys +from datetime import date +from pathlib import Path +from typing import Annotated + +import typer +import yaml +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.table import Table + +DOCS_DIR = Path(__file__).parent.parent / "docs" + + +def extract_frontmatter(file_path: Path) -> dict | None: + """Extract YAML frontmatter from a markdown file.""" + content = file_path.read_text() + if not content.startswith("---"): + return None + + end_idx = content.find("---", 3) + if end_idx == -1: + return None + + frontmatter_text = content[3:end_idx].strip() + try: + return yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return None + + +def get_last_reviewed(frontmatter: dict) -> date | None: + """Extract last-reviewed date from frontmatter.""" + raw = frontmatter.get("last-reviewed") + if raw is None: + return None + if isinstance(raw, date): + return raw + try: + return date.fromisoformat(str(raw)) + except ValueError: + return None + + +def main( + limit: Annotated[int, typer.Option(help="Number of docs to show in the table")] = 15, +) -> None: + console = Console() + today = date.today() + + entries: list[tuple[str, date | None, Path]] = [] + + for md_file in sorted(DOCS_DIR.rglob("*.md")): + if "changelog.d" in md_file.parts: + continue + + frontmatter = extract_frontmatter(md_file) + last_reviewed = get_last_reviewed(frontmatter) if frontmatter else None + rel_path = str(md_file.relative_to(DOCS_DIR)) + entries.append((rel_path, last_reviewed, md_file)) + + # Sort: never-reviewed first (None), then oldest reviewed + entries.sort(key=lambda e: (e[1] is not None, e[1] or date.min)) + + never_reviewed = sum(1 for e in entries if e[1] is None) + + # --- Staleness table --- + console.print() + console.print(Panel( + f"[bold]{len(entries)}[/bold] total docs, " + f"[bold red]{never_reviewed}[/bold red] never reviewed", + title="[bold]Documentation Review Queue[/bold]", + border_style="cyan", + )) + console.print() + + table = Table(show_header=True, header_style="bold") + table.add_column("#", justify="right") + table.add_column("File") + table.add_column("Last Reviewed", justify="right") + table.add_column("Age (days)", justify="right") + + for i, (rel_path, last_reviewed, _) in enumerate(entries[:limit], 1): + if last_reviewed is None: + table.add_row( + str(i), + f"[red]{rel_path}[/red]", + "[red]never[/red]", + "[red]—[/red]", + ) + else: + age = (today - last_reviewed).days + style = "yellow" if age > 90 else "" + age_str = f"[{style}]{age}[/{style}]" if style else str(age) + path_str = f"[{style}]{rel_path}[/{style}]" if style else rel_path + date_str = f"[{style}]{last_reviewed}[/{style}]" if style else str(last_reviewed) + table.add_row(str(i), path_str, date_str, age_str) + + remaining = len(entries) - limit + if remaining > 0: + table.add_row("", f"[dim]… {remaining} more[/dim]", "", "") + + console.print(table) + console.print() + + # --- Show the most stale doc --- + if not entries: + console.print("[bold red]No documentation files found![/bold red]") + raise typer.Exit(code=1) + + top_path, top_reviewed, top_file = entries[0] + console.print(Panel( + f"[bold cyan]{top_path}[/bold cyan]\n" + + (f"[dim]Last reviewed: {top_reviewed}[/dim]" if top_reviewed else "[dim red]Never reviewed[/dim red]"), + title="[bold]Up For Review[/bold]", + border_style="green", + )) + console.print() + + content = top_file.read_text() + console.print(Markdown(content)) + + console.print() + console.print(Panel( + "[bold]Review Checklist:[/bold]\n\n" + "• Is the information accurate and up-to-date?\n" + "• Are there broken or missing wiki-links?\n" + "• Should this card link to other related cards?\n" + "• Is the card too large and should be split?\n" + "• Is the card too small and should be merged?\n" + "• Does the frontmatter (tags, title) make sense?\n" + "• Is the card in the correct category (reference/how-to/etc)?\n\n" + "[bold]Verify Deployed State:[/bold]\n\n" + "• If ArgoCD app: is it synced? (argocd app get )\n" + "• If Ansible role: does it apply idempotently? (--check --diff)\n" + "• If Pulumi: is there drift? (pulumi preview)\n\n" + "[bold]After Review:[/bold]\n\n" + "• Update the card's frontmatter: [cyan]last-reviewed: " + + str(today) + + "[/cyan]\n" + "• Commit the change (along with any fixes)", + title="[bold yellow]Review Guidance[/bold yellow]", + border_style="yellow", + )) + + +if __name__ == "__main__": + typer.run(main) diff --git a/mise-tasks/docs-review-random b/mise-tasks/docs-review-random deleted file mode 100755 index 542b780..0000000 --- a/mise-tasks/docs-review-random +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = ["rich>=13.0.0"] -# /// -#MISE description="Select a random documentation card for review" -"""Select a random documentation card for review. - -This script scans all markdown files in the docs/ directory (excluding -changelog.d/), selects one at random, and displays it for review. - -Useful for periodic knowledge base maintenance and verification. - -Usage: mise run docs-review-random -""" - -import random -import sys -from pathlib import Path - -from rich.console import Console -from rich.markdown import Markdown -from rich.panel import Panel - -DOCS_DIR = Path(__file__).parent.parent / "docs" - - -def get_all_docs() -> list[Path]: - """Get all documentation markdown files, excluding changelog.d.""" - docs = [] - for md_file in DOCS_DIR.rglob("*.md"): - # Skip changelog fragments - if "changelog.d" in md_file.parts: - continue - docs.append(md_file) - return docs - - -def main() -> int: - console = Console() - - docs = get_all_docs() - if not docs: - console.print("[bold red]No documentation files found![/bold red]") - return 1 - - # Select a random document - selected = random.choice(docs) - rel_path = selected.relative_to(DOCS_DIR) - - # Display header - console.print() - console.print(Panel( - f"[bold cyan]{rel_path}[/bold cyan]\n" - f"[dim]{len(docs)} total docs in knowledge base[/dim]", - title="[bold]Random Documentation Card[/bold]", - border_style="cyan", - )) - console.print() - - # Display the file content - content = selected.read_text() - console.print(Markdown(content)) - - # Review checklist - console.print() - console.print(Panel( - "[bold]Review Checklist:[/bold]\n\n" - "• Is the information accurate and up-to-date?\n" - "• Are there broken or missing wiki-links?\n" - "• Should this card link to other related cards?\n" - "• Is the card too large and should be split?\n" - "• Is the card too small and should be merged?\n" - "• Does the frontmatter (tags, title) make sense?\n" - "• Is the card in the correct category (reference/how-to/etc)?\n\n" - "[bold]Verify Deployed State:[/bold]\n\n" - "• If ArgoCD app: is it synced? (argocd app get )\n" - "• If Ansible role: does it apply idempotently? (--check --diff)\n" - "• If Pulumi: is there drift? (pulumi preview)", - title="[bold yellow]Review Guidance[/bold yellow]", - border_style="yellow", - )) - - return 0 - - -if __name__ == "__main__": - sys.exit(main())