diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 18b54ce..ba270ec 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -12,12 +12,5 @@ jobs: - name: Unit tests run: cd frontend && npm ci && npm run test:unit - - name: Build & Deploy - run: | - rsync -az --exclude node_modules --exclude .git --exclude .venv . oci:~/oil-calculator/ - ssh oci " - cd ~/oil-calculator && - docker build -t registry.oci.euphon.net/oil-calculator:latest . && - docker push registry.oci.euphon.net/oil-calculator:latest && - sudo k3s kubectl rollout restart deploy/oil-calculator -n oil-calculator - " + - name: Deploy + run: python3 scripts/deploy-preview.py deploy-prod diff --git a/.gitea/workflows/preview.yml b/.gitea/workflows/preview.yml index cd1a42c..534e05d 100644 --- a/.gitea/workflows/preview.yml +++ b/.gitea/workflows/preview.yml @@ -3,9 +3,6 @@ on: pull_request: types: [opened, synchronize, reopened, closed] -env: - BASE_DOMAIN: oil.oci.euphon.net - jobs: deploy-preview: if: github.event.action != 'closed' @@ -20,13 +17,15 @@ jobs: run: python3 scripts/deploy-preview.py deploy ${{ github.event.pull_request.number }} - name: Comment PR + env: + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} run: | PR_ID="${{ github.event.pull_request.number }}" - curl -s -X POST \ + curl -sf -X POST \ "https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \ - -H "Authorization: token ${{ secrets.GIT_TOKEN }}" \ + -H "Authorization: token ${GIT_TOKEN}" \ -H "Content-Type: application/json" \ - -d "{\"body\": \"🚀 **Preview deployed**: https://pr-${PR_ID}.${BASE_DOMAIN}\n\nDB is a copy of production. Changes won't affect prod.\"}" + -d "{\"body\": \"🚀 **Preview**: https://pr-${PR_ID}.oil.oci.euphon.net\n\nDB is a copy of production.\"}" || true teardown-preview: if: github.event.action == 'closed' @@ -34,14 +33,16 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Teardown Preview + - name: Teardown run: python3 scripts/deploy-preview.py teardown ${{ github.event.pull_request.number }} - name: Comment PR + env: + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} run: | PR_ID="${{ github.event.pull_request.number }}" - curl -s -X POST \ + curl -sf -X POST \ "https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \ - -H "Authorization: token ${{ secrets.GIT_TOKEN }}" \ + -H "Authorization: token ${GIT_TOKEN}" \ -H "Content-Type: application/json" \ - -d "{\"body\": \"🗑️ Preview environment torn down.\"}" + -d "{\"body\": \"🗑️ Preview torn down.\"}" || true diff --git a/scripts/deploy-preview.py b/scripts/deploy-preview.py index ba3d4fc..1ddf296 100644 --- a/scripts/deploy-preview.py +++ b/scripts/deploy-preview.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 -"""Deploy or teardown a PR preview environment on oci k3s. +"""Deploy or teardown a PR preview environment on local k3s. + +Runs directly on the oci server (where k3s and docker are local). Usage: python3 scripts/deploy-preview.py deploy @@ -8,35 +10,48 @@ Usage: import subprocess import sys -import textwrap import json +import tempfile +import textwrap +from pathlib import Path -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) +def run(cmd: list[str] | str, *, check=True, capture=False) -> subprocess.CompletedProcess: + """Run a command, print it, and optionally check for errors.""" + if isinstance(cmd, str): + cmd = ["sh", "-c", cmd] + display = " ".join(cmd) if isinstance(cmd, list) else cmd + print(f" $ {display}") + r = subprocess.run(cmd, text=True, capture_output=capture) + if capture and r.stdout.strip(): + for line in r.stdout.strip().splitlines()[:5]: + print(f" {line}") if check and r.returncode != 0: - if capture: - print(r.stderr) - sys.exit(f"Command failed: {cmd}") + print(f" FAILED (exit {r.returncode})") + if capture and r.stderr.strip(): + print(f" {r.stderr.strip()[:200]}") + sys.exit(1) 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(*args, capture=False, check=True) -> subprocess.CompletedProcess: + return run(["sudo", "k3s", "kubectl", *args], capture=capture, check=check) -def kubectl(cmd, *, check=True, capture=False): - """Run kubectl on oci.""" - return ssh(f"sudo k3s kubectl {cmd}", check=check, capture=capture) +def docker(*args, check=True) -> subprocess.CompletedProcess: + return run(["docker", *args], check=check) + + +def write_temp(content: str, suffix=".yaml") -> Path: + """Write content to a temp file and return its path.""" + f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) + f.write(content) + f.close() + return Path(f.name) # ─── Deploy ────────────────────────────────────────────── @@ -45,35 +60,31 @@ 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" Deploying: https://{host}") + print(f" Namespace: {ns}") 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}/") + # 1. Copy production DB into build context + print("[1/5] Copying production database...") + Path("data").mkdir(exist_ok=True) + prod_pod = kubectl( + "get", "pods", "-n", PROD_NS, + "-l", "app=oil-calculator", + "--field-selector=status.phase=Running", + "-o", "jsonpath={.items[0].metadata.name}", + capture=True, check=False + ).stdout.strip() - # 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") + kubectl("cp", f"{PROD_NS}/{prod_pod}:/data/oil_calculator.db", "data/oil_calculator.db") else: print(" WARNING: No running prod pod, using empty DB") - ssh(f"touch {build_dir}/data/oil_calculator.db") + Path("data/oil_calculator.db").touch() - # 3. Write preview Dockerfile and build - print("[3/6] Building Docker image...") + # 2. Build and push image + print("[2/5] Building Docker image...") dockerfile = textwrap.dedent("""\ FROM node:20-slim AS frontend-build WORKDIR /build @@ -94,22 +105,27 @@ def deploy(pr_id: str): 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}") + df = write_temp(dockerfile, suffix=".Dockerfile") + docker("build", "-f", str(df), "-t", image, ".") + df.unlink() + 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 -") + # 3. Create namespace + regcred + print("[3/5] Creating namespace...") + kubectl("create", "namespace", ns, "--dry-run=client", "-o", "yaml", + check=False) # just for display + run(f"sudo k3s kubectl create namespace {ns} --dry-run=client -o yaml | sudo k3s kubectl apply -f -") - # 5. Apply K8s manifests - print("[5/6] Applying K8s resources...") + # Copy regcred from prod namespace + r = kubectl("get", "secret", "regcred", "-n", PROD_NS, "-o", "json", capture=True) + secret = json.loads(r.stdout) + secret["metadata"] = {"name": "regcred", "namespace": ns} + p = write_temp(json.dumps(secret), suffix=".json") + kubectl("apply", "-f", str(p)) + p.unlink() + + # 4. Apply manifests + print("[4/5] Applying K8s resources...") manifests = textwrap.dedent(f"""\ apiVersion: apps/v1 kind: Deployment @@ -177,14 +193,16 @@ def deploy(pr_id: str): port: number: 80 """) - ssh(f"cat << 'EOYAML' | sudo k3s kubectl apply -f -\n{manifests}EOYAML") + p = write_temp(manifests) + kubectl("apply", "-f", str(p)) + p.unlink() - # 6. Wait for rollout - print("[6/6] Waiting for rollout...") - kubectl(f"rollout status deploy/oil-calculator -n {ns} --timeout=120s") + # 5. Wait for rollout + print("[5/5] Waiting for rollout...") + kubectl("rollout", "status", f"deploy/oil-calculator", "-n", ns, "--timeout=120s") - # Cleanup build dir - ssh(f"rm -rf {build_dir}", check=False) + # Cleanup + run("rm -rf data/oil_calculator.db", check=False) print(f"\n{'='*60}") print(f" Preview live: https://{host}") @@ -197,24 +215,43 @@ 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") + print(f"\n Tearing down: {ns}") + kubectl("delete", "namespace", ns, "--ignore-not-found") + docker("rmi", image, check=False) + print(" Done.\n") + + +# ─── Deploy Production ─────────────────────────────────── + +def deploy_prod(): + image = f"{REGISTRY}/oil-calculator:latest" + + print(f"\n{'='*60}") + print(f" Deploying production: https://{BASE_DOMAIN}") + print(f"{'='*60}\n") + + docker("build", "-t", image, ".") + docker("push", image) + kubectl("rollout", "restart", "deploy/oil-calculator", "-n", PROD_NS) + kubectl("rollout", "status", "deploy/oil-calculator", "-n", PROD_NS, "--timeout=120s") + + print(f"\n Production deployed: https://{BASE_DOMAIN}\n") # ─── Main ──────────────────────────────────────────────── if __name__ == "__main__": - if len(sys.argv) < 3: + if len(sys.argv) < 2: 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) + action = sys.argv[1] + if action == "deploy" and len(sys.argv) >= 3: + deploy(sys.argv[2]) + elif action == "teardown" and len(sys.argv) >= 3: + teardown(sys.argv[2]) + elif action == "deploy-prod": + deploy_prod() else: - print(f"Unknown action: {action}") + print(__doc__) sys.exit(1)