feat: v9.8.1 pre-built Docker images and GHCR release workflow

Setup.py now asks whether to pull pre-built images from GHCR
(recommended) or build from source. Pre-built mode skips the
15-minute compile step entirely.

- Add .github/workflows/release.yml (builds+pushes 4 images on tag)
- Add docker-compose.build.yml (source-build overlay)
- Switch docker-compose.prod.yml from build: to image: refs
- Add --build-mode CLI arg and wizard step to setup.py
- Bump version to 9.8.1 across all files
- Document TOD_VERSION env var in CONFIGURATION.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-22 18:33:12 -05:00
parent 0c1ffe0e39
commit b1ac1cce24
11 changed files with 250 additions and 27 deletions

87
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ghcr.io/staack/the-other-dude
jobs:
build-and-push:
name: Build & Push Docker Images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Build and push each image sequentially to avoid OOM on the runner.
# Each multi-stage build (Go, Python/pip, Node/tsc) peaks at 1-2 GB.
- name: Build & push API
uses: docker/build-push-action@v6
with:
context: .
file: infrastructure/docker/Dockerfile.api
push: true
tags: |
${{ env.IMAGE_PREFIX }}/api:${{ steps.version.outputs.version }}
${{ env.IMAGE_PREFIX }}/api:latest
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
- name: Build & push Poller
uses: docker/build-push-action@v6
with:
context: ./poller
file: poller/Dockerfile
push: true
tags: |
${{ env.IMAGE_PREFIX }}/poller:${{ steps.version.outputs.version }}
${{ env.IMAGE_PREFIX }}/poller:latest
cache-from: type=gha,scope=poller
cache-to: type=gha,mode=max,scope=poller
- name: Build & push Frontend
uses: docker/build-push-action@v6
with:
context: .
file: infrastructure/docker/Dockerfile.frontend
push: true
tags: |
${{ env.IMAGE_PREFIX }}/frontend:${{ steps.version.outputs.version }}
${{ env.IMAGE_PREFIX }}/frontend:latest
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
- name: Build & push WinBox Worker
uses: docker/build-push-action@v6
with:
context: ./winbox-worker
file: winbox-worker/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_PREFIX }}/winbox-worker:${{ steps.version.outputs.version }}
${{ env.IMAGE_PREFIX }}/winbox-worker:latest
cache-from: type=gha,scope=winbox-worker
cache-to: type=gha,mode=max,scope=winbox-worker

View File

@@ -1 +1 @@
9.8.0 9.8.1

View File

