#!/usr/bin/env python3 """ generate_compose.py - Generate pinned Docker Compose files for suite releases Sprint: CI/CD Enhancement - Suite Release Pipeline Creates docker-compose.yml files with pinned image versions for releases. Usage: python generate_compose.py [options] python generate_compose.py 2026.04 Nova --output docker-compose.yml python generate_compose.py 2026.04 Nova --airgap --output docker-compose.airgap.yml Arguments: version Suite version (YYYY.MM format) codename Release codename Options: --output FILE Output file (default: stdout) --airgap Generate air-gap variant --registry URL Container registry URL --include-deps Include infrastructure dependencies (postgres, valkey) """ import argparse import json import sys from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional # Repository paths SCRIPT_DIR = Path(__file__).parent REPO_ROOT = SCRIPT_DIR.parent.parent.parent MANIFEST_FILE = REPO_ROOT / "devops" / "releases" / "service-versions.json" # Default registry DEFAULT_REGISTRY = "git.stella-ops.org/stella-ops.org" # Service definitions with port mappings and dependencies SERVICE_DEFINITIONS = { "authority": { "ports": ["8080:8080"], "depends_on": ["postgres"], "environment": { "AUTHORITY_DB_CONNECTION": "Host=postgres;Database=authority;Username=stellaops;Password=${POSTGRES_PASSWORD}", }, "healthcheck": { "test": ["CMD", "curl", "-f", "http://localhost:8080/health"], "interval": "30s", "timeout": "10s", "retries": 3, }, }, "attestor": { "ports": ["8081:8080"], "depends_on": ["postgres", "authority"], "environment": { "ATTESTOR_DB_CONNECTION": "Host=postgres;Database=attestor;Username=stellaops;Password=${POSTGRES_PASSWORD}", "ATTESTOR_AUTHORITY_URL": "http://authority:8080", }, }, "concelier": { "ports": ["8082:8080"], "depends_on": ["postgres", "valkey"], "environment": { "CONCELIER_DB_CONNECTION": "Host=postgres;Database=concelier;Username=stellaops;Password=${POSTGRES_PASSWORD}", "CONCELIER_CACHE_URL": "valkey:6379", }, }, "scanner": { "ports": ["8083:8080"], "depends_on": ["postgres", "concelier"], "environment": { "SCANNER_DB_CONNECTION": "Host=postgres;Database=scanner;Username=stellaops;Password=${POSTGRES_PASSWORD}", "SCANNER_CONCELIER_URL": "http://concelier:8080", }, "volumes": ["/var/run/docker.sock:/var/run/docker.sock:ro"], }, "policy": { "ports": ["8084:8080"], "depends_on": ["postgres"], "environment": { "POLICY_DB_CONNECTION": "Host=postgres;Database=policy;Username=stellaops;Password=${POSTGRES_PASSWORD}", }, }, "signer": { "ports": ["8085:8080"], "depends_on": ["authority"], "environment": { "SIGNER_AUTHORITY_URL": "http://authority:8080", }, }, "excititor": { "ports": ["8086:8080"], "depends_on": ["postgres", "concelier"], "environment": { "EXCITITOR_DB_CONNECTION": "Host=postgres;Database=excititor;Username=stellaops;Password=${POSTGRES_PASSWORD}", }, }, "gateway": { "ports": ["8000:8080"], "depends_on": ["authority"], "environment": { "GATEWAY_AUTHORITY_URL": "http://authority:8080", }, }, "scheduler": { "ports": ["8087:8080"], "depends_on": ["postgres", "valkey"], "environment": { "SCHEDULER_DB_CONNECTION": "Host=postgres;Database=scheduler;Username=stellaops;Password=${POSTGRES_PASSWORD}", "SCHEDULER_QUEUE_URL": "valkey:6379", }, }, } # Infrastructure services INFRASTRUCTURE_SERVICES = { "postgres": { "image": "postgres:16-alpine", "environment": { "POSTGRES_USER": "stellaops", "POSTGRES_PASSWORD": "${POSTGRES_PASSWORD:-stellaops}", "POSTGRES_DB": "stellaops", }, "volumes": ["postgres_data:/var/lib/postgresql/data"], "healthcheck": { "test": ["CMD-SHELL", "pg_isready -U stellaops"], "interval": "10s", "timeout": "5s", "retries": 5, }, }, "valkey": { "image": "valkey/valkey:8-alpine", "volumes": ["valkey_data:/data"], "healthcheck": { "test": ["CMD", "valkey-cli", "ping"], "interval": "10s", "timeout": "5s", "retries": 5, }, }, } def read_service_versions() -> Dict[str, dict]: """Read service versions from manifest.""" if not MANIFEST_FILE.exists(): return {} try: manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8")) return manifest.get("services", {}) except json.JSONDecodeError: return {} def generate_compose( version: str, codename: str, registry: str, services: Dict[str, dict], airgap: bool = False, include_deps: bool = True, ) -> str: """Generate Docker Compose YAML.""" now = datetime.now(timezone.utc) lines = [ "# Docker Compose for StellaOps Suite", f"# Version: {version} \"{codename}\"", f"# Generated: {now.isoformat()}", "#", "# Usage:", "# docker compose up -d", "# docker compose logs -f", "# docker compose down", "#", "# Environment variables:", "# POSTGRES_PASSWORD - PostgreSQL password (default: stellaops)", "#", "", "services:", ] # Add infrastructure services if requested if include_deps: for name, config in INFRASTRUCTURE_SERVICES.items(): lines.extend(generate_service_block(name, config, indent=2)) # Add StellaOps services for svc_name, svc_def in SERVICE_DEFINITIONS.items(): # Get version info from manifest manifest_info = services.get(svc_name, {}) docker_tag = manifest_info.get("dockerTag") or manifest_info.get("version", version) # Build image reference if airgap: image = f"localhost:5000/{svc_name}:{docker_tag}" else: image = f"{registry}/{svc_name}:{docker_tag}" # Build service config config = { "image": image, "restart": "unless-stopped", **svc_def, } # Add release labels config["labels"] = { "com.stellaops.release.version": version, "com.stellaops.release.codename": codename, "com.stellaops.service.name": svc_name, "com.stellaops.service.version": manifest_info.get("version", "1.0.0"), } lines.extend(generate_service_block(svc_name, config, indent=2)) # Add volumes lines.extend([ "", "volumes:", ]) if include_deps: lines.extend([ " postgres_data:", " driver: local", " valkey_data:", " driver: local", ]) # Add networks lines.extend([ "", "networks:", " default:", " name: stellaops", " driver: bridge", ]) return "\n".join(lines) def generate_service_block(name: str, config: dict, indent: int = 2) -> List[str]: """Generate YAML block for a service.""" prefix = " " * indent lines = [ "", f"{prefix}{name}:", ] inner_prefix = " " * (indent + 2) # Image if "image" in config: lines.append(f"{inner_prefix}image: {config['image']}") # Container name lines.append(f"{inner_prefix}container_name: stellaops-{name}") # Restart policy if "restart" in config: lines.append(f"{inner_prefix}restart: {config['restart']}") # Ports if "ports" in config: lines.append(f"{inner_prefix}ports:") for port in config["ports"]: lines.append(f"{inner_prefix} - \"{port}\"") # Volumes if "volumes" in config: lines.append(f"{inner_prefix}volumes:") for vol in config["volumes"]: lines.append(f"{inner_prefix} - {vol}") # Environment if "environment" in config: lines.append(f"{inner_prefix}environment:") for key, value in config["environment"].items(): lines.append(f"{inner_prefix} {key}: \"{value}\"") # Depends on if "depends_on" in config: lines.append(f"{inner_prefix}depends_on:") for dep in config["depends_on"]: lines.append(f"{inner_prefix} {dep}:") lines.append(f"{inner_prefix} condition: service_healthy") # Health check if "healthcheck" in config: hc = config["healthcheck"] lines.append(f"{inner_prefix}healthcheck:") if "test" in hc: test = hc["test"] if isinstance(test, list): lines.append(f"{inner_prefix} test: {json.dumps(test)}") else: lines.append(f"{inner_prefix} test: \"{test}\"") for key in ["interval", "timeout", "retries", "start_period"]: if key in hc: lines.append(f"{inner_prefix} {key}: {hc[key]}") # Labels if "labels" in config: lines.append(f"{inner_prefix}labels:") for key, value in config["labels"].items(): lines.append(f"{inner_prefix} {key}: \"{value}\"") return lines def main(): parser = argparse.ArgumentParser( description="Generate pinned Docker Compose files for suite releases", ) parser.add_argument("version", help="Suite version (YYYY.MM format)") parser.add_argument("codename", help="Release codename") parser.add_argument("--output", "-o", help="Output file") parser.add_argument( "--airgap", action="store_true", help="Generate air-gap variant (localhost:5000 registry)", ) parser.add_argument( "--registry", default=DEFAULT_REGISTRY, help="Container registry URL", ) parser.add_argument( "--include-deps", action="store_true", default=True, help="Include infrastructure dependencies", ) parser.add_argument( "--no-deps", action="store_true", help="Exclude infrastructure dependencies", ) args = parser.parse_args() # Read service versions services = read_service_versions() if not services: print("Warning: No service versions found in manifest", file=sys.stderr) # Generate compose file include_deps = args.include_deps and not args.no_deps compose = generate_compose( version=args.version, codename=args.codename, registry=args.registry, services=services, airgap=args.airgap, include_deps=include_deps, ) # Output if args.output: Path(args.output).write_text(compose, encoding="utf-8") print(f"Docker Compose written to: {args.output}", file=sys.stderr) else: print(compose) if __name__ == "__main__": main()