diff --git a/.github/workflows/helm-test.yml b/.github/workflows/helm-test.yml index 5a6034835d..fb3a2ed37d 100644 --- a/.github/workflows/helm-test.yml +++ b/.github/workflows/helm-test.yml @@ -156,3 +156,298 @@ jobs: --set database.url=postgresql://ci:ci@postgres.example.com:5432/studio \ -f deploy/helm/studio/examples/values-clickhouse.yaml \ > /dev/null + + # values-preview.yaml is consumed by the studio-previews ApplicationSet, + # which supplies the deployment-specific half. Render it here the same way + # that chart does, and assert the properties previews depend on — not just + # that it renders. + - name: Render (per-PR preview) + run: | + set -euo pipefail + helm template studio-pr-42 deploy/helm/studio \ + -f deploy/helm/studio/values-preview.yaml \ + --set preview.prNumber=42 \ + --set preview.host=pr-42.pr.studio.decocms.com \ + --set image.repository=ghcr.io/decocms/studio/studio-preview \ + --set image.tag=pr-42-abc1234 \ + --set nginx.image.tag=pr-42-abc1234 \ + --set externalSecret.secretPath=preview/studio/application \ + --set externalSecret.secretStoreName=aws-secrets-manager \ + --set externalSecret.secretStoreKind=ClusterSecretStore \ + --api-versions gateway.networking.k8s.io/v1 \ + > /tmp/preview.yaml + + fail() { echo "::error::$1"; exit 1; } + + grep -q 'kind: HTTPRoute' /tmp/preview.yaml \ + || fail "preview render has no HTTPRoute — the preview would be unreachable" + grep -q 'pr-42.pr.studio.decocms.com' /tmp/preview.yaml \ + || fail "preview HTTPRoute is missing its hostname" + + grep -q "studio-pr-42-preview-migrate" /tmp/preview.yaml \ + || fail "preview render is missing the migration Job" + grep -q "studio-pr-42-postgres" /tmp/preview.yaml \ + || fail "preview render has no Postgres — the DB must live and die with the namespace" + + grep -q "studio-pr-42-minio" /tmp/preview.yaml \ + || fail "preview render has no MinIO — object storage must live and die with the namespace" + # All six together, or the application downloads and runs its own + # MinIO binary at boot: a fresh ~100MB pull on every pod start, on a + # spot pool that consolidates after a minute. + for k in S3_ENDPOINT S3_BUCKET S3_REGION S3_FORCE_PATH_STYLE \ + S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do + grep -q " ${k}:" /tmp/preview.yaml \ + || fail "preview render is missing ${k} — a partial S3 config makes the app self-provision MinIO" + done + grep -q 'S3_ENDPOINT: "http://studio-pr-42-minio:9000"' /tmp/preview.yaml \ + || fail "S3_ENDPOINT does not point at this preview's own MinIO" + + # Ordering is the whole correctness argument: Postgres must be healthy + # before the migration Job runs, and that Job must finish before the + # app Deployments start. Argo expresses that with waves, and a wrong + # one shows up as a CrashLoopBackOff on first sync, not a render error. + python3 - <<'WAVES' + import sys, yaml + waves = {} + for d in yaml.safe_load_all(open("/tmp/preview.yaml")): + if not d: + continue + name = d["metadata"]["name"] + ann = (d["metadata"].get("annotations") or {}) + w = int(ann.get("argocd.argoproj.io/sync-wave", 0)) + waves[(d["kind"], name)] = w + pg = waves[("Deployment", "studio-pr-42-postgres")] + mig = waves[("Job", "studio-pr-42-preview-migrate")] + app = waves[("Deployment", "studio-pr-42")] + ok = pg < mig < app + print(f"postgres={pg} migrate={mig} app={app} ordered={ok}") + if not ok: + print("::error::preview sync-waves are out of order " + f"(want postgres < migrate < app, got {pg} < {mig} < {app})") + sys.exit(0 if ok else 1) + WAVES + + # Previews run un-merged code on a dedicated spot-only node pool. Both + # halves must reach EVERY pod: the toleration alone lets a preview onto + # the pool without keeping it there, and the selector alone strands it + # Pending. A subchart (NATS) inherits neither, so it is the one that + # silently lands on shared nodes when this regresses. + python3 - <<'POOL' + import sys, yaml + bad = [] + for d in yaml.safe_load_all(open("/tmp/preview.yaml")): + if not d or d.get("kind") not in ("Deployment", "StatefulSet", "Job"): + continue + spec = d["spec"]["template"]["spec"] + sel = (spec.get("nodeSelector") or {}).get("decocms.com/nodepool") + tol = any(t.get("key") == "decocms.com/studio-preview" + for t in (spec.get("tolerations") or [])) + if sel != "studio-preview" or not tol: + bad.append(f'{d["kind"]}/{d["metadata"]["name"]} ' + f'(selector={sel!r}, toleration={tol})') + if bad: + print("::error::pods not pinned to the preview node pool: " + "; ".join(bad)) + sys.exit(1) + print("every preview pod is pinned to the preview node pool") + POOL + + # Every studio container (api-0, api-1, worker) must skip migrations: + # the PreSync Job is the single writer. Three, not two. + # `|| true` — grep exits 1 on zero matches, which under `set -e` would + # abort before the explanatory message below. + count=$(grep -c -- '--skip-migrations' /tmp/preview.yaml || true) + [ "$count" -eq 3 ] \ + || fail "expected 3 containers with --skip-migrations, found ${count}" + + grep -q 'DATABASE_POOL_MAX: "5"' /tmp/preview.yaml \ + || fail "DATABASE_POOL_MAX is not 5" + + # This repository is public and the chart is published. Nothing that + # names one deployment may live in the shared values file. + # Domains are matched only where they are a HOSTNAME — i.e. not + # followed by `/`. Kubernetes label and taint keys are domain-prefixed + # by convention (decocms.com/nodepool), and those are structure, not + # identity: they carry no account, bucket or endpoint. + # + # `if grep` and not `grep && fail`: under `set -e` a bare grep that + # matches nothing exits 1 and aborts the step — which is the passing + # case here. + for leak in 'cloudflarestorage\.com([^/]|$)' 'decocms\.com([^/]|$)' \ + 'deco\.cx([^/]|$)' 'amazonaws\.com([^/]|$)' \ + 'deco-studio-storage' 'aws-secrets-manager'; do + if grep -qE "$leak" deploy/helm/studio/values-preview.yaml; then + echo "--- offending lines ---" + grep -nE "$leak" deploy/helm/studio/values-preview.yaml + fail "values-preview.yaml names a specific deployment (/${leak}/) — that belongs in the studio-previews values, which are not published" + fi + done + echo "values-preview.yaml carries no deployment-specific identifiers" + + # DATABASE_URL must address this preview's OWN Postgres. If it ever + # points somewhere else, a preview is writing to a database it does + # not own. + grep -q 'DATABASE_URL: "postgresql://postgres:postgres@studio-pr-42-postgres:5432/postgres"' /tmp/preview.yaml \ + || fail "preview DATABASE_URL does not point at this preview's own in-namespace Postgres" + + # A namespaced SecretStore needs per-namespace IRSA, which per-PR + # namespaces do not have. + grep -q 'kind: ClusterSecretStore' /tmp/preview.yaml \ + || fail "preview ExternalSecret does not reference a ClusterSecretStore" + if grep -qE '^kind: SecretStore$' /tmp/preview.yaml; then + fail "preview render creates a namespaced SecretStore" + fi + + # PVC finalizers are the most common cause of a namespace that never + # finishes deleting. + if grep -qE '^kind: PersistentVolumeClaim' /tmp/preview.yaml; then + fail "preview render contains a PersistentVolumeClaim" + fi + + # Shell scripts embedded in the hook Jobs — a bad heredoc indent here + # only surfaces at sync time otherwise. + python3 -c 'import yaml' 2>/dev/null || pip install --quiet pyyaml + python3 - <<'PY' + import subprocess, sys, yaml + bad = 0 + for d in yaml.safe_load_all(open("/tmp/preview.yaml")): + if not d or d.get("kind") != "Job": + continue + spec = d["spec"]["template"]["spec"] + for c in (spec.get("initContainers") or []) + spec["containers"]: + if not c.get("args"): + continue + p = subprocess.run(["sh", "-n"], input=c["args"][0], + capture_output=True, text=True) + if p.returncode != 0: + print(f"::error::{d['metadata']['name']}/{c['name']}: {p.stderr.strip()}") + bad += 1 + sys.exit(1 if bad else 0) + PY + + # The whole reason the ApplicationSet lives in this repository: the tag the + # build publishes and the tag the generator asks for are now checkable + # against each other. When they were in two repositories, they silently + # disagreed — the workflow tagged the ephemeral refs/pull/N/merge commit + # while the generator asked for .head_sha, and every preview would have + # sat in ImagePullBackOff with both repos' CI green. + - name: Preview build and ApplicationSet agree on tag and host + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + + BUILD=.github/workflows/preview-build.yaml + CHART=deploy/helm/studio-previews + + grep -q 'github.event.pull_request.head.sha' "$BUILD" \ + || fail "preview-build.yaml must tag from the PR head sha; the ApplicationSet can only name .head_sha" + if grep -q 'rev-parse --short=7 HEAD' "$BUILD"; then + fail "preview-build.yaml tags from the merge ref — the ApplicationSet has no parameter for that commit" + fi + grep -q 'head_sha' "$CHART/templates/_helpers.tpl" \ + || fail "the ApplicationSet image tag must derive from .head_sha" + + # Same host on both sides, or the bot comment links somewhere that does + # not exist while the environment serves somewhere else. Compare the + # host the workflow advertises against the one the chart renders, both + # for the same PR number — not just that each is non-empty. + BUILD_DOMAIN=$(awk -F': ' '/^ PREVIEW_DOMAIN:/{print $2}' "$BUILD") + test -n "$BUILD_DOMAIN" || fail "PREVIEW_DOMAIN is unset in preview-build.yaml" + # `host=pr-.${PREVIEW_DOMAIN}` in the workflow's meta step. + BUILD_HOST="pr-42.${BUILD_DOMAIN}" + + # Every value the chart requires, in ONE place. It ships no + # deployment-specific defaults by design, so each new guard adds + # another mandatory value — keeping the list here means the render + # steps stay correct when that happens, instead of failing with a + # message about a value the test never meant to exercise. + PREVIEWS_VALUES=( + --set applicationSet.repo.owner=o + --set applicationSet.repo.name=r + --set applicationSet.repo.tokenSecret.name=t + --set applicationSet.repo.tokenSecret.externalSecret.secretPath=p + --set applicationSet.repo.tokenSecret.externalSecret.secretStoreName=s + --set studioChart.repoURL=oci://x + --set studioChart.version=0.0.0 + --set studioChart.valuesRepo.url=https://x + --set images.api=a + --set images.nginx=n + --set studioValues.externalSecret.secretPath=s + --set studioValues.externalSecret.secretStoreName=s + ) + + helm template p "$CHART" \ + --set domain="$BUILD_DOMAIN" \ + "${PREVIEWS_VALUES[@]}" \ + --api-versions gateway.networking.k8s.io/v1 > /tmp/appset.yaml + + # The rendered host still carries the generator's own template, so + # substitute a PR number the way the controller would. + RENDER_HOST=$(grep -oE "host: '[^']+'" /tmp/appset.yaml | head -1 \ + | sed -E "s/host: '(.*)'/\1/; s/\{\{ \.number \}\}/42/") + [ "$RENDER_HOST" = "$BUILD_HOST" ] \ + || fail "host mismatch: preview-build advertises ${BUILD_HOST}, the ApplicationSet serves ${RENDER_HOST}" + echo "both halves agree on ${BUILD_HOST}" + + # The previews chart must refuse to render without the values that name a + # deployment — it is published publicly and must carry no defaults. + - name: Render (studio-previews requires explicit configuration) + run: | + set -euo pipefail + if helm template p deploy/helm/studio-previews >/tmp/bare.yaml 2>&1; then + echo "::error::studio-previews rendered with no values — it must require them" + exit 1 + fi + grep -q 'domain is required' /tmp/bare.yaml \ + || { echo "::error::wrong failure message"; cat /tmp/bare.yaml; exit 1; } + echo "studio-previews correctly refuses to render unconfigured" + - name: Render (preview validations must fail) + run: | + set -uo pipefail + expect_fail() { + desc="$1"; want="$2"; shift 2 + # `if cmd; then` and not `out=$(cmd); [ $? -eq 0 ]` — the runner's + # default shell is `bash -e`, under which a failing assignment aborts + # the step. Every render here is SUPPOSED to fail. + # secretPath/secretStoreName are supplied by the studio-previews + # ApplicationSet, not by values-preview.yaml — they name an account, + # and this file is published. Without them here, validateExternalSecret + # fires before validatePreview and every case below fails for the + # wrong reason. + if out=$(helm template studio-pr-42 deploy/helm/studio \ + -f deploy/helm/studio/values-preview.yaml \ + --set preview.prNumber=42 \ + --set externalSecret.secretPath=preview/studio/application \ + --set externalSecret.secretStoreName=some-cluster-store \ + --api-versions gateway.networking.k8s.io/v1 "$@" 2>&1); then + echo "::error::${desc}: rendered successfully — the guard is missing" + exit 1 + fi + if ! echo "$out" | grep -q "$want"; then + echo "::error::${desc}: failed for the wrong reason. Wanted '${want}', got:" + echo "$out" + exit 1 + fi + echo "ok: ${desc}" + } + + expect_fail "missing preview.host" "preview.host is required" + expect_fail "ingress conflict" "mutually exclusive" \ + --set preview.host=h.example.com \ + --set ingress.enabled=true \ + --set 'ingress.hosts[0].host=h.example.com' \ + --set 'ingress.hosts[0].paths[0].path=/' \ + --set 'ingress.hosts[0].paths[0].pathType=Prefix' + expect_fail "missing gateway name" "preview.gateway.name is required" \ + --set preview.host=h.example.com --set preview.gateway.name= + # Turning off the built-in Postgres without supplying a database + # elsewhere would render pods pointing at nothing. Caught by the + # chart's general database validation, not a preview-specific one. + expect_fail "no database at all" "set database.url when database.engine=postgresql" \ + --set preview.host=h.example.com \ + --set preview.postgres.enabled=false \ + --set externalSecret.enabled=false + + # image.command is a list, which --set cannot express cleanly. + printf 'image:\n command: ["bun","run","deco"]\n' > /tmp/no-skip.yaml + expect_fail "missing --skip-migrations" "requires --skip-migrations" \ + --set preview.host=h.example.com -f /tmp/no-skip.yaml diff --git a/.github/workflows/preview-build.yaml b/.github/workflows/preview-build.yaml new file mode 100644 index 0000000000..ed67d40288 --- /dev/null +++ b/.github/workflows/preview-build.yaml @@ -0,0 +1,212 @@ +name: Preview build + +# Builds the two images for a per-PR preview environment and posts (or edits) +# the sticky PR comment carrying the URL. +# +# Deliberately a separate workflow rather than a workflow_call refactor of +# release-studio.yaml: that file is release-consistency logic (npm version +# probing, GHCR existence checks, provenance, the deco-apps-cd dispatch gate, +# the docs webhook), and parameterising it would thread ~8 booleans through +# every `if:` in the PRODUCTION release path. The genuinely shared surface is +# one command (`bun run build:studio`) and two Dockerfiles, already reusable +# as-is — so previews cannot destabilise shipping. +# +# What deploys the images is Argo CD, in decocms/deco-apps-cd: an ApplicationSet +# with a GitHub PR generator, also gated on the `preview` label. Nothing here +# holds a cluster credential. + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + +concurrency: + group: preview-build-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + # Separate GHCR packages from the release ones, so the preview GC can be + # aggressive with zero chance of deleting a release tag. + IMAGE_NAME: ${{ github.repository }}/studio-preview + NGINX_IMAGE_NAME: ${{ github.repository }}/studio-nginx-preview + PREVIEW_DOMAIN: pr.studio.decocms.com + +jobs: + build: + name: Build preview images + # Opt-in. `labeled` is in the trigger list so adding the label to an + # already-open PR starts a build; the ApplicationSet enforces the same + # label independently, so neither half alone can create an orphan. + if: contains(github.event.pull_request.labels.*.name, 'preview') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + pull-requests: write + steps: + # This workflow triggers on `pull_request` ONLY, so the checkout is + # refs/pull/N/merge — GitHub constructs it by merging the PR into main, + # meaning the image always contains main ∪ PR migrations. That invariant + # is load-bearing (Kysely's strict missing-migration check hard-fails a + # boot against a database that knows a migration the image lacks) but it + # holds by construction, so there is nothing to assert. If a + # workflow_dispatch trigger is ever added, it breaks and must be + # re-established — a shallow checkout cannot prove ancestry, so that + # would need `fetch-depth: 0`. + # + # Note the split between CONTENT and NAME: the image content comes from + # the merge ref, but the tag names the PR head sha, because that is the + # only commit both this workflow and the Argo pullRequest generator can + # refer to. + - uses: actions/checkout@v4 + + - id: meta + run: | + # The 7-char prefix of the PR HEAD sha, NOT `git rev-parse HEAD`. + # The checkout below lands on refs/pull/N/merge, whose HEAD is an + # ephemeral merge commit that the Argo pullRequest generator has no + # parameter for — it can only name `.head_sha`. Tagging from the merge + # commit produces an image the ApplicationSet can never ask for, and + # every preview sits in ImagePullBackOff. Asserted in helm-test.yml. + echo "tag=pr-${{ github.event.pull_request.number }}-$(echo '${{ github.event.pull_request.head.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT" + echo "host=pr-${{ github.event.pull_request.number }}.${PREVIEW_DOMAIN}" >> "$GITHUB_OUTPUT" + + # The hidden marker is what makes this comment sticky: find it first, then + # edit in place. Without the lookup every push would append a new comment. + - name: Find existing preview comment + id: find-comment + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: github-actions[bot] + body-includes: + + # Posted before the build so a reviewer sees "building" at t+15s rather + # than nothing for seven minutes. Edited in place when the images land. + - name: Comment (building) + uses: peter-evans/create-or-update-comment@v4 + continue-on-error: true + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + + 🔨 **Building preview** for `${{ steps.meta.outputs.tag }}`… + + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun and install dependencies + uses: ./.github/actions/setup-bun + + # No sourcemaps (nothing un-minifies preview stacks), no npm publish, no + # provenance, no tarball smoke test. Same `build:studio` and same + # `bun add /tmp/decocms.tgz` install as a release, so preview packaging + # cannot drift from production packaging. + - name: Build combined Studio distribution + run: bun run build:studio + env: + BUILD_SOURCEMAPS: "0" + + - name: Strip sourcemaps and pack + run: | + find apps/web/dist apps/api/dist -name '*.map' -delete 2>/dev/null || true + cd apps/api && npm pack + + - name: Stage the tarball for both build contexts + run: | + mkdir -p docker-context + mv apps/api/decocms-*.tgz docker-context/decocms.tgz + # apps/web/Dockerfile builds from the repo root because it also COPYs + # deploy/helm/studio/files/api-nginx.conf. + cp docker-context/decocms.tgz decocms.tgz + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@v3 + + # amd64 only — the preview cluster is amd64, and the release workflow's + # own comments record that emulated arm64 costs ~64s vs ~13s native for + # the tarball-install layer. `cache-from` also reads the release scopes so + # the apt / useradd / duckdb layers are hits on a cold preview. + - name: Build and push API image + uses: docker/build-push-action@v5 + with: + context: ./docker-context + file: ./apps/api/Dockerfile + platforms: linux/amd64 + push: true + provenance: false + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }} + cache-from: | + type=gha,scope=studio-preview-amd64 + type=gha,scope=studio-api-amd64 + cache-to: type=gha,mode=max,scope=studio-preview-amd64 + + - name: Build and push nginx image + uses: docker/build-push-action@v5 + with: + context: . + file: ./apps/web/Dockerfile + platforms: linux/amd64 + push: true + provenance: false + tags: ${{ env.REGISTRY }}/${{ env.NGINX_IMAGE_NAME }}:${{ steps.meta.outputs.tag }} + cache-from: | + type=gha,scope=studio-preview-web-amd64 + type=gha,scope=studio-web-amd64 + cache-to: type=gha,mode=max,scope=studio-preview-web-amd64 + + # Re-find: on the first run the "building" step created the comment, so + # the id from before the build is empty. + - name: Re-find preview comment + id: find-comment-ready + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: github-actions[bot] + body-includes: + + - name: Comment (ready) + uses: peter-evans/create-or-update-comment@v4 + continue-on-error: true + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment-ready.outputs.comment-id }} + edit-mode: replace + body: | + + ### 🔍 Preview: https://${{ steps.meta.outputs.host }} + + Built from `${{ steps.meta.outputs.tag }}`. Argo CD picks the image up + within ~60s; the first sync also creates and migrates the database, so + allow another minute or two on a brand-new preview. + + **Sign in:** the database is empty and yours — sign up with any email + and password. It is thrown away when this PR closes. + +
What does not work in a preview + + - **Agent tool execution against a hosted sandbox** — previews run + `STUDIO_SANDBOX_PROVIDER=user-desktop` with no daemon attached, so + dispatch returns `409 link_offline`. Sandbox previews and the + sandbox lifecycle UI are equally out. + - **AI features** until you add your own provider key in org settings. + Previews ship no key, so preview LLM spend is zero by construction. + - **Google / GitHub sign-in** — OAuth callbacks cannot be registered + for a per-PR hostname. The buttons are hidden. + - **Monitoring dashboard** (no ClickHouse), **billing** (no Stripe), + **outbound email** (no mail provider). + - **Multi-pod behaviour** — a preview is one pod. Do not conclude + "it worked in preview" about a distributed-systems change; that is + what `tests/multi-pod/` is for. + +
+ + Remove the `preview` label to tear this down now. Previews expire + **48h after their last deploy** — push, or re-add the label, to + reset the clock. diff --git a/.github/workflows/preview-gc.yaml b/.github/workflows/preview-gc.yaml new file mode 100644 index 0000000000..eb01fdfd1d --- /dev/null +++ b/.github/workflows/preview-gc.yaml @@ -0,0 +1,113 @@ +name: Preview GC + +# GHCR has no TTL, so preview image tags accumulate forever without this. +# Deletes every `pr--` tag whose pull request is closed, then applies a +# retention floor to whatever survives. +# +# Scoped to the *-preview packages only. Release tags live in different +# packages (studio/studio, studio/studio-nginx) and are never reachable from +# here, which is the whole reason previews got their own packages. + +on: + schedule: + - cron: "17 4 * * *" + workflow_dispatch: + +permissions: + contents: read + packages: write + pull-requests: read + +jobs: + gc: + name: Prune preview image tags + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: [studio/studio-preview, studio/studio-nginx-preview] + steps: + - name: Delete tags for closed PRs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ORG: ${{ github.repository_owner }} + PACKAGE: ${{ matrix.package }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # %2F — the package name contains a slash and must be path-escaped. + ENCODED=$(printf '%s' "$PACKAGE" | sed 's#/#%2F#g') + + if ! gh api "orgs/${ORG}/packages/container/${ENCODED}/versions?per_page=100" \ + --paginate > /tmp/versions.json 2>/tmp/api-err; then + # A package that has never been pushed 404s. That is the expected + # state before the first preview, not a failure. + if grep -q "Not Found" /tmp/api-err; then + echo "Package ${PACKAGE} does not exist yet — nothing to prune." + exit 0 + fi + # The org-level packages API is frequently out of reach for + # GITHUB_TOKEN. Warn loudly and exit clean: a janitor that pages + # nightly gets muted, and a silent skip would read as "pruned". + if grep -q "read:packages scope\|Forbidden\|403" /tmp/api-err; then + echo "::warning::Cannot list ${PACKAGE}: GITHUB_TOKEN lacks org package read." + echo "::warning::Preview image tags are NOT being pruned. Grant a PAT with" + echo "::warning::read:packages + delete:packages as secrets.PREVIEW_GC_TOKEN" + echo "::warning::and set GH_TOKEN to it, or prune manually." + exit 0 + fi + cat /tmp/api-err >&2 + exit 1 + fi + + jq -r '.[] | . as $v | ($v.metadata.container.tags // [])[] | "\($v.id) \(.)"' \ + /tmp/versions.json > /tmp/tags.txt + + # Resolve each PR's state ONCE, not once per tag: a PR routinely has + # a dozen tags and the API is rate-limited. + awk '{ n = $2; sub(/^pr-/, "", n); sub(/-.*$/, "", n); + if (n ~ /^[0-9]+$/) print n }' /tmp/tags.txt | sort -u > /tmp/prs.txt + + : > /tmp/closed.txt + while read -r pr_number; do + [ -n "$pr_number" ] || continue + state=$(gh pr view "$pr_number" --repo "$REPO" --json state --jq .state 2>/dev/null || echo UNKNOWN) + echo "PR #${pr_number}: ${state}" + # UNKNOWN (deleted or inaccessible PR) is deliberately kept — this + # job must never be the thing that removes an image someone is + # still looking at. + if [ "$state" = "CLOSED" ] || [ "$state" = "MERGED" ]; then + echo "$pr_number" >> /tmp/closed.txt + fi + done < /tmp/prs.txt + + deleted=0 + while read -r version_id tag; do + [ -n "$tag" ] || continue + case "$tag" in pr-*) ;; *) continue ;; esac + pr_number="${tag#pr-}" + pr_number="${pr_number%%-*}" + case "$pr_number" in ''|*[!0-9]*) echo "skip malformed tag: $tag"; continue ;; esac + + if grep -qx "$pr_number" /tmp/closed.txt; then + echo "deleting ${PACKAGE}:${tag} (PR #${pr_number})" + gh api --method DELETE \ + "orgs/${ORG}/packages/container/${ENCODED}/versions/${version_id}" || true + deleted=$((deleted + 1)) + fi + done < /tmp/tags.txt + + echo "deleted ${deleted} tag(s) from ${PACKAGE}" + + # Backstop for untagged layers orphaned by tag deletion and for tags whose + # PR lookup kept failing. The floor is generous on purpose: this job is + # about bounding growth, not reclaiming every byte. + - name: Retention floor + uses: actions/delete-package-versions@v5 + continue-on-error: true + with: + owner: ${{ github.repository_owner }} + package-name: ${{ matrix.package }} + package-type: container + min-versions-to-keep: 50 + delete-only-untagged-versions: true diff --git a/.github/workflows/preview-ttl.yaml b/.github/workflows/preview-ttl.yaml new file mode 100644 index 0000000000..f4478c4e98 --- /dev/null +++ b/.github/workflows/preview-ttl.yaml @@ -0,0 +1,121 @@ +name: Preview TTL + +# Expires previews 48h after their last deploy, by removing the `preview` +# label. The Argo ApplicationSet in decocms/deco-apps-cd generates Applications +# from labelled open PRs, so dropping the label makes the preview disappear on +# the next poll — namespace, Postgres pod and all. +# +# Removing the LABEL rather than deleting the Application directly is the whole +# trick: a directly-deleted Application is regenerated within ~60s, because the +# generator's source of truth is the GitHub API, not the cluster. +# +# Long-lived PRs accumulating forgotten previews is the failure mode this +# exists for. Pushing to the PR, or re-adding the label, resets the clock. + +on: + schedule: + # Every 2h. A coarser cadence would make "48h" mean "up to 54h" and the + # job costs seconds. + - cron: "23 */2 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "List what would expire without removing any labels" + type: boolean + default: false + +permissions: + contents: read + pull-requests: write + +jobs: + expire: + name: Expire stale previews + runs-on: ubuntu-latest + steps: + - name: Expire previews older than the TTL + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TTL_HOURS: "48" + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + run: | + set -euo pipefail + + gh pr list --repo "$REPO" --label preview --state open \ + --json number --jq '.[].number' > /tmp/prs.txt + + if [ ! -s /tmp/prs.txt ]; then + echo "No open PRs carry the preview label." + exit 0 + fi + + now=$(date -u +%s) + ttl_seconds=$(( TTL_HOURS * 3600 )) + expired=0 + kept=0 + + while read -r pr; do + [ -n "$pr" ] || continue + + # Age is measured from the sticky preview comment, which + # preview-build.yaml rewrites on every successful deploy. That is + # the only signal that means "when was this preview last actually + # built": the PR's updatedAt moves on any comment, and the head + # commit date would instantly expire a months-old PR that someone + # labelled thirty seconds ago to review it. + deployed_at=$(gh api "repos/${REPO}/issues/${pr}/comments" --paginate \ + --jq '[.[] | select(.body | contains("")) | .updated_at] | last // empty') + + if [ -z "$deployed_at" ]; then + # No comment means we cannot date the preview. Never reap on + # ambiguity — the same rule the image GC follows for PRs it + # cannot resolve. + echo "PR #${pr}: no preview comment, cannot determine age — keeping" + kept=$((kept + 1)) + continue + fi + + deployed_ts=$(date -u -d "$deployed_at" +%s) + age_h=$(( (now - deployed_ts) / 3600 )) + + if [ "$(( now - deployed_ts ))" -lt "$ttl_seconds" ]; then + echo "PR #${pr}: deployed ${age_h}h ago — keeping" + kept=$((kept + 1)) + continue + fi + + echo "PR #${pr}: deployed ${age_h}h ago — EXPIRED (ttl ${TTL_HOURS}h)" + expired=$((expired + 1)) + if [ "$DRY_RUN" = "true" ]; then + continue + fi + + gh pr edit "$pr" --repo "$REPO" --remove-label preview + + # Rewrite the sticky comment in place. Leaving the old one up would + # advertise a URL that now 404s, which is exactly the confusion + # this feature is supposed to avoid. + comment_id=$(gh api "repos/${REPO}/issues/${pr}/comments" --paginate \ + --jq '[.[] | select(.body | contains("")) | .id] | last // empty') + if [ -n "$comment_id" ]; then + # Heredoc rather than an inline string: the body contains + # backticks, which inside a command substitution would be read as + # one. + cat > /tmp/expired.md < + 🌙 **Preview expired** — it had not been redeployed in ${TTL_HOURS}h, so it + was torn down to stop idle previews piling up on long-lived PRs. + + Add the \`preview\` label again to rebuild it. + EOF + gh api --method PATCH "repos/${REPO}/issues/comments/${comment_id}" \ + -F body=@/tmp/expired.md > /dev/null + fi + done < /tmp/prs.txt + + echo "---" + echo "expired=${expired} kept=${kept} dry_run=${DRY_RUN}" + if [ "$expired" -gt 0 ] && [ "$DRY_RUN" != "true" ]; then + echo "::notice::Expired ${expired} preview(s) after ${TTL_HOURS}h." + fi diff --git a/.github/workflows/release-sandbox-charts.yaml b/.github/workflows/release-sandbox-charts.yaml index 608d3888fb..cdbb807b8b 100644 --- a/.github/workflows/release-sandbox-charts.yaml +++ b/.github/workflows/release-sandbox-charts.yaml @@ -6,6 +6,7 @@ on: paths: - "deploy/helm/sandbox-operator/**" - "deploy/helm/sandbox-env/**" + - "deploy/helm/studio-previews/**" workflow_dispatch: env: @@ -20,12 +21,14 @@ jobs: release: name: Package & push ${{ matrix.chart }} runs-on: ubuntu-latest - # Run sandbox-operator and sandbox-env in parallel — independent OCI - # tags, no shared mutable state. + # Run every auxiliary chart in parallel — independent OCI tags, no shared + # mutable state. These are published here, and NOT by publish-chart.yml, + # because that workflow dispatches a chart bump into deco-apps-cd on every + # publish: a change to preview policy must not roll the production Studio. strategy: fail-fast: false matrix: - chart: [sandbox-operator, sandbox-env] + chart: [sandbox-operator, sandbox-env, studio-previews] # `[release]:` commits come from the auto-bump bot and don't actually # change chart contents — skip them so we don't republish the same # version. diff --git a/.github/workflows/release-studio.yaml b/.github/workflows/release-studio.yaml index 20f62a3372..7e06135a88 100644 --- a/.github/workflows/release-studio.yaml +++ b/.github/workflows/release-studio.yaml @@ -167,6 +167,7 @@ jobs: test -f apps/api/dist/client/index.html test -f apps/api/dist/server/server.js test -f apps/api/dist/server/migrate.js + test -f apps/api/dist/server/migrate-dbos.js test -f apps/api/dist/server/cli.js - name: Pack the npm tarball working-directory: apps/api diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9474e6c30..427420aad9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,24 @@ bun run dev # migrations + web app + API what changed, how to test it, and any migration notes. - Keep PRs focused. Stack dependent PRs rather than bundling unrelated changes. +### Preview environments + +Add the **`preview`** label to a PR and a throwaway Studio is deployed at +`https://pr-.pr.studio.decocms.com`. A bot comment carries the link and +updates itself as the build progresses; expect ~10 minutes on a fresh preview. +Each preview runs its own Postgres, so the database is empty — sign up with any +email and password. + +Previews are **short-lived on purpose**: they expire 48h after their last +deploy, and removing the label (or closing the PR) tears one down immediately. +Pushing to the PR, or re-adding the label, resets the clock. Nothing in a +preview survives, so never put anything you need in one. + +Previews deliberately do **not** cover hosted agent sandboxes, OAuth sign-in, +billing, monitoring, or multi-pod behaviour — a green preview says nothing +about any of those. See [`deploy/preview/README.md`](./deploy/preview/README.md) +for the full list and for troubleshooting. + ## The rules that bite hardest ### 1. Tools go through `StudioContext` diff --git a/apps/api/package.json b/apps/api/package.json index 318e9f057b..dffec9f3c1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,9 +25,11 @@ "build:server": "bun run scripts/bundle-server-script.ts --dist ./dist/server", "generate:tool-contracts": "bun run scripts/generate-tool-contracts.ts", "db:migrate": "bun run ./dist/server/migrate.js", + "db:migrate:dbos": "bun run ./dist/server/migrate-dbos.js", "check": "tsc --noEmit", "start": "bun run ./dist/server/server.js", "migrate": "bun run src/database/migrate.ts", + "migrate:dbos": "bun run src/database/migrate-dbos.ts", "test": "bun test src", "better-auth:migrate": "bunx --bun @better-auth/cli migrate -y --config src/auth/index.ts", "prepublishOnly": "bun run --cwd=../.. build:studio" diff --git a/apps/api/scripts/bundle-server-script.ts b/apps/api/scripts/bundle-server-script.ts index 55a25045d7..bd1e40660f 100644 --- a/apps/api/scripts/bundle-server-script.ts +++ b/apps/api/scripts/bundle-server-script.ts @@ -526,6 +526,14 @@ function buildMigrateScript(packagesToExternalize: Set) { ); } +function buildMigrateDbosScript(packagesToExternalize: Set) { + return buildScript( + join(SCRIPT_DIR, "../src/database/migrate-dbos.ts"), + "migrate-dbos.js", + packagesToExternalize, + ); +} + function buildServerScript(packagesToExternalize: Set) { return buildScript( join(SCRIPT_DIR, "../src/index.ts"), @@ -710,7 +718,7 @@ async function verifyBundlesShipExternals() { const failures: { pkg: string; from: Set }[] = []; const byPkg = new Map>(); - const entries = ["cli.js", "server.js", "migrate.js"]; + const entries = ["cli.js", "server.js", "migrate.js", "migrate-dbos.js"]; for (const entry of entries) { const entryPath = join(OUTPUT_DIR, entry); if (!existsSync(entryPath)) continue; @@ -827,6 +835,7 @@ async function main() { // Build migrate.js, server.js, and cli.js await buildMigrateScript(packagesToExternalize); + await buildMigrateDbosScript(packagesToExternalize); await buildServerScript(packagesToExternalize); await buildCliScript(packagesToExternalize); @@ -846,6 +855,7 @@ async function main() { console.log("\n🎉 Build completed successfully!"); console.log(`📦 Output directory: ${OUTPUT_DIR}`); console.log(` - migrate.js`); + console.log(` - migrate-dbos.js`); console.log(` - server.js`); console.log(` - cli.js`); console.log(` - emscripten-module.wasm`); diff --git a/apps/api/src/database/migrate-dbos.ts b/apps/api/src/database/migrate-dbos.ts new file mode 100644 index 0000000000..d9b5269f87 --- /dev/null +++ b/apps/api/src/database/migrate-dbos.ts @@ -0,0 +1,64 @@ +/** + * DBOS System-Schema Migration Runner + * + * Creates/updates the `dbos` schema (which DBOS owns inside studio's Postgres + * database) as a standalone step, so it happens exactly once before any app + * pod boots. + * + * Why this exists as its own entry point: studio's `--skip-migrations` flag + * only skips studio's OWN migrations (see settings/pipeline.ts). DBOS still + * runs its system-schema migrations on `DBOS.launch()`, so N pods booting + * against a fresh database race on inserts into `dbos.dbos_migrations` and the + * losers crash with unique-constraint violations — the failure documented in + * tests/multi-pod/docker-compose.yml. Running this once, ahead of the pods, + * removes the race. + * + * Deliberately separate from migrate.ts rather than folded into it: the two + * fail independently, and `bun run migrate` keeps its exact current behavior + * for dev, e2e and the multi-pod harness. + * + * Usage: + * bun run migrate:dbos (from source) + * bun run dist/server/migrate-dbos.js (from the published bundle/image) + */ + +import { buildDbosConfig } from "../dbos/config"; +import { getSettings } from "../settings"; +import { withSslmode } from "./index"; + +export async function migrateDbos(): Promise { + const settings = getSettings(); + + // Dynamic import mirrors index.ts: setConfig must precede workflow registration. + const { DBOS } = await import("@dbos-inc/dbos-sdk"); + + DBOS.setConfig( + buildDbosConfig({ + systemDatabaseUrl: withSslmode( + settings.databaseUrl, + settings.databasePgSsl, + ), + poolSize: settings.dbosPoolSize, + executorID: settings.podName, + // Dequeue nothing — this process only creates the schema. + listenQueues: [], + }), + ); + + // launch() is what applies the system-schema migrations. + await DBOS.launch(); + await DBOS.shutdown(); +} + +if (import.meta.main) { + (async () => { + try { + await migrateDbos(); + console.log("DBOS system-schema migrations completed."); + process.exit(0); + } catch (error) { + console.error("DBOS system-schema migration failed:", error); + process.exit(1); + } + })(); +} diff --git a/apps/docs/client/src/content/deco-studio/en/studio/self-hosting/deploy/kubernetes.mdx b/apps/docs/client/src/content/deco-studio/en/studio/self-hosting/deploy/kubernetes.mdx index 2a6447718c..d65747da2f 100644 --- a/apps/docs/client/src/content/deco-studio/en/studio/self-hosting/deploy/kubernetes.mdx +++ b/apps/docs/client/src/content/deco-studio/en/studio/self-hosting/deploy/kubernetes.mdx @@ -439,3 +439,47 @@ helm uninstall deco-studio -n deco-studio # Only after every environment has removed sandbox-env: helm uninstall sandbox-operator -n agent-sandbox-system ``` + +## Per-PR preview environments + +The chart carries an optional `preview` block. It turns a release into a +short-lived, self-contained environment: an ephemeral Postgres in the same +namespace, migrations run once by a Job instead of racing across pods, and an +`HTTPRoute` onto a shared wildcard `Gateway`. It is meant for reviewing a change +before it merges — one throwaway Studio per pull request. + +`preview.enabled` is `false` by default and every preview template is gated on +it, so a normal install renders exactly what it rendered before. You do not need +this to self-host Studio, and you should leave it off unless you are building a +review pipeline. + +Turning it on requires an orchestrator to create and destroy an environment per +pull request. The suggested one is Argo CD, and the `chart-deco-studio-previews` +chart in this repository ships that half: an `ApplicationSet` whose pullRequest +generator watches a repository for a label, plus the shared `Gateway`. Point it +at your own repository, domain and object storage — it has no defaults. + +```bash +helm template studio-previews deploy/helm/studio-previews \ + --set domain=pr.example.com \ + --set applicationSet.repo.owner=your-org \ + --set applicationSet.repo.name=your-fork \ + --set applicationSet.repo.tokenSecret.name=preview-github-token \ + --set studioChart.repoURL=oci://ghcr.io/decocms \ + --set studioChart.version=0.14.0 \ + --set studioChart.valuesRepo.url=https://github.com/your-org/your-fork.git \ + --set images.api=ghcr.io/your-org/studio-preview \ + --set images.nginx=ghcr.io/your-org/studio-nginx-preview \ + --set studioValues.externalSecret.secretPath=preview/studio \ + --set studioValues.externalSecret.secretStoreName=your-cluster-store \ + --set studioValues.objectStorage.endpoint=https://your-endpoint \ + --set studioValues.objectStorage.bucket=your-preview-bucket +``` + +A preview is not a small production. It does not cover hosted agent sandboxes, +OAuth sign-in, billing, the monitoring dashboard, outbound email, or anything +that depends on more than one pod. It also authenticates nobody by itself — put +an authorization policy on the Gateway listener before exposing it, because a +preview accepts email/password signup and can drive LLM agents. + +See `deploy/helm/studio-previews/README.md` for the full contract. diff --git a/apps/docs/client/src/content/deco-studio/pt-br/studio/self-hosting/deploy/kubernetes.mdx b/apps/docs/client/src/content/deco-studio/pt-br/studio/self-hosting/deploy/kubernetes.mdx index 9c1f5366dc..3f2127418b 100644 --- a/apps/docs/client/src/content/deco-studio/pt-br/studio/self-hosting/deploy/kubernetes.mdx +++ b/apps/docs/client/src/content/deco-studio/pt-br/studio/self-hosting/deploy/kubernetes.mdx @@ -439,3 +439,50 @@ helm uninstall deco-studio -n deco-studio # Somente depois que todos os ambientes removerem sandbox-env: helm uninstall sandbox-operator -n agent-sandbox-system ``` + +## Ambientes de preview por pull request + +O chart traz um bloco opcional `preview`. Ele transforma um release em um +ambiente efêmero e autocontido: um Postgres descartável no mesmo namespace, as +migrations executadas uma única vez por um Job em vez de disputadas entre os +pods, e um `HTTPRoute` apontando para um `Gateway` wildcard compartilhado. +Serve para revisar uma mudança antes do merge — um Studio descartável por pull +request. + +`preview.enabled` é `false` por padrão e todos os templates de preview dependem +dele, então uma instalação normal renderiza exatamente o que renderizava antes. +Você não precisa disso para self-host do Studio, e deve deixá-lo desligado a +menos que esteja montando um pipeline de revisão. + +Ligá-lo exige um orquestrador que crie e destrua um ambiente por pull request. +A sugestão é o Argo CD, e o chart `chart-deco-studio-previews` neste +repositório entrega essa metade: um `ApplicationSet` cujo generator de pull +request observa um repositório em busca de uma label, mais o `Gateway` +compartilhado. Aponte-o para o seu repositório, domínio e object storage — ele +não tem defaults. + +```bash +helm template studio-previews deploy/helm/studio-previews \ + --set domain=pr.example.com \ + --set applicationSet.repo.owner=sua-org \ + --set applicationSet.repo.name=seu-fork \ + --set applicationSet.repo.tokenSecret.name=preview-github-token \ + --set studioChart.repoURL=oci://ghcr.io/decocms \ + --set studioChart.version=0.14.0 \ + --set studioChart.valuesRepo.url=https://github.com/sua-org/seu-fork.git \ + --set images.api=ghcr.io/sua-org/studio-preview \ + --set images.nginx=ghcr.io/sua-org/studio-nginx-preview \ + --set studioValues.externalSecret.secretPath=preview/studio \ + --set studioValues.externalSecret.secretStoreName=seu-cluster-store \ + --set studioValues.objectStorage.endpoint=https://seu-endpoint \ + --set studioValues.objectStorage.bucket=seu-bucket-de-preview +``` + +Um preview não é uma produção pequena. Ele não cobre sandboxes de agente +hospedados, login por OAuth, billing, o dashboard de monitoring, e-mail de +saída, nem nada que dependa de mais de um pod. Ele também não autentica +ninguém por conta própria — coloque uma authorization policy no listener do +Gateway antes de expô-lo, porque um preview aceita cadastro por e-mail/senha e +consegue acionar agentes de LLM. + +Veja `deploy/helm/studio-previews/README.md` para o contrato completo. diff --git a/deploy/helm/studio-previews/.helmignore b/deploy/helm/studio-previews/.helmignore new file mode 100644 index 0000000000..52b79aafc0 --- /dev/null +++ b/deploy/helm/studio-previews/.helmignore @@ -0,0 +1,6 @@ +.DS_Store +.git/ +.gitignore +*.tmproj +.idea/ +.vscode/ diff --git a/deploy/helm/studio-previews/Chart.yaml b/deploy/helm/studio-previews/Chart.yaml new file mode 100644 index 0000000000..1cf7f0e805 --- /dev/null +++ b/deploy/helm/studio-previews/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: chart-deco-studio-previews +description: | + Control plane for per-PR preview environments of a Studio repository. + + Deploys no workload of its own. It renders an Argo CD ApplicationSet whose + pullRequest generator turns every labelled open PR into an Application that + installs chart-deco-studio into its own namespace, plus the shared wildcard + Gateway those per-PR HTTPRoutes attach to. + + Requires Argo CD. Deliberately a separate chart from chart-deco-studio: + the application chart must never carry an argoproj.io CRD that a plain Helm + install cannot resolve, a preview release must never be able to render the + ApplicationSet that generates previews, and preview policy must be able to + change without bumping — and therefore rolling — the application chart. +type: application +version: 0.1.0 diff --git a/deploy/helm/studio-previews/README.md b/deploy/helm/studio-previews/README.md new file mode 100644 index 0000000000..35cbfa9014 --- /dev/null +++ b/deploy/helm/studio-previews/README.md @@ -0,0 +1,55 @@ +# chart-deco-studio-previews + +Control plane for per-PR preview environments. Deploys no workload of its own: +it renders an Argo CD `ApplicationSet` and the shared wildcard `Gateway` that +per-PR routes attach to. + +Label a pull request and the generator produces an Application that installs +`chart-deco-studio` into its own namespace. Close the PR, drop the label, or let +the TTL expire it, and the Application disappears on the next poll — namespace, +Postgres pod and all. + +## Requirements + +- Argo CD, with the ApplicationSet controller running. +- Gateway API and a `GatewayClass` (the defaults assume Istio). +- A published `chart-deco-studio` **>= 0.14.0** reachable from the cluster. +- A token with `pull_requests: read`, in a Secret in the Argo CD namespace. +- The application repository's own preview-build workflow, publishing images + tagged `--<7-char head sha>`. + +## Why this is a separate chart + +`chart-deco-studio` is installed by people self-hosting Studio. It must not +carry an `argoproj.io` CRD that a plain `helm install` cannot resolve, and a +preview release must never be able to render the ApplicationSet that generates +previews — that recursion is impossible here by construction rather than by +validation. Preview policy also changes on a different cadence than the +application: publishing `chart-deco-studio` dispatches a chart bump downstream, +so a change to poll interval or label would otherwise roll production. + +## Configuration + +Every value that names an organisation, repository, domain or cloud account is +empty by default and required at install time; `templates/validations.yaml` +fails the render on anything missing. See `values.yaml` — the two blocks that +matter are `applicationSet` (what to watch) and `studioValues` (what to pass +through to each generated preview). + +## The tag contract + +`images.tagPrefix` plus the PR number plus the first 7 characters of +`.head_sha` must equal what the build workflow publishes. This is asserted in +`.github/workflows/helm-test.yml`, which is the reason this chart lives in the +same repository as that workflow: a `pull_request` checkout lands on +`refs/pull/N/merge`, and tagging from *that* commit produces an image the +generator can never ask for. + +## Known gaps + +- The listener is plain HTTP behind load-balancer TLS termination. Terminating + at the Gateway instead would need a certificate per host, which means an + issuance per PR. +- Nothing here authenticates the preview URL. Put an authorization policy on + the listener before pointing it at a public domain: a preview accepts + email/password signup and can drive LLM agents. diff --git a/deploy/helm/studio-previews/templates/_helpers.tpl b/deploy/helm/studio-previews/templates/_helpers.tpl new file mode 100644 index 0000000000..ccea12853c --- /dev/null +++ b/deploy/helm/studio-previews/templates/_helpers.tpl @@ -0,0 +1,29 @@ +{{/* +Per-PR preview tag, as an Argo Go-template expression evaluated by the +ApplicationSet controller (NOT by Helm). + +The 7-char prefix of `.head_sha` is load bearing: it must be the same commit the +preview-build workflow tags its images with. A `pull_request`-triggered checkout +lands on refs/pull/N/merge, whose HEAD is an ephemeral merge commit that this +generator has no way to name — so the workflow must tag from +`github.event.pull_request.head.sha`, not from `git rev-parse HEAD`. +*/}} +{{- define "studio-previews.imageTag" -}} +{{ .Values.images.tagPrefix }}-{{ `{{ .number }}` }}-{{ `{{ substr 0 7 .head_sha }}` }} +{{- end }} + +{{/* +Release/namespace name of a generated preview. +*/}} +{{- define "studio-previews.appName" -}} +{{ .Values.namePrefix }}-{{ `{{ .number }}` }} +{{- end }} + +{{/* +Public origin of a generated preview. hostPrefix is deliberately separate from +namePrefix: the release and namespace are studio-pr- so they read clearly in +kubectl, while the host stays pr-. so the URL is short. +*/}} +{{- define "studio-previews.host" -}} +{{ .Values.hostPrefix }}-{{ `{{ .number }}` }}.{{ .Values.domain }} +{{- end }} diff --git a/deploy/helm/studio-previews/templates/applicationset.yaml b/deploy/helm/studio-previews/templates/applicationset.yaml new file mode 100644 index 0000000000..0db0c63053 --- /dev/null +++ b/deploy/helm/studio-previews/templates/applicationset.yaml @@ -0,0 +1,108 @@ +{{- $as := .Values.applicationSet -}} +{{- $sc := .Values.studioChart -}} +{{- $sv := .Values.studioValues -}} +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: {{ .Values.name }} + namespace: {{ .Values.argocd.namespace }} +spec: + goTemplate: true + goTemplateOptions: ["missingkey=error"] + + generators: + # The desired set of previews is DERIVED from the GitHub API rather than + # committed anywhere, which is what makes teardown reliable: close a PR (or + # drop the label) and the Application disappears on the next poll, taking + # its namespace and its database with it. A CI-driven `helm upgrade` would + # leak an environment every time its teardown step was cancelled or skipped. + - pullRequest: + github: + owner: {{ $as.repo.owner }} + repo: {{ $as.repo.name }} + tokenRef: + secretName: {{ $as.repo.tokenSecret.name }} + key: {{ $as.repo.tokenSecret.key }} + # Opt-in. The repository's preview-build workflow gates on the same + # label, so neither half alone can create an orphan. + labels: + - {{ $as.label }} + requeueAfterSeconds: {{ $as.requeueAfterSeconds }} + + template: + metadata: + name: '{{ include "studio-previews.appName" . }}' + labels: + {{ .Values.gateway.namespaceSelectorLabel }}: "true" + decocms.com/pr: '{{ `{{ .number }}` }}' + spec: + project: {{ $as.project }} + sources: + # The published application chart. Pinned, never a moving tag. + - repoURL: {{ $sc.repoURL }} + chart: {{ $sc.name }} + targetRevision: {{ $sc.version | quote }} + helm: + releaseName: '{{ include "studio-previews.appName" . }}' + valueFiles: + # The half of the configuration that is identical for every + # preview lives next to the application chart and is rendered and + # asserted by the same CI, so it cannot rot. Read from a fixed + # revision on purpose: a PR must not be able to rewrite its own + # preview's infrastructure. (Consequence: a PR that edits that + # file cannot test the edit in its own preview.) + - $values/{{ $sc.valuesRepo.path }} + # Everything below is either derived per PR or specific to this + # deployment. Nothing here may be hardcoded in the chart. + valuesObject: + image: + repository: {{ .Values.images.api }} + tag: '{{ include "studio-previews.imageTag" . }}' + nginx: + image: + repository: {{ .Values.images.nginx }} + tag: '{{ include "studio-previews.imageTag" . }}' + preview: + prNumber: '{{ `{{ .number }}` }}' + host: '{{ include "studio-previews.host" . }}' + gateway: + name: {{ .Values.gateway.name }} + namespace: {{ .Values.gateway.namespace }} + # Must name a listener that exists. A sectionName matching no + # listener attaches the route to nothing, silently. + sectionName: {{ .Values.gateway.listenerName }} + externalSecret: + secretPath: {{ $sv.externalSecret.secretPath }} + secretStoreName: {{ $sv.externalSecret.secretStoreName }} + secretStoreKind: {{ $sv.externalSecret.secretStoreKind }} + configMap: + meshConfig: + # Better Auth resolves a single exact trusted origin with no + # wildcard support, so these must match the host character + # for character or login fails with a CSRF-shaped error. + BASE_URL: 'https://{{ include "studio-previews.host" . }}' + BETTER_AUTH_URL: 'https://{{ include "studio-previews.host" . }}' + - repoURL: {{ $sc.valuesRepo.url }} + targetRevision: {{ $sc.valuesRepo.revision }} + ref: values + destination: + server: {{ $as.destination.server }} + namespace: '{{ include "studio-previews.appName" . }}' + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + # NOT optional. The Gateway selects routes by NAMESPACE label and + # CreateNamespace=true labels nothing. Without this every preview's + # HTTPRoute attaches to nothing and reports no error at all. + managedNamespaceMetadata: + labels: + {{ .Values.gateway.namespaceSelectorLabel }}: "true" + retry: + limit: 3 + backoff: + duration: 30s + factor: 2 + maxDuration: 5m diff --git a/deploy/helm/studio-previews/templates/gateway.yaml b/deploy/helm/studio-previews/templates/gateway.yaml new file mode 100644 index 0000000000..ae4c83f18a --- /dev/null +++ b/deploy/helm/studio-previews/templates/gateway.yaml @@ -0,0 +1,33 @@ +{{- $g := .Values.gateway -}} +{{- if $g.create }} +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: {{ $g.name }} + namespace: {{ $g.namespace }} + annotations: + # The Gateway outlives any single preview and is shared by all of them; + # pruning it would take every open preview offline at once. + argocd.argoproj.io/sync-options: Delete=false +spec: + gatewayClassName: {{ $g.gatewayClassName }} + {{- with $g.infrastructure }} + infrastructure: + {{- toYaml . | nindent 4 }} + {{- end }} + listeners: + # Plain HTTP: TLS is terminated at the load balancer, so this listener only + # ever sees decrypted traffic from it. A single shared wildcard listener, + # rather than a certificate per preview — per-host issuance would mean one + # ACME order per PR and a handshake delay before the reviewer's first click. + - name: {{ $g.listenerName }} + protocol: HTTP + port: {{ $g.port }} + hostname: "*.{{ .Values.domain }}" + allowedRoutes: + namespaces: + from: Selector + selector: + matchLabels: + {{ $g.namespaceSelectorLabel }}: "true" +{{- end }} diff --git a/deploy/helm/studio-previews/templates/github-token.yaml b/deploy/helm/studio-previews/templates/github-token.yaml new file mode 100644 index 0000000000..659b26b4cf --- /dev/null +++ b/deploy/helm/studio-previews/templates/github-token.yaml @@ -0,0 +1,38 @@ +{{- $t := .Values.applicationSet.repo.tokenSecret -}} +{{- if $t.externalSecret.enabled }} +{{- /* +The token the pullRequest generator authenticates with, synced from a secret +store rather than created by hand. + +It needs exactly one permission — pull_requests: read on the watched repository — +and nothing else. The generator lists open PRs and their labels; it never writes. + +Worth knowing about expiry: if this token lapses, the generator stops listing +PRs, and that does NOT tear anything down. Existing previews keep running +because the ApplicationSet cannot see that their PRs closed, so a lapsed token +turns into a fleet of orphans nobody is watching. A GitHub App (appSecretName on +the generator) has no expiry and is the better long-term answer. +*/ -}} +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ $t.name }} + namespace: {{ $.Values.argocd.namespace }} + labels: + {{ $.Values.gateway.namespaceSelectorLabel }}: "true" +spec: + refreshInterval: {{ $t.externalSecret.refreshInterval }} + secretStoreRef: + name: {{ $t.externalSecret.secretStoreName }} + # ClusterSecretStore: this lands in the Argo CD namespace, which is not one + # of ours and has no store of its own. + kind: {{ $t.externalSecret.secretStoreKind }} + target: + name: {{ $t.name }} + creationPolicy: Owner + data: + - secretKey: {{ $t.key }} + remoteRef: + key: {{ $t.externalSecret.secretPath }} + property: {{ $t.key }} +{{- end }} diff --git a/deploy/helm/studio-previews/templates/validations.yaml b/deploy/helm/studio-previews/templates/validations.yaml new file mode 100644 index 0000000000..d1eddcf5d2 --- /dev/null +++ b/deploy/helm/studio-previews/templates/validations.yaml @@ -0,0 +1,53 @@ +{{- /* +Renders no resources. Every check here covers a value whose absence produces a +healthy-looking object that silently does nothing. +*/ -}} +{{- $required := list + (list "domain" .Values.domain) + (list "applicationSet.repo.owner" .Values.applicationSet.repo.owner) + (list "applicationSet.repo.name" .Values.applicationSet.repo.name) + (list "applicationSet.repo.tokenSecret.name" .Values.applicationSet.repo.tokenSecret.name) + (list "studioChart.repoURL" .Values.studioChart.repoURL) + (list "studioChart.version" .Values.studioChart.version) + (list "studioChart.valuesRepo.url" .Values.studioChart.valuesRepo.url) + (list "images.api" .Values.images.api) + (list "images.nginx" .Values.images.nginx) + (list "studioValues.externalSecret.secretPath" .Values.studioValues.externalSecret.secretPath) + (list "studioValues.externalSecret.secretStoreName" .Values.studioValues.externalSecret.secretStoreName) +-}} +{{- range $required }} +{{- if not (index . 1) }} +{{- fail (printf "chart-deco-studio-previews: %s is required — this chart ships no deployment-specific defaults" (index . 0)) -}} +{{- end }} +{{- end }} + +{{- /* +The token ExternalSecret needs a path and a store, or it renders an object that +syncs nothing and the generator authenticates with an empty Secret. +*/ -}} +{{- if .Values.applicationSet.repo.tokenSecret.externalSecret.enabled }} +{{- if not .Values.applicationSet.repo.tokenSecret.externalSecret.secretPath }} +{{- fail "chart-deco-studio-previews: applicationSet.repo.tokenSecret.externalSecret.secretPath is required when the token ExternalSecret is enabled" -}} +{{- end }} +{{- if not .Values.applicationSet.repo.tokenSecret.externalSecret.secretStoreName }} +{{- fail "chart-deco-studio-previews: applicationSet.repo.tokenSecret.externalSecret.secretStoreName is required when the token ExternalSecret is enabled" -}} +{{- end }} +{{- end }} + +{{- /* +A moving chart tag would roll every open preview the moment an unrelated chart +version is published, in the middle of somebody's review. +*/ -}} +{{- if has (lower .Values.studioChart.version) (list "latest" "main" "stable") }} +{{- fail "chart-deco-studio-previews: studioChart.version must be a pinned version, never a moving tag" -}} +{{- end }} + +{{- /* +The Gateway selects routes by NAMESPACE label and Argo's CreateNamespace=true +labels nothing, so the generated Applications must apply the same label through +managedNamespaceMetadata. If these two ever diverge, every preview's HTTPRoute +attaches to nothing and reports no error anywhere. +*/ -}} +{{- if not .Values.gateway.namespaceSelectorLabel }} +{{- fail "chart-deco-studio-previews: gateway.namespaceSelectorLabel is required — without it the per-PR HTTPRoutes attach to no Gateway and report no error" -}} +{{- end }} diff --git a/deploy/helm/studio-previews/values.yaml b/deploy/helm/studio-previews/values.yaml new file mode 100644 index 0000000000..9ac41e5df6 --- /dev/null +++ b/deploy/helm/studio-previews/values.yaml @@ -0,0 +1,117 @@ +# Control plane for per-PR Studio previews. +# +# Every value that identifies an organisation, a repository, a domain or a +# cloud account is intentionally EMPTY here and required at install time. This +# chart is published publicly; it must describe the capability, never one +# deployment of it. validations.yaml fails the render on anything missing. + +argocd: + # Namespace the ApplicationSet controller watches. The ApplicationSet must be + # created here — anywhere else it is silently ignored. + namespace: argocd + +# Name of the generated ApplicationSet and the prefix of every Application it +# generates (releases and namespaces are -). +name: studio-preview +namePrefix: studio-pr +# Host prefix, kept separate from namePrefix so the URL stays short: +# -.. +hostPrefix: pr + +applicationSet: + # Argo project the generated Applications belong to. + project: default + # Pull request label that opts a PR into a preview. The repository's own + # image-build workflow must gate on the SAME label, so that neither half can + # create an environment the other does not know about. + label: preview + # GitHub PR generator poll interval. This is the difference between "closed + # the PR, it is gone" and "closed the PR, come back later". + requeueAfterSeconds: 60 + repo: + owner: "" + name: "" + # Token with `pull_requests: read`. Nothing else is required of it. + # + # If it expires the generator stops listing PRs, and nothing is torn down — + # existing previews keep running because the ApplicationSet can no longer + # see that their PRs closed. Prefer a GitHub App, which does not expire. + tokenSecret: + name: "" + key: token + # Sync the token from a secret store instead of creating the Secret by + # hand. Set enabled=false when something else already provides it. + externalSecret: + enabled: true + secretPath: "" + secretStoreName: "" + secretStoreKind: ClusterSecretStore + refreshInterval: 1h + destination: + # Cluster the previews are installed into. The in-cluster default keeps + # generated Applications local to wherever this chart is deployed. + server: https://kubernetes.default.svc + +# Wildcard host for previews: -. +domain: "" + +# The application chart each preview installs. +studioChart: + repoURL: "" + name: chart-deco-studio + # PINNED, never a moving tag: tracking one would roll every open preview on + # an unrelated chart bump. Must be >= 0.14.0 (the release that added preview.*). + version: "" + # Shared preview values, read from the studio repository by Argo's multi-source + # $values ref. Held on a fixed revision on purpose: a PR must not be able to + # rewrite the infrastructure values of its own preview. + valuesRepo: + url: "" + revision: main + path: deploy/helm/studio/values-preview.yaml + +images: + # Preview image repositories. Tags are derived per PR as + # --<7-char head sha> and MUST match what the repository's + # preview-build workflow publishes — see the assertion in helm-test.yml. + api: "" + nginx: "" + tagPrefix: pr + +# Deployment-specific values passed straight through to the Studio chart of +# every generated preview. Everything here identifies a specific cloud account +# and therefore has no default. +studioValues: + externalSecret: + # Path of the secret holding BETTER_AUTH_SECRET, ENCRYPTION_KEY and the + # object storage credentials. BETTER_AUTH_SECRET and ENCRYPTION_KEY must be + # STABLE for the life of the environment: a rotating auth secret is a login + # loop, a rotating encryption key makes every vaulted credential + # undecryptable. + secretPath: "" + # A ClusterSecretStore, because previews create namespaces on the fly and a + # namespaced store would need credentials wired up per namespace. + secretStoreName: "" + secretStoreKind: ClusterSecretStore + # No object storage configuration: each preview runs its own MinIO inside its + # namespace (preview.minio in the studio chart), so there is no bucket to + # create, no lifecycle rule to keep and no credential to distribute. + +gateway: + # Set false when the wildcard Gateway is owned by another release. + create: true + name: studio-preview + namespace: istio-system + gatewayClassName: istio + # Label the per-PR namespaces carry and the Gateway selects routes by. Argo's + # CreateNamespace=true does not label anything, so the generated Applications + # set it through managedNamespaceMetadata. + namespaceSelectorLabel: decocms.com/preview + # TLS terminates at the load balancer, so the listener itself is plain HTTP. + # The per-PR HTTPRoute targets this section by name. + listenerName: http + port: 443 + # Cloud-specific load balancer configuration, applied verbatim to the + # Gateway's generated Service. + infrastructure: + annotations: {} diff --git a/deploy/helm/studio/.helmignore b/deploy/helm/studio/.helmignore new file mode 100644 index 0000000000..041851fb12 --- /dev/null +++ b/deploy/helm/studio/.helmignore @@ -0,0 +1,13 @@ +# Patterns are matched against the chart directory at `helm package` time. +.DS_Store +.git/ +.gitignore +*.tmproj +.idea/ +.vscode/ + +# Preview wiring is consumed from git by the studio-previews ApplicationSet +# ($values multi-source ref), never from the packaged chart. Keeping it out of +# the tarball means a self-hosted install never receives a values file written +# for somebody else's cluster. +values-preview.yaml diff --git a/deploy/helm/studio/Chart.yaml b/deploy/helm/studio/Chart.yaml index 3e1ff67e4f..daeaaf64b3 100644 --- a/deploy/helm/studio/Chart.yaml +++ b/deploy/helm/studio/Chart.yaml @@ -2,6 +2,13 @@ apiVersion: v2 name: chart-deco-studio description: Helm chart for deco Studio — supports inline secrets, external secretName, and AWS Secrets Manager via ExternalSecret type: application +# 0.14.0: per-PR preview releases (preview.enabled, default false). Adds an +# HTTPRoute, an ephemeral in-namespace Postgres and a single PreSync migration +# Job — the database lives and dies with the namespace, so there is no shared +# server to provision, no admin credential to distribute and nothing to sweep. +# Renders nothing when disabled, so the default render is byte-identical and +# existing releases are unaffected. Pod topology is deliberately unchanged, and +# helm.sh/chart is kept off pod templates so a version bump rolls no pods. # 0.13.2: dedicated emptyDir + FAST_PREVIEW_CACHE_DIR for the sandbox-less # Fast Preview disk cache (fastPreviewCache values block). # 0.13.1: documents the pre-existing `serviceAccount` values block. The @@ -12,7 +19,7 @@ type: application # when disabled, so existing releases are unaffected. # 0.12.4: chart-managed API/worker dispatch roles now use # STUDIO_DISPATCH_ROLE; legacy overrides are rejected. -version: 0.13.2 +version: 0.14.0 appVersion: "latest" dependencies: diff --git a/deploy/helm/studio/templates/_helpers.tpl b/deploy/helm/studio/templates/_helpers.tpl index 5467ec34c4..43ace27ba8 100644 --- a/deploy/helm/studio/templates/_helpers.tpl +++ b/deploy/helm/studio/templates/_helpers.tpl @@ -110,7 +110,12 @@ Resolves DATABASE_URL honoring database engine configuration. */}} {{- define "chart-deco-studio.databaseUrl" -}} {{- if eq (lower (default "sqlite" .Values.database.engine)) "postgresql" -}} +{{- if and .Values.preview.enabled .Values.preview.postgres.enabled (not .Values.database.url) -}} +{{- $pg := .Values.preview.postgres.auth -}} +{{- printf "postgresql://%s:%s@%s:5432/%s" $pg.username $pg.password (include "chart-deco-studio.previewPostgresName" .) $pg.database -}} +{{- else -}} {{- required "database.url must be set when database.engine=postgresql" .Values.database.url | trim -}} +{{- end -}} {{- else -}} {{/* Historical filename retained so existing PVCs keep their database. */}} /app/data/mesh.db @@ -132,7 +137,8 @@ Global validations to ensure scaling requirements are met. {{- if and .Values.autoscaling.enabled (not (or $distributed $usesPostgres)) }} {{- fail "chart-deco-studio: autoscaling.enabled=true requires distributed storage (persistence.distributed=true or accessMode=ReadWriteMany) or database.engine=postgresql" -}} {{- end }} -{{- if and $usesPostgres (not .Values.database.url) (not .Values.secret.secretName) (not .Values.externalSecret.enabled) }} +{{- $previewPg := eq (include "chart-deco-studio.previewPostgresProvidesUrl" . | trim) "true" -}} +{{- if and $usesPostgres (not $previewPg) (not .Values.database.url) (not .Values.secret.secretName) (not .Values.externalSecret.enabled) }} {{- fail "chart-deco-studio: set database.url when database.engine=postgresql, or use secret.secretName to provide DATABASE_URL via Secret" -}} {{- end }} {{- if and .Values.autoscaling.enabled (gt (int .Values.autoscaling.minReplicas) (int .Values.autoscaling.maxReplicas)) }} @@ -292,6 +298,76 @@ at render time instead. {{- end }} {{- end }} +{{/* +True when the preview's own in-namespace Postgres is what supplies DATABASE_URL. +In that case the URL is NOT a credential — it addresses an ephemeral pod +reachable only from this namespace — so it belongs in the ConfigMap. Routing it +through the Secret would break the ESO path, where no chart-managed Secret is +rendered at all and DATABASE_URL would simply go missing. +*/}} +{{- define "chart-deco-studio.previewPostgresProvidesUrl" -}} +{{- if and .Values.preview.enabled .Values.preview.postgres.enabled (not .Values.database.url) -}} +true +{{- else -}} +false +{{- end -}} +{{- end }} + +{{/* +Service/Deployment name for the preview's own Postgres. +*/}} +{{- define "chart-deco-studio.previewPostgresName" -}} +{{- printf "%s-postgres" (include "chart-deco-studio.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{/* +True when the preview's own in-namespace MinIO is what supplies object storage. +Same reasoning as the Postgres helper: the endpoint and credentials address an +ephemeral pod reachable only from this namespace, so they are configuration and +not secrets, and they belong in the ConfigMap rather than the Secret. +*/}} +{{- define "chart-deco-studio.previewMinioProvidesStorage" -}} +{{- if and .Values.preview.enabled .Values.preview.minio.enabled -}} +true +{{- else -}} +false +{{- end -}} +{{- end }} + +{{/* +Service/Deployment name for the preview's own MinIO. +*/}} +{{- define "chart-deco-studio.previewMinioName" -}} +{{- printf "%s-minio" (include "chart-deco-studio.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{/* +Validates per-PR preview releases. Every failure here is something that would +otherwise render a healthy-looking object that silently does nothing: an +HTTPRoute with no hostname matches no traffic, a pod without --skip-migrations +races the PreSync migration Job, and a provisioner Job without admin +credentials cannot create the database the pods are about to connect to. +*/}} +{{- define "chart-deco-studio.validatePreview" -}} +{{- if .Values.preview.enabled }} +{{- if not .Values.preview.host }} +{{- fail "chart-deco-studio: preview.host is required when preview.enabled=true" -}} +{{- end }} +{{- if not .Values.preview.prNumber }} +{{- fail "chart-deco-studio: preview.prNumber is required when preview.enabled=true — it names the per-PR database and bucket" -}} +{{- end }} +{{- if .Values.ingress.enabled }} +{{- fail "chart-deco-studio: preview.enabled and ingress.enabled are mutually exclusive — previews route through Gateway API (HTTPRoute), not Ingress" -}} +{{- end }} +{{- if not .Values.preview.gateway.name }} +{{- fail "chart-deco-studio: preview.gateway.name is required when preview.enabled=true" -}} +{{- end }} +{{- if not (has "--skip-migrations" (.Values.image.command | default list)) }} +{{- fail "chart-deco-studio: preview.enabled=true requires --skip-migrations in image.command; the PreSync Job is the single migration writer and neither the API nor the worker containers (which share image.command) may race it" -}} +{{- end }} +{{- end }} +{{- end }} + {{/* Validates public NATS tunnel cluster-creds mount configuration. */}} diff --git a/deploy/helm/studio/templates/configmap.yaml b/deploy/helm/studio/templates/configmap.yaml index f3c09c9dce..602d3a36ad 100644 --- a/deploy/helm/studio/templates/configmap.yaml +++ b/deploy/helm/studio/templates/configmap.yaml @@ -25,6 +25,21 @@ data: {{- end }} NATS_TUNNEL_PUBLIC_ENABLED: {{ .Values.tunnel.nats.publicEnabled | quote }} NATS_TUNNEL_SESSION_TTL_SECONDS: {{ .Values.tunnel.nats.sessionTtlSeconds | quote }} - {{- if ne (lower (default "sqlite" .Values.database.engine)) "postgresql" }} + {{- if or (ne (lower (default "sqlite" .Values.database.engine)) "postgresql") (eq (include "chart-deco-studio.previewPostgresProvidesUrl" . | trim) "true") }} DATABASE_URL: {{ include "chart-deco-studio.databaseUrl" . | trim | quote }} {{- end }} + {{- /* Derive object storage from the preview's own in-namespace MinIO. All + four S3_* plus both credentials must be present together — with any of them + missing the application falls back to downloading and running its own MinIO + binary, which is the failure this exists to avoid. They are ConfigMap and not + Secret for the same reason DATABASE_URL is: they address an ephemeral pod + reachable only from this namespace. An explicit meshConfig value wins, so a + preview can still be pointed at a real bucket. */}} + {{- if and (eq (include "chart-deco-studio.previewMinioProvidesStorage" . | trim) "true") (not (hasKey .Values.configMap.meshConfig "S3_ENDPOINT")) }} + S3_ENDPOINT: {{ printf "http://%s:9000" (include "chart-deco-studio.previewMinioName" .) | quote }} + S3_BUCKET: {{ .Values.preview.minio.bucket | quote }} + S3_REGION: "us-east-1" + S3_FORCE_PATH_STYLE: "true" + S3_ACCESS_KEY_ID: {{ .Values.preview.minio.auth.accessKey | quote }} + S3_SECRET_ACCESS_KEY: {{ .Values.preview.minio.auth.secretKey | quote }} + {{- end }} diff --git a/deploy/helm/studio/templates/externalsecret.yaml b/deploy/helm/studio/templates/externalsecret.yaml index 501475b799..f09b136644 100644 --- a/deploy/helm/studio/templates/externalsecret.yaml +++ b/deploy/helm/studio/templates/externalsecret.yaml @@ -1,4 +1,5 @@ {{- if .Values.externalSecret.enabled }} +{{- if .Values.externalSecret.createSecretStore }} --- apiVersion: external-secrets.io/v1 kind: SecretStore @@ -12,6 +13,7 @@ spec: aws: service: SecretsManager region: {{ .Values.externalSecret.provider.aws.region }} +{{- end }} --- apiVersion: external-secrets.io/v1 kind: ExternalSecret @@ -20,15 +22,38 @@ metadata: namespace: {{ .Release.Namespace }} labels: {{- include "chart-deco-studio.labels" . | nindent 4 }} + {{- if .Values.preview.enabled }} + annotations: + # Ahead of everything that mounts it: the migrate Job (-10) and the app + # Deployments (0) both consume this Secret via envFrom, and a missing + # secretRef stops a pod from starting at all. + argocd.argoproj.io/sync-wave: "-30" + {{- end }} spec: refreshInterval: {{ .Values.externalSecret.refreshInterval | quote }} secretStoreRef: name: {{ .Values.externalSecret.secretStoreName }} - kind: SecretStore + kind: {{ .Values.externalSecret.secretStoreKind }} target: name: {{ include "chart-deco-studio.fullname" . }}-secrets creationPolicy: Owner deletionPolicy: Retain + {{- with .Values.externalSecret.template }} + template: + # Merge, not ESO's Replace default — Replace would drop every dataFrom key. + mergePolicy: {{ .mergePolicy | default "Merge" }} + {{- with .engineVersion }} + engineVersion: {{ . }} + {{- end }} + {{- with .metadata }} + metadata: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .data }} + data: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} dataFrom: - extract: key: {{ .Values.externalSecret.secretPath }} diff --git a/deploy/helm/studio/templates/httproute.yaml b/deploy/helm/studio/templates/httproute.yaml new file mode 100644 index 0000000000..866f62b285 --- /dev/null +++ b/deploy/helm/studio/templates/httproute.yaml @@ -0,0 +1,33 @@ +{{- if .Values.preview.enabled }} +{{- /* +Gateway API route for a per-PR preview, attaching to a SHARED wildcard Gateway +(one DNS record, one cert) rather than provisioning per-host TLS. + +The Gateway's allowedRoutes selector matches on NAMESPACE labels, so whatever +creates this namespace must label it — with Argo CD that means +syncPolicy.managedNamespaceMetadata. Without the label this route attaches to +nothing and reports no error. + +The `ingress` values block is the wrong tool here and says so; validatePreview +rejects enabling both. +*/ -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "chart-deco-studio.fullname" . }} + labels: + {{- include "chart-deco-studio.labels" . | nindent 4 }} +spec: + parentRefs: + - name: {{ .Values.preview.gateway.name }} + namespace: {{ .Values.preview.gateway.namespace }} + {{- with .Values.preview.gateway.sectionName }} + sectionName: {{ . }} + {{- end }} + hostnames: + - {{ .Values.preview.host | quote }} + rules: + - backendRefs: + - name: {{ include "chart-deco-studio.fullname" . }} + port: {{ .Values.service.port }} +{{- end }} diff --git a/deploy/helm/studio/templates/preview-migrate-job.yaml b/deploy/helm/studio/templates/preview-migrate-job.yaml new file mode 100644 index 0000000000..00104bb3a5 --- /dev/null +++ b/deploy/helm/studio/templates/preview-migrate-job.yaml @@ -0,0 +1,76 @@ +{{- if .Values.preview.enabled }} +{{- /* +Sync-phase hook at wave -10: the single migration writer for a preview. + +A Sync hook rather than PreSync because the preview's Postgres (wave -20) is a +Sync-phase resource — a PreSync Job would run before it exists. Argo holds each +wave until its resources report healthy, so this starts only once Postgres is +accepting connections, and the app Deployments (default wave 0) start only once +this Job has completed. Pods then boot with --skip-migrations (enforced by +validatePreview). + +Both steps are needed. --skip-migrations only covers studio's own migrations; +DBOS still migrates its `dbos` schema on launch, and parallel pod boots race on +inserts into dbos.dbos_migrations — see tests/multi-pod/docker-compose.yml. +*/ -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "chart-deco-studio.fullname" . }}-preview-migrate + labels: + {{- include "chart-deco-studio.labels" . | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation + argocd.argoproj.io/sync-wave: "-10" +spec: + backoffLimit: 3 + template: + metadata: + labels: + {{- include "chart-deco-studio.podLabels" . | nindent 8 }} + spec: + restartPolicy: OnFailure + serviceAccountName: {{ include "chart-deco-studio.serviceAccountName" . }} + securityContext: + {{- include "chart-deco-studio.podSecurityContext" . | nindent 8 }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: migrate + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.image.repository }}{{- if and .Values.image.tag (hasPrefix "sha256:" .Values.image.tag) }}@{{ .Values.image.tag }}{{- else }}:{{ .Values.image.tag | default .Chart.AppVersion }}{{- end }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + envFrom: + - configMapRef: + name: {{ include "chart-deco-studio.fullname" . }}-config + - secretRef: + name: {{ include "chart-deco-studio.secretName" . }} + command: ["/bin/sh", "-c"] + args: + # Absolute bundle paths, not `bun run