diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..18b54ce --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,23 @@ +name: Deploy Production +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - 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 + " diff --git a/.gitea/workflows/preview.yml b/.gitea/workflows/preview.yml new file mode 100644 index 0000000..822aae5 --- /dev/null +++ b/.gitea/workflows/preview.yml @@ -0,0 +1,190 @@ +name: PR Preview +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +env: + REGISTRY: registry.oci.euphon.net + BASE_DOMAIN: oil.oci.euphon.net + +jobs: + deploy-preview: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Unit tests + run: cd frontend && npm ci && npm run test:unit + + - name: Deploy Preview Environment + 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.GITEA_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.\"}" + + 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}" + + ssh oci " + sudo k3s kubectl delete namespace ${NS} --ignore-not-found + docker rmi ${IMAGE} 2>/dev/null || true + " + + - name: Comment PR + run: | + PR_ID="${{ github.event.pull_request.number }}" + curl -s -X POST \ + "https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \ + -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"🗑️ Preview environment torn down.\"}" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..2c0dd9c --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,17 @@ +name: Test +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: cd frontend && npm ci + + - name: Unit tests + run: cd frontend && npm run test:unit + + - name: Build check + run: cd frontend && npm run build diff --git a/scripts/setup-runner.sh b/scripts/setup-runner.sh new file mode 100644 index 0000000..b86d6ad --- /dev/null +++ b/scripts/setup-runner.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Setup Gitea Act Runner on this machine (host mode) +# Usage: bash scripts/setup-runner.sh + +set -e + +TOKEN="${1:?Usage: $0 }" +INSTANCE="https://git.euphon.cloud" +RUNNER_NAME="hera-runner" +RUNNER_BIN="$HOME/bin/act_runner" +VERSION="v0.2.11" + +echo "=== Installing act_runner ${VERSION} ===" +mkdir -p "$HOME/bin" +curl -L "https://gitea.com/gitea/act_runner/releases/download/${VERSION}/act_runner-${VERSION}-linux-amd64" \ + -o "$RUNNER_BIN" +chmod +x "$RUNNER_BIN" +echo "Installed: $($RUNNER_BIN --version)" + +echo "" +echo "=== Registering runner ===" +cd "$HOME" +$RUNNER_BIN register --no-interactive \ + --instance "$INSTANCE" \ + --token "$TOKEN" \ + --name "$RUNNER_NAME" \ + --labels "ubuntu-latest:host" + +echo "" +echo "=== Setting up systemd user service ===" +mkdir -p "$HOME/.config/systemd/user" +cat > "$HOME/.config/systemd/user/act-runner.service" << EOF +[Unit] +Description=Gitea Act Runner +After=network.target + +[Service] +Type=simple +WorkingDirectory=%h +ExecStart=%h/bin/act_runner daemon +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target +EOF + +systemctl --user daemon-reload +systemctl --user enable act-runner +systemctl --user start act-runner + +echo "" +echo "=== Done! ===" +systemctl --user status act-runner --no-pager | head -8 +echo "" +echo "Check Gitea → Settings → Actions → Runners to verify."