diff --git a/.gitea/workflows/deploy-verso-prod.yml b/.gitea/workflows/deploy-verso-prod.yml new file mode 100644 index 0000000000..ade9dc1964 --- /dev/null +++ b/.gitea/workflows/deploy-verso-prod.yml @@ -0,0 +1,444 @@ +name: Build and Deploy Verso (prod) + +# Production deploy. Triggered only by pushes to the `prod` branch — keep `main` +# for day-to-day work and fast-forward `prod` when a build is stable. +# +# Differences from the test deploy (deploy-verso.yml): +# - Runs in the `verso` namespace (test runs in `test`). +# - Mongo / Redis / app data live on PersistentVolumeClaims and are applied +# idempotently: this workflow NEVER deletes them, so data survives deploys. +# - The replica set is initialised only once. +# - Builds/pushes a distinct image tag (verso:stable) so prod and test never +# clobber each other's image. +# - SMTP comes from the `verso-smtp` Secret (create it with kubectl); email is +# optional so the app still boots before the secret exists. +# - Public self-registration stays off (CE default): friends-only, admin +# creates accounts / sends invites. +# +# Out of band (do once): create the `verso-smtp` Secret and a verso.alocoq.fr +# Ingress (see server-ce/k8s/verso-prod-ingress.example.yaml) + DNS. + +on: + push: + branches: + - prod + workflow_dispatch: + +env: + SITE_URL: https://verso.alocoq.fr + +jobs: + deploy: + runs-on: native + timeout-minutes: 240 + + steps: + - name: Build and push Verso prod image with BuildKit + run: | + kubectl -n ci delete job verso-buildkit-prod --ignore-not-found=true --wait=true + + cat <<'EOF' | kubectl apply -f - + apiVersion: batch/v1 + kind: Job + metadata: + name: verso-buildkit-prod + namespace: ci + spec: + backoffLimit: 0 + template: + spec: + restartPolicy: Never + initContainers: + - name: prepare + image: alpine/git:latest + command: ["sh", "-c"] + args: + - | + set -eux + REG=registry.git.svc.cluster.local:5000 + git clone --depth 1 --branch prod https://git.alocoq.fr/alois/verso.git /workspace/repo + + # Build the base image only when Dockerfile-base changes + # (content-hash tag); otherwise reuse the cached base. + BTAG=$(sha256sum /workspace/repo/server-ce/Dockerfile-base | cut -c1-16) + printf '%s' "$BTAG" > /workspace/base_tag + if wget -qO- "http://$REG/v2/verso-base/tags/list" 2>/dev/null | grep -q "\"base-$BTAG\""; then + echo "Base image base-$BTAG already present - skipping base build" + else + touch /workspace/build-base + echo "Base image base-$BTAG not found - base will be built" + fi + volumeMounts: + - name: workspace + mountPath: /workspace + + containers: + - name: buildkit + image: moby/buildkit:latest + securityContext: + privileged: true + command: ["sh", "-c"] + args: + - | + set -eux + + REG=registry.git.svc.cluster.local:5000 + + mkdir -p /etc/buildkit + printf '[registry."%s"]\n http = true\n insecure = true\n' "$REG" > /etc/buildkit/buildkitd.toml + + BTAG=$(cat /workspace/base_tag) + BASE_REF="$REG/verso-base:base-$BTAG" + + if [ -f /workspace/build-base ]; then + buildctl-daemonless.sh build \ + --frontend=dockerfile.v0 \ + --local context=/workspace/repo \ + --local dockerfile=/workspace/repo/server-ce \ + --opt filename=Dockerfile-base \ + --import-cache type=registry,ref=$REG/verso-cache:base \ + --export-cache type=registry,ref=$REG/verso-cache:base,mode=max \ + --output type=image,name=$BASE_REF,push=true,registry.insecure=true + else + echo "Reusing existing base image $BASE_REF" + fi + + # App image → verso:stable (prod tag). + buildctl-daemonless.sh build \ + --frontend=dockerfile.v0 \ + --local context=/workspace/repo \ + --local dockerfile=/workspace/repo/server-ce \ + --opt filename=Dockerfile \ + --opt build-arg:OVERLEAF_BASE_TAG=$BASE_REF \ + --import-cache type=registry,ref=$REG/verso-cache:app \ + --export-cache type=registry,ref=$REG/verso-cache:app,mode=max \ + --output type=image,name=$REG/verso:stable,push=true,registry.insecure=true + volumeMounts: + - name: workspace + mountPath: /workspace + + volumes: + - name: workspace + emptyDir: {} + EOF + + - name: Wait for build + run: | + kubectl -n ci wait --for=condition=complete job/verso-buildkit-prod --timeout=14400s + + - name: Show build logs + if: always() + run: | + kubectl -n ci logs job/verso-buildkit-prod -c prepare || true + kubectl -n ci logs job/verso-buildkit-prod -c buildkit || true + + - name: Ensure namespace + persistent data services (never deleted) + run: | + kubectl create namespace verso --dry-run=client -o yaml | kubectl apply -f - + + # PVCs + Mongo/Redis. Applied idempotently — this step must never + # delete these, so project data survives every deploy. + cat <<'EOF' | kubectl apply -f - + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: mongo-data + namespace: verso + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 10Gi + --- + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: redis-data + namespace: verso + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 2Gi + --- + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: verso-data + namespace: verso + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 20Gi + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: mongo + namespace: verso + spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: mongo + template: + metadata: + labels: + app: mongo + spec: + containers: + - name: mongo + image: mongo:8 + command: ["mongod", "--replSet", "rs0", "--bind_ip_all"] + ports: + - containerPort: 27017 + volumeMounts: + - name: mongo-data + mountPath: /data/db + volumes: + - name: mongo-data + persistentVolumeClaim: + claimName: mongo-data + --- + apiVersion: v1 + kind: Service + metadata: + name: mongo + namespace: verso + spec: + selector: + app: mongo + ports: + - name: mongo + port: 27017 + targetPort: 27017 + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: redis + namespace: verso + spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7 + # AOF persistence so a restart doesn't drop in-flight edits + # before they're flushed to Mongo. + command: ["redis-server", "--appendonly", "yes"] + ports: + - containerPort: 6379 + volumeMounts: + - name: redis-data + mountPath: /data + volumes: + - name: redis-data + persistentVolumeClaim: + claimName: redis-data + --- + apiVersion: v1 + kind: Service + metadata: + name: redis + namespace: verso + spec: + selector: + app: redis + ports: + - name: redis + port: 6379 + targetPort: 6379 + EOF + + kubectl -n verso rollout status deployment/mongo --timeout=300s + kubectl -n verso rollout status deployment/redis --timeout=300s + + - name: Initialise Mongo replica set (only if not already initialised) + run: | + kubectl -n verso exec deploy/mongo -- mongosh --quiet --eval ' + try { + rs.status() + print("replica set already initialised") + } catch (e) { + if (e.codeName === "NotYetInitialized" || /no replset config/i.test(e.message)) { + rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "mongo:27017" }] }) + print("replica set initiated") + } else { + throw e + } + } + ' + kubectl -n verso exec deploy/mongo -- mongosh --quiet --eval ' + while (rs.status().myState !== 1) { sleep(1000) } + print("Mongo replica set is PRIMARY") + ' + + - name: Ensure Verso deployment + service + run: | + # Stamp the instance name with this build number, e.g. "Verso V0.12 Alpha". + NAV_TITLE="Verso V0.${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}} Alpha" + cat <<'EOF' | sed "s|__NAV_TITLE__|${NAV_TITLE}|g" | kubectl apply -f - + apiVersion: apps/v1 + kind: Deployment + metadata: + name: verso + namespace: verso + spec: + replicas: 1 + # RWO data volume → can't run two pods at once; recreate on update. + strategy: + type: Recreate + selector: + matchLabels: + app: verso + template: + metadata: + labels: + app: verso + spec: + securityContext: + # App runs as www-data (uid/gid 33); make the data volume + # group-writable by it. + fsGroup: 33 + initContainers: + - name: init-data-perms + image: busybox:latest + command: ["sh", "-c"] + args: + - | + set -eux + mkdir -p /data/template_files /data/user_files \ + /data/compiles /data/cache /data/output /data/published + chown -R 33:33 /data + volumeMounts: + - name: verso-data + mountPath: /data + containers: + - name: verso + image: registry.alocoq.fr/verso:stable + # :stable is a fixed tag, so force a pull on every rollout to + # pick up the freshly built image. + imagePullPolicy: Always + ports: + - containerPort: 80 + env: + - name: OVERLEAF_MONGO_URL + value: mongodb://mongo:27017/sharelatex?replicaSet=rs0 + - name: OVERLEAF_REDIS_HOST + value: redis + - name: REDIS_HOST + value: redis + - name: OVERLEAF_APP_NAME + value: Verso + - name: OVERLEAF_NAV_TITLE + value: "__NAV_TITLE__" + - name: OVERLEAF_SITE_URL + value: https://verso.alocoq.fr + - name: OVERLEAF_SITE_LANGUAGE + value: fr + # Allow anonymous visitors so public published-presentation + # links and read-only share links work without login. + - name: OVERLEAF_ALLOW_PUBLIC_ACCESS + value: "true" + # NB: anonymous read-AND-write sharing is intentionally NOT + # enabled (compiles are unsandboxed → only trusted accounts + # may trigger them). Public self-registration is also off + # (CE default): admin creates accounts / sends invites. + - name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV + value: "true" + # SMTP for password-reset / invite emails. Values come from + # the 'verso-smtp' Secret; all optional so the app boots + # before the secret exists (email stays off until + # OVERLEAF_EMAIL_FROM_ADDRESS is present). + - name: OVERLEAF_EMAIL_FROM_ADDRESS + valueFrom: + secretKeyRef: + name: verso-smtp + key: from-address + optional: true + - name: OVERLEAF_EMAIL_SMTP_HOST + valueFrom: + secretKeyRef: + name: verso-smtp + key: smtp-host + optional: true + - name: OVERLEAF_EMAIL_SMTP_PORT + valueFrom: + secretKeyRef: + name: verso-smtp + key: smtp-port + optional: true + - name: OVERLEAF_EMAIL_SMTP_SECURE + valueFrom: + secretKeyRef: + name: verso-smtp + key: smtp-secure + optional: true + - name: OVERLEAF_EMAIL_SMTP_USER + valueFrom: + secretKeyRef: + name: verso-smtp + key: smtp-user + optional: true + - name: OVERLEAF_EMAIL_SMTP_PASS + valueFrom: + secretKeyRef: + name: verso-smtp + key: smtp-pass + optional: true + volumeMounts: + - name: verso-data + mountPath: /var/lib/overleaf/data + volumes: + - name: verso-data + persistentVolumeClaim: + claimName: verso-data + --- + apiVersion: v1 + kind: Service + metadata: + name: verso + namespace: verso + spec: + selector: + app: verso + ports: + - name: http + port: 80 + targetPort: 80 + EOF + + - name: Deploy Verso image + run: | + kubectl -n verso set image deployment/verso \ + verso=registry.alocoq.fr/verso:stable + kubectl -n verso rollout restart deployment/verso + kubectl -n verso rollout status deployment/verso --timeout=600s + + - name: Create initial admin (only if no users exist) + run: | + COUNT=$(kubectl -n verso exec deploy/mongo -- mongosh sharelatex --quiet --eval 'db.users.countDocuments()' | tr -d '[:space:]') + if [ "$COUNT" = "0" ]; then + echo "No users yet — creating the initial admin account" + kubectl -n verso exec deploy/verso -- bash -lc ' + cd /overleaf/services/web + node modules/server-ce-scripts/scripts/create-user \ + --admin \ + --email=alois.coquillard@gmail.com + ' + else + echo "Users already exist ($COUNT) — skipping admin creation" + fi diff --git a/server-ce/k8s/verso-prod-ingress.example.yaml b/server-ce/k8s/verso-prod-ingress.example.yaml new file mode 100644 index 0000000000..6d201c49f9 --- /dev/null +++ b/server-ce/k8s/verso-prod-ingress.example.yaml @@ -0,0 +1,44 @@ +# Example Ingress for the prod (verso namespace) instance at verso.alocoq.fr. +# +# This is NOT applied by the deploy workflow on purpose: the test ingress is +# managed by hand, and TLS/annotations depend on your cluster's ingress +# controller (Traefik) and cert setup. Copy this, adapt it to match how +# test.alocoq.fr is wired, then `kubectl apply -f` it once. +# +# Prerequisites: +# - DNS: verso.alocoq.fr → your ingress/load-balancer IP. +# - A TLS cert for verso.alocoq.fr (cert-manager, or a manually created +# Secret referenced under tls.secretName). +# +# Adjust: +# - ingressClassName (e.g. "traefik") to match your controller. +# - annotations (cert-manager issuer, Traefik entrypoints/router, etc.) to +# match the test ingress. +# - the TLS block (cert-manager will create the secret; otherwise create it). + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: verso + namespace: verso + annotations: + # --- adapt these to match your test.alocoq.fr ingress --- + # cert-manager.io/cluster-issuer: letsencrypt-prod + # traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + tls: + - hosts: + - verso.alocoq.fr + secretName: verso-tls + rules: + - host: verso.alocoq.fr + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: verso + port: + number: 80