#!/usr/bin/env python3 """Deploy or teardown a PR preview environment on oci k3s. Usage: python3 scripts/deploy-preview.py deploy python3 scripts/deploy-preview.py teardown """ import subprocess import sys import textwrap import json OCI_HOST = "oci" REGISTRY = "registry.oci.euphon.net" BASE_DOMAIN = "oil.oci.euphon.net" PROD_NS = "oil-calculator" def run(cmd, *, check=True, capture=False, **kw): """Run a local command.""" print(f" $ {cmd}") r = subprocess.run(cmd, shell=True, text=True, capture_output=capture, **kw) if check and r.returncode != 0: if capture: print(r.stderr) sys.exit(f"Command failed: {cmd}") return r def ssh(cmd, *, check=True, capture=False): """Run a command on oci via SSH.""" return run(f'ssh {OCI_HOST} {repr(cmd)}', check=check, capture=capture) def kubectl(cmd, *, check=True, capture=False): """Run kubectl on oci.""" return ssh(f"sudo k3s kubectl {cmd}", check=check, capture=capture) # ─── Deploy ────────────────────────────────────────────── def deploy(pr_id: str): ns = f"oil-pr-{pr_id}" host = f"pr-{pr_id}.{BASE_DOMAIN}" image = f"{REGISTRY}/oil-calculator:pr-{pr_id}" build_dir = f"/tmp/oil-pr-{pr_id}-build" print(f"\n{'='*60}") print(f" Deploying preview: https://{host}") print(f" Namespace: {ns} Image: {image}") print(f"{'='*60}\n") # 1. Rsync source to oci print("[1/6] Syncing source code...") run(f"rsync -az --exclude node_modules --exclude .git --exclude .venv " f"--exclude cypress --exclude demo-output . {OCI_HOST}:{build_dir}/") # 2. Copy production DB print("[2/6] Copying production database...") ssh(f"mkdir -p {build_dir}/data") r = kubectl(f"get pods -n {PROD_NS} -l app=oil-calculator " f"--field-selector=status.phase=Running " f"-o jsonpath='{{.items[0].metadata.name}}'", capture=True) prod_pod = r.stdout.strip() if prod_pod: kubectl(f"cp {PROD_NS}/{prod_pod}:/data/oil_calculator.db " f"{build_dir}/data/oil_calculator.db") else: print(" WARNING: No running prod pod, using empty DB") ssh(f"touch {build_dir}/data/oil_calculator.db") # 3. Write preview Dockerfile and build print("[3/6] Building Docker image...") dockerfile = textwrap.dedent("""\ FROM node:20-slim AS frontend-build WORKDIR /build COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ RUN npm run build FROM python:3.12-slim WORKDIR /app COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY backend/ ./backend/ COPY --from=frontend-build /build/dist ./frontend/ COPY data/oil_calculator.db /data/oil_calculator.db ENV DB_PATH=/data/oil_calculator.db ENV FRONTEND_DIR=/app/frontend EXPOSE 8000 CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] """) # Write Dockerfile on remote ssh(f"cat > {build_dir}/Dockerfile.preview << 'DEOF'\n{dockerfile}DEOF") ssh(f"cd {build_dir} && docker build -f Dockerfile.preview -t {image} .") ssh(f"docker push {image}") # 4. Create namespace + copy regcred print("[4/6] Creating namespace...") kubectl(f"create namespace {ns} --dry-run=client -o yaml | " f"sudo k3s kubectl apply -f -") # Copy regcred from prod kubectl(f"get secret regcred -n {PROD_NS} -o json | " f"sed 's/\"{PROD_NS}\"/\"{ns}\"/g' | " f"sudo k3s kubectl apply -n {ns} -f -") # 5. Apply K8s manifests print("[5/6] Applying K8s resources...") manifests = textwrap.dedent(f"""\ apiVersion: apps/v1 kind: Deployment metadata: name: oil-calculator namespace: {ns} spec: replicas: 1 selector: matchLabels: app: oil-calculator template: metadata: labels: app: oil-calculator spec: imagePullSecrets: - name: regcred containers: - name: app image: {image} ports: - containerPort: 8000 resources: requests: cpu: 50m memory: 64Mi limits: cpu: 500m memory: 256Mi --- apiVersion: v1 kind: Service metadata: name: oil-calculator namespace: {ns} spec: selector: app: oil-calculator ports: - port: 80 targetPort: 8000 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: oil-calculator namespace: {ns} annotations: traefik.ingress.kubernetes.io/router.tls.certresolver: le spec: ingressClassName: traefik tls: - hosts: - {host} rules: - host: {host} http: paths: - path: / pathType: Prefix backend: service: name: oil-calculator port: number: 80 """) ssh(f"cat << 'EOYAML' | sudo k3s kubectl apply -f -\n{manifests}EOYAML") # 6. Wait for rollout print("[6/6] Waiting for rollout...") kubectl(f"rollout status deploy/oil-calculator -n {ns} --timeout=120s") # Cleanup build dir ssh(f"rm -rf {build_dir}", check=False) print(f"\n{'='*60}") print(f" Preview live: https://{host}") print(f"{'='*60}\n") # ─── Teardown ──────────────────────────────────────────── def teardown(pr_id: str): ns = f"oil-pr-{pr_id}" image = f"{REGISTRY}/oil-calculator:pr-{pr_id}" print(f"\nTearing down preview: {ns}") kubectl(f"delete namespace {ns} --ignore-not-found") ssh(f"docker rmi {image} 2>/dev/null || true", check=False) print("Done.\n") # ─── Main ──────────────────────────────────────────────── if __name__ == "__main__": if len(sys.argv) < 3: print(__doc__) sys.exit(1) action, pr_id = sys.argv[1], sys.argv[2] if action == "deploy": deploy(pr_id) elif action == "teardown": teardown(pr_id) else: print(f"Unknown action: {action}") sys.exit(1)