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:
87
.github/workflows/release.yml
vendored
Normal file
87
.github/workflows/release.yml
vendored
Normal 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
|
||||
@@ -144,7 +144,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# App settings
|
||||
APP_NAME: str = "TOD - The Other Dude"
|
||||
APP_VERSION: str = "9.8.0"
|
||||
APP_VERSION: str = "9.8.1"
|
||||
DEBUG: bool = False
|
||||
|
||||
@field_validator("CREDENTIAL_ENCRYPTION_KEY")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "the-other-dude-backend"
|
||||
version = "9.8.0"
|
||||
version = "9.8.1"
|
||||
description = "MikroTik Fleet Management Portal - Backend API"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
28
docker-compose.build.yml
Normal file
28
docker-compose.build.yml
Normal 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
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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:
|
||||
postgres:
|
||||
@@ -13,9 +18,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.api
|
||||
image: ghcr.io/staack/the-other-dude/api:${TOD_VERSION:-latest}
|
||||
container_name: tod_api
|
||||
env_file: .env.prod
|
||||
environment:
|
||||
@@ -67,9 +70,7 @@ services:
|
||||
- tod_remote_worker
|
||||
|
||||
poller:
|
||||
build:
|
||||
context: ./poller
|
||||
dockerfile: ./Dockerfile
|
||||
image: ghcr.io/staack/the-other-dude/poller:${TOD_VERSION:-latest}
|
||||
container_name: tod_poller
|
||||
env_file: .env.prod
|
||||
cap_add:
|
||||
@@ -135,6 +136,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
winbox-worker:
|
||||
image: ghcr.io/staack/the-other-dude/winbox-worker:${TOD_VERSION:-latest}
|
||||
environment:
|
||||
LOG_LEVEL: info
|
||||
MAX_CONCURRENT_SESSIONS: 10
|
||||
@@ -146,9 +148,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/Dockerfile.frontend
|
||||
image: ghcr.io/staack/the-other-dude/frontend:${TOD_VERSION:-latest}
|
||||
container_name: tod_frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
|
||||
@@ -9,7 +9,8 @@ TOD uses Pydantic Settings for configuration. All values can be set via environm
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `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` |
|
||||
| `DEBUG` | `false` | Enable debug mode |
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173,http://localhost:8080` | Comma-separated list of allowed CORS origins |
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"Zero-knowledge authentication (SRP-6a)"
|
||||
],
|
||||
"softwareRequirements": "Docker, PostgreSQL 17, Redis, NATS",
|
||||
"softwareVersion": "9.8.0",
|
||||
"softwareVersion": "9.8.1",
|
||||
"license": "https://mariadb.com/bsl11/"
|
||||
}
|
||||
</script>
|
||||
@@ -547,7 +547,7 @@
|
||||
<section class="wp-section">
|
||||
<h2>Status</h2>
|
||||
<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>Free tier</td><td>250 devices</td></tr>
|
||||
<tr><td>Stability</td><td>Breaking changes expected before v11</td></tr>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "9.8.0",
|
||||
"version": "9.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,7 +3,7 @@ name: tod
|
||||
description: The Other Dude — MikroTik fleet management platform
|
||||
type: application
|
||||
version: 1.0.0
|
||||
appVersion: "9.8.0"
|
||||
appVersion: "9.8.1"
|
||||
kubeVersion: ">=1.28.0-0"
|
||||
keywords:
|
||||
- mikrotik
|
||||
|
||||
125
setup.py
125
setup.py
@@ -40,6 +40,7 @@ INIT_SQL_TEMPLATE = PROJECT_ROOT / "scripts" / "init-postgres.sql"
|
||||
INIT_SQL_PROD = PROJECT_ROOT / "scripts" / "init-postgres-prod.sql"
|
||||
COMPOSE_BASE = "docker-compose.yml"
|
||||
COMPOSE_PROD = "docker-compose.prod.yml"
|
||||
COMPOSE_BUILD_OVERRIDE = "docker-compose.build.yml"
|
||||
COMPOSE_CMD = [
|
||||
"docker", "compose",
|
||||
"-f", COMPOSE_BASE,
|
||||
@@ -459,8 +460,8 @@ def preflight(args: argparse.Namespace) -> bool:
|
||||
"""Run all pre-flight checks. Returns True if OK to proceed."""
|
||||
banner("TOD Production Setup")
|
||||
print(" This wizard will configure your production environment,")
|
||||
print(" generate secrets, bootstrap OpenBao, build images, and")
|
||||
print(" start the stack.")
|
||||
print(" generate secrets, bootstrap OpenBao, pull or build images,")
|
||||
print(" and start the stack.")
|
||||
print()
|
||||
|
||||
section("Pre-flight Checks")
|
||||
@@ -989,6 +990,57 @@ def wizard_telemetry(config: dict, telem: SetupTelemetry, args: argparse.Namespa
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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()
|
||||
|
||||
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" {dim('(will be captured automatically during bootstrap)')}")
|
||||
print()
|
||||
@@ -1121,6 +1181,7 @@ ENVIRONMENT=production
|
||||
LOG_LEVEL=info
|
||||
DEBUG=false
|
||||
APP_NAME=TOD - The Other Dude
|
||||
TOD_VERSION={config.get('tod_version', 'latest')}
|
||||
|
||||
# --- Storage ---
|
||||
GIT_STORE_PATH=/data/git-store
|
||||
@@ -1388,6 +1449,36 @@ def bootstrap_openbao(config: dict) -> bool:
|
||||
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:
|
||||
"""Build Docker images one at a time to avoid OOM."""
|
||||
section("Building Images")
|
||||
@@ -1405,7 +1496,8 @@ def build_images() -> bool:
|
||||
fail(f"Failed to build {service}")
|
||||
print()
|
||||
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
|
||||
except subprocess.TimeoutExpired:
|
||||
fail(f"Build of {service} timed out (15 min)")
|
||||
@@ -1558,6 +1650,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
help="Enable anonymous diagnostics")
|
||||
parser.add_argument("--no-telemetry", action="store_true", default=False,
|
||||
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,
|
||||
help="Auto-confirm summary (don't prompt for confirmation)")
|
||||
return parser
|
||||
@@ -1602,6 +1697,7 @@ def main() -> int:
|
||||
|
||||
# Phase 2: Wizard
|
||||
try:
|
||||
wizard_build_mode(config, args)
|
||||
wizard_database(config, args)
|
||||
wizard_security(config)
|
||||
wizard_admin(config, args)
|
||||
@@ -1651,18 +1747,29 @@ def main() -> int:
|
||||
error_message="Aborted after OpenBao failure")
|
||||
return 1
|
||||
|
||||
# Phase 5: Build
|
||||
# Phase 5: Build or Pull
|
||||
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)
|
||||
telem.step("build_images", "failure", duration_ms=duration_ms)
|
||||
warn("Fix the build error and re-run setup.py to continue.")
|
||||
telem.step(step_name, "failure", duration_ms=duration_ms)
|
||||
warn(retry_hint)
|
||||
telem.step("setup_total", "failure",
|
||||
duration_ms=int((time.monotonic() - setup_start) * 1000),
|
||||
error_message="Docker build failed")
|
||||
error_message=fail_msg)
|
||||
return 1
|
||||
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
|
||||
t0 = time.monotonic()
|
||||
|
||||
Reference in New Issue
Block a user