@@ -144,7 +144,7 @@ class Settings(BaseSettings):
# App settings # App settings
APP_NAME: str = "TOD - The Other Dude" APP_NAME: str = "TOD - The Other Dude"
APP_VERSION: str = "9.8.0" APP_VERSION: str = "9.8.1"
DEBUG: bool = False DEBUG: bool = False
@field_validator("CREDENTIAL_ENCRYPTION_KEY") @field_validator("CREDENTIAL_ENCRYPTION_KEY")

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "the-other-dude-backend" name = "the-other-dude-backend"
version = "9.8.0" version = "9.8.1"
description = "MikroTik Fleet Management Portal - Backend API" description = "MikroTik Fleet Management Portal - Backend API"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [

28
docker-compose.build.yml Normal file
View File

@@ -0,0 +1,28 @@
# docker-compose.build.yml -- Build-from-source override
#
# Adds build contexts so Docker Compose builds images locally instead of
# pulling pre-built images from GHCR.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml \
# -f docker-compose.build.yml --env-file .env.prod up -d --build
services:
api:
build:
context: .
dockerfile: infrastructure/docker/Dockerfile.api
poller:
build:
context: ./poller
dockerfile: ./Dockerfile
frontend:
build:
context: .
dockerfile: infrastructure/docker/Dockerfile.frontend
winbox-worker:
build:
context: ./winbox-worker

View File

@@ -1,5 +1,10 @@
# docker-compose.prod.yml -- Production environment override # docker-compose.prod.yml -- Production environment override
# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d #
# Pre-built images (recommended):
# docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.prod up -d
#
# Build from source:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.build.yml --env-file .env.prod up -d
services: services:
postgres: postgres:
@@ -13,9 +18,7 @@ services:
retries: 5 retries: 5
api: api:
build: image: ghcr.io/staack/the-other-dude/api:${TOD_VERSION:-latest}
context: .
dockerfile: infrastructure/docker/Dockerfile.api
container_name: tod_api container_name: tod_api
env_file: .env.prod env_file: .env.prod
environment: environment:
@@ -67,9 +70,7 @@ services:
- tod_remote_worker - tod_remote_worker
poller: poller:
build: image: ghcr.io/staack/the-other-dude/poller:${TOD_VERSION:-latest}
context: ./poller
dockerfile: ./Dockerfile
container_name: tod_poller container_name: tod_poller
env_file: .env.prod env_file: .env.prod
cap_add: cap_add:
@@ -135,6 +136,7 @@ services:
max-file: "3" max-file: "3"
winbox-worker: winbox-worker:
image: ghcr.io/staack/the-other-dude/winbox-worker:${TOD_VERSION:-latest}
environment: environment:
LOG_LEVEL: info LOG_LEVEL: info
MAX_CONCURRENT_SESSIONS: 10 MAX_CONCURRENT_SESSIONS: 10
@@ -146,9 +148,7 @@ services:
restart: unless-stopped restart: unless-stopped
frontend: frontend:
build: image: ghcr.io/staack/the-other-dude/frontend:${TOD_VERSION:-latest}
context: .
dockerfile: infrastructure/docker/Dockerfile.frontend
container_name: tod_frontend container_name: tod_frontend
ports: ports:
- "3000:80" - "3000:80"

View File

@@ -9,7 +9,8 @@ TOD uses Pydantic Settings for configuration. All values can be set via environm
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `APP_NAME` | `TOD - The Other Dude` | Application display name | | `APP_NAME` | `TOD - The Other Dude` | Application display name |
| `APP_VERSION` | `9.7.2` | Semantic version string (see VERSION file at project root) | | `APP_VERSION` | `9.8.1` | Semantic version string (see VERSION file at project root) |
| `TOD_VERSION` | `latest` | Docker image tag for pre-built images (set by setup.py) |
| `ENVIRONMENT` | `dev` | Runtime environment: `dev`, `staging`, or `production` | | `ENVIRONMENT` | `dev` | Runtime environment: `dev`, `staging`, or `production` |
| `DEBUG` | `false` | Enable debug mode | | `DEBUG` | `false` | Enable debug mode |
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173,http://localhost:8080` | Comma-separated list of allowed CORS origins | | `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173,http://localhost:8080` | Comma-separated list of allowed CORS origins |

View File

@@ -55,7 +55,7 @@
"Zero-knowledge authentication (SRP-6a)" "Zero-knowledge authentication (SRP-6a)"
], ],
"softwareRequirements": "Docker, PostgreSQL 17, Redis, NATS", "softwareRequirements": "Docker, PostgreSQL 17, Redis, NATS",
"softwareVersion": "9.8.0", "softwareVersion": "9.8.1",
"license": "https://mariadb.com/bsl11/" "license": "https://mariadb.com/bsl11/"
} }
</script> </script>
@@ -547,7 +547,7 @@
<section class="wp-section"> <section class="wp-section">
<h2>Status</h2> <h2>Status</h2>
<table class="wp-status-table"> <table class="wp-status-table">
<tr><td>Version</td><td>9.8.0</td></tr> <tr><td>Version</td><td>9.8.1</td></tr>
<tr><td>License</td><td>BSL 1.1 (converts to Apache 2.0 in 2030)</td></tr> <tr><td>License</td><td>BSL 1.1 (converts to Apache 2.0 in 2030)</td></tr>
<tr><td>Free tier</td><td>250 devices</td></tr> <tr><td>Free tier</td><td>250 devices</td></tr>
<tr><td>Stability</td><td>Breaking changes expected before v11</td></tr> <tr><td>Stability</td><td>Breaking changes expected before v11</td></tr>

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "9.8.0", "version": "9.8.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -3,7 +3,7 @@ name: tod
description: The Other Dude — MikroTik fleet management platform description: The Other Dude — MikroTik fleet management platform
type: application type: application
version: 1.0.0 version: 1.0.0
appVersion: "9.8.0" appVersion: "9.8.1"
kubeVersion: ">=1.28.0-0" kubeVersion: ">=1.28.0-0"
keywords: keywords:
- mikrotik - mikrotik

125
setup.py
View File

