diff --git a/AGENTS.md b/AGENTS.md index 09113a967..0df4057e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ POST /pastebin/pastebin/User | `dbal/production/src/daemon/server_routes.cpp` | Route registration + auto-seed startup | | `frontends/pastebin/backend/app.py` | Flask JWT auth + Python runner | | `frontends/pastebin/src/` | Next.js React app | -| `deployment/docker-compose.stack.yml` | Full stack compose | +| `deployment/compose.yml` | Full stack compose | | `deployment/deployment.py` | Python CLI for all build/deploy/stack commands | --- @@ -80,15 +80,15 @@ cd deployment # Full rebuild + restart python3 deployment.py build apps --force dbal pastebin -docker compose -f docker-compose.stack.yml up -d +docker compose -f compose.yml up -d # Flask backend (separate from Next.js) -docker compose -f docker-compose.stack.yml build pastebin-backend -docker compose -f docker-compose.stack.yml up -d pastebin-backend +docker compose -f compose.yml build pastebin-backend +docker compose -f compose.yml up -d pastebin-backend # dbal-init volume (schema volume container — rebuild when entity JSON changes) -docker compose -f docker-compose.stack.yml build dbal-init -docker compose -f docker-compose.stack.yml up dbal-init +docker compose -f compose.yml build dbal-init +docker compose -f compose.yml up dbal-init ``` --- diff --git a/CLAUDE.md b/CLAUDE.md index 40bd35e30..561ef4aa1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,11 +230,11 @@ npm run build --workspaces cd deployment && python3 deployment.py build base # Build Docker base images # Deploy full stack -cd deployment && docker compose -f docker-compose.stack.yml up -d +cd deployment && docker compose -f compose.yml up -d # Build & deploy specific apps python3 deployment.py build apps --force dbal pastebin # Next.js frontend only -docker compose -f docker-compose.stack.yml build pastebin-backend # Flask backend +docker compose -f compose.yml build pastebin-backend # Flask backend # DBAL logs / seed verification docker logs -f metabuilder-dbal diff --git a/README.md b/README.md index e84dd1676..0aaf4b364 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ http://localhost:8080 # DBAL C++ REST API (entities) ```bash # Deploy full stack cd deployment -docker compose -f docker-compose.stack.yml up -d +docker compose -f compose.yml up -d # Build & deploy a specific app python3 deployment.py build apps --force dbal pastebin diff --git a/deployment/README.md b/deployment/README.md index f4913bab7..fda55d550 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -105,7 +105,7 @@ deployment.py artifactory init | File | Purpose | |------|---------| | `docker-compose.nexus.yml` | Local registries (Nexus + Artifactory) | -| `docker-compose.stack.yml` | Full application stack | +| `compose.yml` | Full application stack | | `docker-compose.test.yml` | Integration test services | | `docker-compose.smoke.yml` | Smoke test environment | diff --git a/deployment/cli/build_apps.py b/deployment/cli/build_apps.py index dc3f769e9..d603aef0e 100644 --- a/deployment/cli/build_apps.py +++ b/deployment/cli/build_apps.py @@ -4,7 +4,7 @@ import argparse import time from cli.helpers import ( BASE_DIR, PROJECT_ROOT, GREEN, YELLOW, NC, - docker_compose, docker_image_exists, log_err, log_info, log_ok, log_warn, + docker_compose, docker_image_exists, get_buildable_services, log_err, log_info, log_ok, log_warn, pull_with_retry, resolve_services, run as run_proc, ) @@ -37,8 +37,8 @@ def run_cmd(args: argparse.Namespace, config: dict) -> int: print(f" - {img}") print(f"{YELLOW}Build with:{NC} python3 deployment.py build base\n") - all_apps = defs["all_apps"] - targets = args.apps if args.apps else list(all_apps) + buildable = get_buildable_services() + targets = args.apps if args.apps else buildable services = resolve_services(targets, config) if services is None: return 1 diff --git a/deployment/cli/commands.json b/deployment/cli/commands.json index a5f3bd136..d901ad6aa 100644 --- a/deployment/cli/commands.json +++ b/deployment/cli/commands.json @@ -9,22 +9,9 @@ }, "definitions": { - "all_apps": ["workflowui", "codegen", "pastebin", "postgres", "emailclient", "exploded-diagrams", "storybook", "frontend-app", "dbal"], "base_build_order": ["apt", "conan-deps", "android-sdk", "node-deps", "pip-deps", "devcontainer"], "stack_commands": ["up", "start", "down", "stop", "build", "restart", "logs", "ps", "status", "clean"], - "service_map": { - "workflowui": "workflowui", - "codegen": "codegen", - "pastebin": "pastebin", - "postgres": "postgres-dashboard", - "emailclient": "emailclient-app", - "exploded-diagrams": "exploded-diagrams", - "storybook": "storybook", - "frontend-app": "frontend-app", - "dbal": "dbal" - }, - "base_images": { "apt": { "dockerfile": "Dockerfile.apt", "tag": "metabuilder/base-apt:latest" }, "conan-deps": { "dockerfile": "Dockerfile.conan-deps", "tag": "metabuilder/base-conan-deps:latest" }, diff --git a/deployment/cli/deploy.py b/deployment/cli/deploy.py index 9369e226c..8627e3ed6 100644 --- a/deployment/cli/deploy.py +++ b/deployment/cli/deploy.py @@ -6,16 +6,16 @@ import sys import time from cli.helpers import ( COMPOSE_FILE, GREEN, RED, YELLOW, BLUE, NC, - docker_compose, log_err, log_warn, resolve_services, run as run_proc, + docker_compose, get_buildable_services, log_err, log_warn, resolve_services, run as run_proc, ) def run_cmd(args: argparse.Namespace, config: dict) -> int: - all_apps = config["definitions"]["all_apps"] - targets = list(all_apps) if args.all else args.apps + buildable = get_buildable_services() + targets = buildable if args.all else args.apps if not targets: log_err("Specify app(s) to deploy, or use --all") - print(f"Available: {', '.join(all_apps)}") + print(f"Available: {', '.join(buildable)}") return 1 services = resolve_services(targets, config) diff --git a/deployment/cli/helpers.py b/deployment/cli/helpers.py index fc0f9ae00..d55956189 100644 --- a/deployment/cli/helpers.py +++ b/deployment/cli/helpers.py @@ -13,7 +13,7 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent.parent # deployment/ PROJECT_ROOT = SCRIPT_DIR.parent BASE_DIR = SCRIPT_DIR / "base-images" -COMPOSE_FILE = SCRIPT_DIR / "docker-compose.stack.yml" +COMPOSE_FILE = SCRIPT_DIR / "compose.yml" # ── Colors ─────────────────────────────────────────────────────────────────── @@ -121,17 +121,27 @@ def build_with_retry(tag: str, dockerfile: str, context: str, max_attempts: int return False +def get_buildable_services() -> list[str]: + """Return all service names that have a build: section in the compose file.""" + import yaml + with open(COMPOSE_FILE) as f: + compose = yaml.safe_load(f) + return [ + name for name, svc in compose.get("services", {}).items() + if isinstance(svc, dict) and "build" in svc + ] + + def resolve_services(targets: list[str], config: dict) -> list[str] | None: - """Map friendly app names to compose service names. Returns None on error.""" - svc_map = config["definitions"]["service_map"] + """Validate compose service names against the compose file. Returns None on error.""" + buildable = get_buildable_services() services = [] for t in targets: - svc = svc_map.get(t) - if not svc: - log_err(f"Unknown app: {t}") - print(f"Available: {', '.join(config['definitions']['all_apps'])}") + if t not in buildable: + log_err(f"Unknown or non-buildable service: {t}") + print(f"Available: {', '.join(buildable)}") return None - services.append(svc) + services.append(t) return services diff --git a/deployment/cli/nexus_populate.py b/deployment/cli/nexus_populate.py index 67d567e03..fa832840c 100644 --- a/deployment/cli/nexus_populate.py +++ b/deployment/cli/nexus_populate.py @@ -1,11 +1,31 @@ """Push all locally-built images to Nexus with :main + :latest tags.""" import argparse +import pathlib +import yaml + from cli.helpers import ( BLUE, GREEN, NC, docker_image_exists, log_err, log_info, log_ok, log_warn, run as run_proc, ) +COMPOSE_FILE = pathlib.Path(__file__).parent.parent / "compose.yml" + + +def _load_built_images() -> list[dict]: + """Return [{local, name}] for every service with a build: directive.""" + with open(COMPOSE_FILE) as f: + dc = yaml.safe_load(f) + images = [] + for svc_name, svc in dc.get("services", {}).items(): + if "build" not in svc: + continue + local = svc.get("image", f"deployment-{svc_name}:latest") + # derive a short push name from the image tag (strip prefix/tag) + name = local.split("/")[-1].split(":")[0].removeprefix("deployment-") + images.append({"local": local, "name": name}) + return images + def run_cmd(args: argparse.Namespace, config: dict) -> int: nexus = "localhost:5050" @@ -16,23 +36,23 @@ def run_cmd(args: argparse.Namespace, config: dict) -> int: run_proc(["docker", "login", nexus, "-u", nexus_user, "--password-stdin"], input=nexus_pass.encode()) - images_def = config["definitions"]["nexus_images"] + images = _load_built_images() pushed = skipped = failed = 0 - def push_image(src: str, name: str, size: str) -> None: + def push_image(local: str, name: str) -> None: nonlocal pushed, skipped, failed - if not docker_image_exists(src): - log_warn(f"SKIP {name} — {src} not found locally") + if not docker_image_exists(local): + log_warn(f"SKIP {name} — {local} not found locally") skipped += 1 return dst_main = f"{nexus}/{slug}/{name}:main" dst_latest = f"{nexus}/{slug}/{name}:latest" - log_info(f"Pushing {name} ({size})...") - run_proc(["docker", "tag", src, dst_main]) - run_proc(["docker", "tag", src, dst_latest]) + log_info(f"Pushing {name}...") + run_proc(["docker", "tag", local, dst_main]) + run_proc(["docker", "tag", local, dst_latest]) r1 = run_proc(["docker", "push", dst_main]) r2 = run_proc(["docker", "push", dst_latest]) @@ -43,21 +63,12 @@ def run_cmd(args: argparse.Namespace, config: dict) -> int: log_err(f" {name} FAILED") failed += 1 - print(f"\n{BLUE}Registry : {nexus}{NC}") - print(f"{BLUE}Slug : {slug}{NC}") - print(f"{BLUE}Skip heavy: {args.skip_heavy}{NC}\n") + print(f"\n{BLUE}Registry : {nexus}{NC}") + print(f"{BLUE}Slug : {slug}{NC}") + print(f"{BLUE}Images : {len(images)} (parsed from compose.yml){NC}\n") - for entry in images_def["base"] + images_def["apps"]: - push_image(entry["local"], entry["name"], entry["size"]) - - if args.skip_heavy: - log_warn("Skipping heavy images (--skip-heavy set):") - for entry in images_def["heavy"] + images_def["heavy_apps"]: - log_warn(f" {entry['name']} ({entry['size']})") - else: - log_info("--- Heavy images (this will take a while) ---") - for entry in images_def["heavy_apps"] + images_def["heavy"]: - push_image(entry["local"], entry["name"], entry["size"]) + for entry in images: + push_image(entry["local"], entry["name"]) print(f"\n{GREEN}{'=' * 46}{NC}") print(f"{GREEN} Done. pushed={pushed} skipped={skipped} failed={failed}{NC}") diff --git a/deployment/cli/nexus_push.py b/deployment/cli/nexus_push.py index 8471f32e1..41c35f320 100644 --- a/deployment/cli/nexus_push.py +++ b/deployment/cli/nexus_push.py @@ -11,7 +11,7 @@ from cli.helpers import ( docker_image_exists, log_info, run as run_proc, ) -COMPOSE_FILE = pathlib.Path(__file__).parent.parent / "docker-compose.stack.yml" +COMPOSE_FILE = pathlib.Path(__file__).parent.parent / "compose.yml" BASE_IMAGES_DIR = pathlib.Path(__file__).parent.parent / "base-images" # Dockerfile.apt -> base-apt, Dockerfile.node-deps -> base-node-deps, etc. @@ -70,7 +70,7 @@ def run_cmd(args: argparse.Namespace, config: dict) -> int: print(f"{YELLOW}Registry:{NC} {local_registry}") print(f"{YELLOW}Slug:{NC} {slug}") print(f"{YELLOW}Tag:{NC} {tag}") - print(f"{YELLOW}Images:{NC} {len(images)} (base-images/ + docker-compose.stack.yml)\n") + print(f"{YELLOW}Images:{NC} {len(images)} (base-images/ + compose.yml)\n") log_info(f"Logging in to {local_registry}...") run_proc(["docker", "login", local_registry, "-u", nexus_user, "--password-stdin"], diff --git a/deployment/docker-compose.stack.yml b/deployment/compose.yml similarity index 92% rename from deployment/docker-compose.stack.yml rename to deployment/compose.yml index dd6042e55..c4fb1dba6 100644 --- a/deployment/docker-compose.stack.yml +++ b/deployment/compose.yml @@ -15,10 +15,10 @@ # --profile media Media daemon (FFmpeg/radio/retro), native HTTP streaming, HLS # # Usage: -# docker compose -f docker-compose.stack.yml up -d -# docker compose -f docker-compose.stack.yml --profile monitoring up -d -# docker compose -f docker-compose.stack.yml --profile media up -d -# docker compose -f docker-compose.stack.yml --profile monitoring --profile media up -d +# docker compose -f compose.yml up -d +# docker compose -f compose.yml --profile monitoring up -d +# docker compose -f compose.yml --profile media up -d +# docker compose -f compose.yml --profile monitoring --profile media up -d # # Access: # http://localhost Welcome portal @@ -918,6 +918,7 @@ services: build: context: .. dockerfile: deployment/config/prometheus/Dockerfile + image: deployment-prometheus:latest container_name: metabuilder-prometheus restart: unless-stopped profiles: [monitoring] @@ -949,6 +950,7 @@ services: build: context: .. dockerfile: deployment/config/grafana/Dockerfile + image: deployment-grafana:latest container_name: metabuilder-grafana restart: unless-stopped profiles: [monitoring] @@ -984,6 +986,7 @@ services: build: context: .. dockerfile: deployment/config/loki/Dockerfile + image: deployment-loki:latest container_name: metabuilder-loki restart: unless-stopped profiles: [monitoring] @@ -1008,6 +1011,7 @@ services: build: context: .. dockerfile: deployment/config/promtail/Dockerfile + image: deployment-promtail:latest container_name: metabuilder-promtail restart: unless-stopped profiles: [monitoring] @@ -1165,6 +1169,7 @@ services: build: context: .. dockerfile: deployment/config/alertmanager/Dockerfile + image: deployment-alertmanager:latest container_name: metabuilder-alertmanager restart: unless-stopped profiles: [monitoring] @@ -1197,6 +1202,7 @@ services: build: context: ../services/media_daemon dockerfile: Dockerfile + image: deployment-media-daemon:latest container_name: metabuilder-media-daemon restart: unless-stopped profiles: [media] @@ -1262,6 +1268,7 @@ services: build: context: .. dockerfile: deployment/config/nginx/Dockerfile.stream + image: deployment-nginx-stream:latest container_name: metabuilder-nginx-stream restart: unless-stopped profiles: [media] @@ -1280,6 +1287,101 @@ services: networks: - metabuilder +# ============================================================================ +# Package Registries (--profile registry) — Nexus + Artifactory +# +# Run with: +# docker compose -f compose.yml --profile registry up -d +# # Wait ~2 min for init containers, then: +# python3 deployment.py nexus push +# python3 deployment.py npm publish-patches +# +# Access: +# Nexus: http://localhost:8091 (admin / nexus) +# Artifactory: http://localhost:8092 (admin / password) +# ============================================================================ + + nexus: + image: sonatype/nexus3:3.75.0 + platform: linux/amd64 + profiles: [registry] + container_name: nexus-registry + restart: unless-stopped + ports: + - "8091:8081" + - "5050:5050" + volumes: + - nexus-data:/nexus-data + environment: + INSTALL4J_ADD_VM_PARAMS: "-Xms1g -Xmx1g -XX:MaxDirectMemorySize=1g" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8081/service/rest/v1/status || exit 1"] + interval: 30s + timeout: 10s + retries: 15 + start_period: 120s + networks: + - metabuilder + + nexus-init: + image: python:3.12-alpine + profiles: [registry] + container_name: nexus-init + depends_on: + nexus: + condition: service_healthy + volumes: + - nexus-data:/nexus-data:ro + - ./nexus-init.py:/nexus-init.py:ro + entrypoint: ["/bin/sh", "-c", "pip install -q requests && python3 /nexus-init.py"] + environment: + NEXUS_URL: "http://nexus:8081" + NEXUS_ADMIN_NEW_PASS: "nexus" + DOCKER_REPO_PORT: "5050" + restart: "no" + networks: + - metabuilder + + artifactory: + image: releases-docker.jfrog.io/jfrog/artifactory-cpp-ce:latest + profiles: [registry] + container_name: artifactory-ce + restart: unless-stopped + ports: + - "8092:8081" + - "8093:8082" + volumes: + - artifactory-data:/var/opt/jfrog/artifactory + environment: + JF_SHARED_DATABASE_TYPE: derby + JF_SHARED_DATABASE_ALLOWNONPOSTGRESQL: "true" + EXTRA_JAVA_OPTIONS: "-Xms512m -Xmx2g" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8082/router/api/v1/system/health || exit 1"] + interval: 30s + timeout: 10s + retries: 15 + start_period: 120s + networks: + - metabuilder + + artifactory-init: + image: python:3.12-alpine + profiles: [registry] + container_name: artifactory-init + depends_on: + artifactory: + condition: service_healthy + volumes: + - ./artifactory-init.py:/artifactory-init.py:ro + entrypoint: ["/bin/sh", "-c", "pip install -q requests && python3 /artifactory-init.py"] + environment: + ARTIFACTORY_URL: "http://artifactory:8081" + ARTIFACTORY_ADMIN_PASS: "password" + restart: "no" + networks: + - metabuilder + # ============================================================================ # Volumes # ============================================================================ @@ -1342,6 +1444,11 @@ volumes: driver: local packagerepo-data: driver: local + # Registry + nexus-data: + name: nexus-data + artifactory-data: + name: artifactory-data # ============================================================================ # Networks diff --git a/deployment/config/dbal/config.yaml b/deployment/config/dbal/config.yaml index 66d1688e3..7834b4d75 100644 --- a/deployment/config/dbal/config.yaml +++ b/deployment/config/dbal/config.yaml @@ -2,7 +2,7 @@ # # Primary adapter is configured here. Additional backends (cache, search, # secondary databases) are configured via environment variables in -# docker-compose.stack.yml: +# compose.yml: # # DBAL_ADAPTER Primary adapter type (postgres, mysql, sqlite, etc.) # DATABASE_URL Primary database connection string diff --git a/deployment/config/nginx-smoke/default.conf b/deployment/config/nginx-smoke/default.conf deleted file mode 100644 index 3bf12ca13..000000000 --- a/deployment/config/nginx-smoke/default.conf +++ /dev/null @@ -1,58 +0,0 @@ -## -## nginx-smoke — Gateway for deployment smoke tests in CI. -## -## Real apps (workflowui, pastebin) are proxied to playwright's webServer -## processes running on the host (reached via host.docker.internal). -## Remaining apps (codegen, emailclient, etc.) return stub 200 responses -## since they are not started as dev servers in CI. -## - -server { - listen 80; - server_name localhost; - - # ── Real apps — proxied to playwright webServer processes on host ───── - - location /workflowui { - proxy_pass http://host.docker.internal:3000; - proxy_set_header Host localhost; - proxy_set_header X-Real-IP $remote_addr; - proxy_read_timeout 120s; - } - - location /pastebin { - proxy_pass http://host.docker.internal:3001; - proxy_set_header Host localhost; - proxy_set_header X-Real-IP $remote_addr; - proxy_read_timeout 120s; - } - - # ── DBAL API — proxied to real C++ daemon ───────────────────────────── - - location /api/ { - proxy_pass http://dbal:8080/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_read_timeout 30s; - } - - # ── Portal — must contain "MetaBuilder" ─────────────────────────────── - - location = / { - add_header Content-Type text/html; - return 200 '