blumeops/mise-tasks/container-build-and-release
Erich Blume a87c997ee1
All checks were successful
Deploy Fly.io Proxy / deploy (push) Successful in 1m28s
Expose Forgejo publicly at forge.eblu.me (#278)
## Summary

Expose Forgejo publicly at `forge.eblu.me` via the Fly.io reverse proxy — the first dynamic, authenticated public-facing service.

- **Forgejo hardening:** Domain changed to forge.eblu.me, SSH stays on forge.ops.eblu.me, reverse proxy trust headers configured, local registration locked to external-only (Authentik SSO)
- **Tailscale Ingress:** ExternalName Service + Ingress in tailscale-operator creates forge.tail8d86e.ts.net endpoint
- **Fly.io proxy:** nginx server block with rate-limited auth endpoints (3r/s), fail2ban with custom nginx-deny action, security headers, /swagger blocked, WebSocket support, 512m body limit
- **Authentik:** OAuth callback updated to forge.eblu.me
- **DNS/TLS:** CNAME record in Pulumi, cert in fly-setup
- **Rename:** ~29 files updated from forge.ops.eblu.me to forge.eblu.me (HTTPS refs only; SSH, container builds, and Caddy table kept as-is)

## Deployment Order

1. `mise run provision-indri -- --tags forgejo` (config changes)
2. Verify forge.ops.eblu.me still works
3. `argocd app set tailscale-operator --revision feature/forge-public && argocd app sync tailscale-operator`
4. Verify `curl https://forge.tail8d86e.ts.net`
5. `cd fly && fly deploy`
6. Verify pre-DNS: `curl -H "Host: forge.eblu.me" https://blumeops-proxy.fly.dev/`
7. `fly certs add forge.eblu.me -a blumeops-proxy`
8. `argocd app set authentik --revision feature/forge-public && argocd app sync authentik`
9. `mise run dns-preview && mise run dns-up`
10. Full verification (see below)
11. Rehearse `mise run fly-shutoff`
12. After merge: reset ArgoCD revisions to main, re-sync

## Verification Checklist

- [ ] forge.eblu.me loads, shows public repos
- [ ] forge.ops.eblu.me still works from tailnet
- [ ] SSH clone via forge.ops.eblu.me:2222 works
- [ ] HTTPS clone via forge.eblu.me works
- [ ] UI shows forge.eblu.me for HTTPS clone, forge.ops.eblu.me for SSH
- [ ] /swagger returns 403
- [ ] Rapid login attempts trigger 429 rate limit
- [ ] fail2ban bans after 5 failed logins in 10 minutes
- [ ] ArgoCD can still sync (SSH unaffected)
- [ ] `mise run fly-shutoff` stops all public traffic
- [ ] `mise run services-check` passes

Reviewed-on: #278
2026-03-03 08:40:41 -08:00

145 lines
4.3 KiB
Text
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["typer>=0.15.0", "httpx>=0.28.0"]
# ///
#MISE description="Trigger container build workflows via Forgejo API"
#USAGE arg "<container>" help="Container name (directory under containers/)"
#USAGE flag "--ref <ref>" help="Commit SHA or branch to build (defaults to current HEAD)"
#USAGE flag "--dry-run" help="Show what would be done without triggering"
"""Trigger container build workflows via Forgejo API dispatch.
Dispatches both Build Container and Build Container (Nix) workflows.
Each workflow checks for its build file and skips if not present.
"""
import subprocess
import sys
from pathlib import Path
import httpx
import typer
REGISTRY = "registry.ops.eblu.me"
FORGE_URL = "https://forge.eblu.me"
FORGE_API = f"{FORGE_URL}/api/v1"
REPO = "eblume/blumeops"
FORGE_ACTIONS = f"{FORGE_URL}/{REPO}/actions"
WORKFLOWS = [
"build-container.yaml",
"build-container-nix.yaml",
]
app = typer.Typer(add_completion=False)
def git(*args: str) -> str:
result = subprocess.run(
["git", *args], capture_output=True, text=True, check=True
)
return result.stdout.strip()
def get_forge_token() -> str:
result = subprocess.run(
["op", "read", "op://blumeops/w3663ffnvkewbftncqxtcpeavy/api-token"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def list_containers() -> None:
typer.echo("Available containers:")
for d in sorted(Path("containers").iterdir()):
if not d.is_dir():
continue
types = []
if (d / "Dockerfile").exists():
types.append("dockerfile")
if (d / "default.nix").exists():
types.append("nix")
if types:
typer.echo(f" - {d.name} ({', '.join(types)})")
@app.command()
def main(
container: str = typer.Argument(help="Container name (directory under containers/)"),
ref: str = typer.Option("", "--ref", help="Commit SHA to build (defaults to current HEAD)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done without triggering"),
) -> None:
"""Trigger container build workflows via Forgejo API dispatch."""
container_dir = Path("containers") / container
has_dockerfile = (container_dir / "Dockerfile").exists()
has_nix = (container_dir / "default.nix").exists()
if not has_dockerfile and not has_nix:
typer.echo(f"Error: No Dockerfile or default.nix found in '{container_dir}'")
typer.echo()
list_containers()
raise typer.Exit(1)
if not ref:
ref = git("rev-parse", "HEAD")
else:
# Resolve short SHAs or branch names to full SHA
ref = git("rev-parse", ref)
short_sha = ref[:7]
image = f"blumeops/{container}"
# Show expected builds
builds = []
if has_dockerfile:
builds.append(f" dockerfile -> {REGISTRY}/{image}:v<version>-{short_sha}")
if has_nix:
builds.append(f" nix -> {REGISTRY}/{image}:v<version>-{short_sha}-nix")
if dry_run:
typer.echo("[dry-run mode]")
typer.echo(f"Container: {container}")
typer.echo(f"Commit: {ref} ({short_sha})")
typer.echo(f"Expected builds:")
for b in builds:
typer.echo(b)
typer.echo()
if dry_run:
typer.echo("[dry-run] Would dispatch workflows:")
for wf in WORKFLOWS:
typer.echo(f" - {wf}")
typer.echo()
typer.echo(f"Monitor builds at: {FORGE_ACTIONS}")
return
token = get_forge_token()
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
for wf in WORKFLOWS:
url = f"{FORGE_API}/repos/{REPO}/actions/workflows/{wf}/dispatches"
payload = {
"ref": "main",
"inputs": {
"container": container,
"ref": ref,
},
}
resp = httpx.post(url, json=payload, headers=headers, timeout=30)
if resp.status_code == 204:
typer.echo(f"Dispatched {wf}")
else:
typer.echo(f"Error dispatching {wf}: {resp.status_code} {resp.text}")
raise typer.Exit(1)
typer.echo()
typer.echo(f"Monitor builds at: {FORGE_ACTIONS}")
if __name__ == "__main__":
app()