@@ -40,6 +40,7 @@ INIT_SQL_TEMPLATE = PROJECT_ROOT / "scripts" / "init-postgres.sql"
INIT_SQL_PROD = PROJECT_ROOT / "scripts" / "init-postgres-prod.sql" INIT_SQL_PROD = PROJECT_ROOT / "scripts" / "init-postgres-prod.sql"
COMPOSE_BASE = "docker-compose.yml" COMPOSE_BASE = "docker-compose.yml"
COMPOSE_PROD = "docker-compose.prod.yml" COMPOSE_PROD = "docker-compose.prod.yml"
COMPOSE_BUILD_OVERRIDE = "docker-compose.build.yml"
COMPOSE_CMD = [ COMPOSE_CMD = [
"docker", "compose", "docker", "compose",
"-f", COMPOSE_BASE, "-f", COMPOSE_BASE,
@@ -459,8 +460,8 @@ def preflight(args: argparse.Namespace) -> bool:
"""Run all pre-flight checks. Returns True if OK to proceed.""" """Run all pre-flight checks. Returns True if OK to proceed."""
banner("TOD Production Setup") banner("TOD Production Setup")
print(" This wizard will configure your production environment,") print(" This wizard will configure your production environment,")
print(" generate secrets, bootstrap OpenBao, build images, and") print(" generate secrets, bootstrap OpenBao, pull or build images,")
print(" start the stack.") print(" and start the stack.")
print() print()
section("Pre-flight Checks") section("Pre-flight Checks")
@@ -989,6 +990,57 @@ def wizard_telemetry(config: dict, telem: SetupTelemetry, args: argparse.Namespa
info("No diagnostics will be sent.") info("No diagnostics will be sent.")
def _read_version() -> str:
"""Read the version string from the VERSION file."""
version_file = PROJECT_ROOT / "VERSION"
if version_file.exists():
return version_file.read_text().strip()
return "latest"
def wizard_build_mode(config: dict, args: argparse.Namespace) -> None:
"""Ask whether to use pre-built images or build from source."""
section("Build Mode")
version = _read_version()
config["tod_version"] = version
if args.non_interactive:
mode = getattr(args, "build_mode", None) or "prebuilt"
config["build_mode"] = mode
if mode == "source":
COMPOSE_CMD.extend(["-f", COMPOSE_BUILD_OVERRIDE])
ok(f"Build from source (v{version})")
else:
ok(f"Pre-built images from GHCR (v{version})")
return
print(f" TOD v{bold(version)} can be installed two ways:")
print()
print(f" {bold('1.')} {green('Pre-built images')} {dim('(recommended)')}")
print(" Pull ready-to-run images from GitHub Container Registry.")
print(" Fast install, no compilation needed.")
print()
print(f" {bold('2.')} Build from source")
print(" Compile Go, Python, and Node.js locally.")
print(" Requires 4+ GB RAM and takes 5-15 minutes.")
print()
while True:
choice = input(" Choice [1/2]: ").strip()
if choice in ("1", ""):
config["build_mode"] = "prebuilt"
ok("Pre-built images from GHCR")
break
elif choice == "2":
config["build_mode"] = "source"
COMPOSE_CMD.extend(["-f", COMPOSE_BUILD_OVERRIDE])
ok("Build from source")
break
else:
warn("Please enter 1 or 2.")
# ── Summary ────────────────────────────────────────────────────────────────── # ── Summary ──────────────────────────────────────────────────────────────────
def show_summary(config: dict, args: argparse.Namespace) -> bool: def show_summary(config: dict, args: argparse.Namespace) -> bool:
@@ -1041,6 +1093,14 @@ def show_summary(config: dict, args: argparse.Namespace) -> bool:
print(f" TELEMETRY_ENABLED = {dim('false')}") print(f" TELEMETRY_ENABLED = {dim('false')}")
print() print()
print(f" {bold('Build Mode')}")
if config.get("build_mode") == "source":
print(" Mode = Build from source")
else:
print(f" Mode = {green('Pre-built images')}")
print(f" Version = {config.get('tod_version', 'latest')}")
print()
print(f" {bold('OpenBao')}") print(f" {bold('OpenBao')}")
print(f" {dim('(will be captured automatically during bootstrap)')}") print(f" {dim('(will be captured automatically during bootstrap)')}")
print() print()
@@ -1121,6 +1181,7 @@ ENVIRONMENT=production
LOG_LEVEL=info LOG_LEVEL=info
DEBUG=false DEBUG=false
APP_NAME=TOD - The Other Dude APP_NAME=TOD - The Other Dude
TOD_VERSION={config.get('tod_version', 'latest')}
# --- Storage --- # --- Storage ---
GIT_STORE_PATH=/data/git-store GIT_STORE_PATH=/data/git-store
@@ -1388,6 +1449,36 @@ def bootstrap_openbao(config: dict) -> bool:
return True return True
def pull_images() -> bool:
"""Pull pre-built images from GHCR."""
section("Pulling Images")
info("Downloading pre-built images from GitHub Container Registry...")
print()
services = ["api", "poller", "frontend", "winbox-worker"]
for i, service in enumerate(services, 1):
info(f"[{i}/{len(services)}] Pulling {service}...")
try:
run_compose("pull", service, timeout=600)
ok(f"{service} pulled successfully")
except subprocess.CalledProcessError:
fail(f"Failed to pull {service}")
print()
warn("Check your internet connection and that the image exists.")
warn("To retry:")
info(f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} "
f"--env-file .env.prod pull {service}")
return False
except subprocess.TimeoutExpired:
fail(f"Pull of {service} timed out (10 min)")
return False
print()
ok("All images ready")
return True
def build_images() -> bool: def build_images() -> bool:
"""Build Docker images one at a time to avoid OOM.""" """Build Docker images one at a time to avoid OOM."""
section("Building Images") section("Building Images")
@@ -1405,7 +1496,8 @@ def build_images() -> bool:
fail(f"Failed to build {service}") fail(f"Failed to build {service}")
print() print()
warn("To retry this build:") warn("To retry this build:")
info(f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} build {service}") info(f" docker compose -f {COMPOSE_BASE} -f {COMPOSE_PROD} "
f"-f {COMPOSE_BUILD_OVERRIDE} build {service}")
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
fail(f"Build of {service} timed out (15 min)") fail(f"Build of {service} timed out (15 min)")
@@ -1558,6 +1650,9 @@ def _build_parser() -> argparse.ArgumentParser:
help="Enable anonymous diagnostics") help="Enable anonymous diagnostics")
parser.add_argument("--no-telemetry", action="store_true", default=False, parser.add_argument("--no-telemetry", action="store_true", default=False,
help="Disable anonymous diagnostics") help="Disable anonymous diagnostics")
parser.add_argument("--build-mode", type=str, default=None,
choices=["prebuilt", "source"],
help="Image source: prebuilt (pull from GHCR) or source (compile locally)")
parser.add_argument("--yes", "-y", action="store_true", default=False, parser.add_argument("--yes", "-y", action="store_true", default=False,
help="Auto-confirm summary (don't prompt for confirmation)") help="Auto-confirm summary (don't prompt for confirmation)")
return parser return parser
@@ -1602,6 +1697,7 @@ def main() -> int:
# Phase 2: Wizard # Phase 2: Wizard
try: try:
wizard_build_mode(config, args)
wizard_database(config, args) wizard_database(config, args)
wizard_security(config) wizard_security(config)
wizard_admin(config, args) wizard_admin(config, args)
@@ -1651,18 +1747,29 @@ def main() -> int:
error_message="Aborted after OpenBao failure") error_message="Aborted after OpenBao failure")
return 1 return 1
# Phase 5: Build # Phase 5: Build or Pull
t0 = time.monotonic() t0 = time.monotonic()
if not build_images(): if config.get("build_mode") == "source":
images_ok = build_images()
step_name = "build_images"
fail_msg = "Docker build failed"
retry_hint = "Fix the build error and re-run setup.py to continue."
else:
images_ok = pull_images()
step_name = "pull_images"
fail_msg = "Image pull failed"
retry_hint = "Check your connection and re-run setup.py to continue."
if not images_ok:
duration_ms = int((time.monotonic() - t0) * 1000) duration_ms = int((time.monotonic() - t0) * 1000)
telem.step("build_images", "failure", duration_ms=duration_ms) telem.step(step_name, "failure", duration_ms=duration_ms)
warn("Fix the build error and re-run setup.py to continue.") warn(retry_hint)
telem.step("setup_total", "failure", telem.step("setup_total", "failure",
duration_ms=int((time.monotonic() - setup_start) * 1000), duration_ms=int((time.monotonic() - setup_start) * 1000),
error_message="Docker build failed") error_message=fail_msg)
return 1 return 1
duration_ms = int((time.monotonic() - t0) * 1000) duration_ms = int((time.monotonic() - t0) * 1000)
telem.step("build_images", "success", duration_ms=duration_ms) telem.step(step_name, "success", duration_ms=duration_ms)
# Phase 6: Start # Phase 6: Start
t0 = time.monotonic() t0 = time.monotonic()