diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..38cb33e --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/VERSION b/VERSION index 834eb3f..31476ce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.8.0 +9.8.1 diff --git a/backend/app/config.py b/backend/app/config.py index 1e52b1e..9ac398a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0f490bd..f6ed279 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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 = [ diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 0000000..c9c220d --- /dev/null +++ b/docker-compose.build.yml @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 61ec792..41aadc5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3cbb5f1..7fcfe64 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 | diff --git a/docs/website/index.html b/docs/website/index.html index 0b641a7..c29b640 100644 --- a/docs/website/index.html +++ b/docs/website/index.html @@ -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/" } @@ -547,7 +547,7 @@

Status

- + diff --git a/frontend/package.json b/frontend/package.json index 1eb9e01..18b59d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "9.8.0", + "version": "9.8.1", "type": "module", "scripts": { "dev": "vite", diff --git a/infrastructure/helm/Chart.yaml b/infrastructure/helm/Chart.yaml index a444b61..a41f41e 100644 --- a/infrastructure/helm/Chart.yaml +++ b/infrastructure/helm/Chart.yaml @@ -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 diff --git a/setup.py b/setup.py index 88485f3..9f469f2 100755 --- a/setup.py +++ b/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()
Version9.8.0
Version9.8.1
LicenseBSL 1.1 (converts to Apache 2.0 in 2030)
Free tier250 devices
StabilityBreaking changes expected before v11