From 2ee0c7c241d726d4205f7885dc0a86297105d7f3 Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Mon, 6 Apr 2026 21:10:29 +0000 Subject: [PATCH] Rewrite preview deploy as Python script - scripts/deploy-preview.py: deploy/teardown PR preview environments - rsync source to oci, copy prod DB, build image, apply K8s manifests - No PVC, DB baked into image - Simplified preview.yml workflow to call the Python script - Remove old shell deploy scripts Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/preview.yml | 159 ++----------------------- scripts/deploy-preview.py | 220 +++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 151 deletions(-) create mode 100644 scripts/deploy-preview.py diff --git a/.gitea/workflows/preview.yml b/.gitea/workflows/preview.yml index b607110..cd1a42c 100644 --- a/.gitea/workflows/preview.yml +++ b/.gitea/workflows/preview.yml @@ -4,7 +4,6 @@ on: types: [opened, synchronize, reopened, closed] env: - REGISTRY: registry.oci.euphon.net BASE_DOMAIN: oil.oci.euphon.net jobs: @@ -17,168 +16,26 @@ jobs: - name: Unit tests run: cd frontend && npm ci && npm run test:unit - - name: Deploy Preview Environment + - name: Deploy Preview + run: python3 scripts/deploy-preview.py deploy ${{ github.event.pull_request.number }} + + - name: Comment PR run: | PR_ID="${{ github.event.pull_request.number }}" - NS="oil-pr-${PR_ID}" - HOST="pr-${PR_ID}.${BASE_DOMAIN}" - IMAGE="${REGISTRY}/oil-calculator:pr-${PR_ID}" - - # Sync source to oci build server - rsync -az --exclude node_modules --exclude .git --exclude .venv \ - . oci:/tmp/oil-pr-${PR_ID}-build/ - - ssh oci bash -s "${PR_ID}" "${NS}" "${HOST}" "${IMAGE}" << 'DEPLOY_SCRIPT' - set -e - PR_ID="$1"; NS="$2"; HOST="$3"; IMAGE="$4" - - cd /tmp/oil-pr-${PR_ID}-build - - # Copy production DB into build context so it's baked into image - PROD_POD=$(sudo k3s kubectl get pods -n oil-calculator -l app=oil-calculator \ - --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") - if [ -n "$PROD_POD" ]; then - sudo k3s kubectl cp "oil-calculator/${PROD_POD}:/data/oil_calculator.db" /tmp/pr-${PR_ID}.db - mkdir -p data - cp /tmp/pr-${PR_ID}.db data/oil_calculator.db - fi - - # Build image (with DB baked in) - cat > Dockerfile.preview << 'DEOF' - 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/ - # Bake production DB copy into image - 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"] - DEOF - - docker build -f Dockerfile.preview -t "${IMAGE}" . - docker push "${IMAGE}" - - # Create namespace - sudo k3s kubectl create namespace "${NS}" --dry-run=client -o yaml | sudo k3s kubectl apply -f - - - # Copy regcred from production namespace - sudo k3s kubectl get secret regcred -n oil-calculator -o json | \ - sed "s/\"namespace\":\"oil-calculator\"/\"namespace\":\"${NS}\"/" | \ - sudo k3s kubectl apply -f - - - # Deploy pod + service + ingress (no PVC needed, DB is in image) - cat << EOYAML | sudo k3s kubectl apply -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 - EOYAML - - # Wait for rollout - sudo k3s kubectl rollout status deploy/oil-calculator -n "${NS}" --timeout=120s - - # Cleanup build dir - rm -rf /tmp/oil-pr-${PR_ID}-build /tmp/pr-${PR_ID}.db - - echo "Preview deployed: https://${HOST}" - DEPLOY_SCRIPT - - - name: Comment PR with preview URL - run: | - PR_ID="${{ github.event.pull_request.number }}" - HOST="pr-${PR_ID}.${BASE_DOMAIN}" curl -s -X POST \ "https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \ -H "Authorization: token ${{ secrets.GIT_TOKEN }}" \ -H "Content-Type: application/json" \ - -d "{\"body\": \"🚀 Preview deployed: https://${HOST}\n\nDB is a copy of production. Changes here won't affect prod.\"}" + -d "{\"body\": \"🚀 **Preview deployed**: https://pr-${PR_ID}.${BASE_DOMAIN}\n\nDB is a copy of production. Changes won't affect prod.\"}" teardown-preview: if: github.event.action == 'closed' runs-on: ubuntu-latest steps: - - name: Teardown Preview Environment - run: | - PR_ID="${{ github.event.pull_request.number }}" - NS="oil-pr-${PR_ID}" - IMAGE="${REGISTRY}/oil-calculator:pr-${PR_ID}" + - uses: actions/checkout@v4 - ssh oci " - sudo k3s kubectl delete namespace ${NS} --ignore-not-found - docker rmi ${IMAGE} 2>/dev/null || true - " + - name: Teardown Preview + run: python3 scripts/deploy-preview.py teardown ${{ github.event.pull_request.number }} - name: Comment PR run: | diff --git a/scripts/deploy-preview.py b/scripts/deploy-preview.py new file mode 100644 index 0000000..ba3d4fc --- /dev/null +++ b/scripts/deploy-preview.py @@ -0,0 +1,220 @@ +#!/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)