374 lines
11 KiB
Python
374 lines
11 KiB
Python
#!/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 <version> <codename> [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()
|