From 9dbc298d542ac365cfcf77eb0a75bd15a151da45 Mon Sep 17 00:00:00 2001 From: James Olds <12104969+oldsj@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:12:47 -0400 Subject: [PATCH] Deploy: the chart is the only deploy path; the prod overlay and release branch go Production now deploys from the published chart (oci://ghcr.io/oldsj/charts/mainloop), so k8s/apps/mainloop (the base, the prod overlay and its devloop RBAC, which the deployment now owns) and the release job that pinned digests into that overlay have no users. The deploy docs describe the chart flow; rollback is pinning a previous chart version. Co-Authored-By: Claude Opus 5.5 --- .github/README.md | 9 +- .github/workflows/images.yml | 94 ------ .gitignore | 1 - AGENTS.md | 3 +- README.md | 13 +- ROADMAP.md | 2 +- docs/production-rollout.md | 67 ++--- docs/specs/devloop.md | 17 +- k8s/apps/mainloop/base/configmap.yaml | 20 -- .../mainloop/base/deployment-backend.yaml | 273 ------------------ .../mainloop/base/deployment-frontend.yaml | 65 ----- k8s/apps/mainloop/base/kustomization.yaml | 17 -- k8s/apps/mainloop/base/namespace.yaml | 4 - k8s/apps/mainloop/base/networkpolicy.yaml | 57 ---- k8s/apps/mainloop/base/rbac-backend.yaml | 5 - k8s/apps/mainloop/base/service-backend.yaml | 13 - k8s/apps/mainloop/base/service-frontend.yaml | 11 - k8s/apps/mainloop/base/service-git-push.yaml | 12 - k8s/apps/mainloop/base/service-git-read.yaml | 12 - k8s/apps/mainloop/base/service-mcp.yaml | 11 - .../mainloop/overlays/prod/backend-patch.yaml | 73 ----- .../mainloop/overlays/prod/config-patch.yaml | 32 -- k8s/apps/mainloop/overlays/prod/database.yaml | 24 -- .../overlays/prod/devloop/kustomization.yaml | 16 - .../mainloop/overlays/prod/devloop/role.yaml | 19 -- .../overlays/prod/devloop/rolebinding.yaml | 15 - .../overlays/prod/frontend-patch.yaml | 9 - .../mainloop/overlays/prod/kustomization.yaml | 26 -- .../overlays/prod/networkpolicy-patch.yaml | 18 -- 29 files changed, 47 insertions(+), 891 deletions(-) delete mode 100644 k8s/apps/mainloop/base/configmap.yaml delete mode 100644 k8s/apps/mainloop/base/deployment-backend.yaml delete mode 100644 k8s/apps/mainloop/base/deployment-frontend.yaml delete mode 100644 k8s/apps/mainloop/base/kustomization.yaml delete mode 100644 k8s/apps/mainloop/base/namespace.yaml delete mode 100644 k8s/apps/mainloop/base/networkpolicy.yaml delete mode 100644 k8s/apps/mainloop/base/rbac-backend.yaml delete mode 100644 k8s/apps/mainloop/base/service-backend.yaml delete mode 100644 k8s/apps/mainloop/base/service-frontend.yaml delete mode 100644 k8s/apps/mainloop/base/service-git-push.yaml delete mode 100644 k8s/apps/mainloop/base/service-git-read.yaml delete mode 100644 k8s/apps/mainloop/base/service-mcp.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/backend-patch.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/config-patch.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/database.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/devloop/kustomization.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/devloop/role.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/devloop/rolebinding.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/frontend-patch.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/kustomization.yaml delete mode 100644 k8s/apps/mainloop/overlays/prod/networkpolicy-patch.yaml diff --git a/.github/README.md b/.github/README.md index 97ccb497..24a468fe 100644 --- a/.github/README.md +++ b/.github/README.md @@ -85,16 +85,15 @@ The tags (`cache-`, one per build) are not pruned yet. `ubuntu-24.04-arm`, publishing only `linux/arm64` images (15 minutes each): - `ghcr.io/oldsj/mainloop-backend:` (the Go server; - the image keeps its historical name because the production manifests pin it) + the image keeps its historical name because deployments pin it) - `ghcr.io/oldsj/mainloop-frontend:` The frontend uses the workflow's production API build argument. There is no `latest` tag or job-level output. Step outputs provide the short SHA and build digest; summaries record source, tag, digest and an immutable infrastructure pin. -The image jobs are not restricted to `main`, but the `release` job is: it runs only on -`main`, and only while its commit is still the tip of `main`. It force-pushes the -`release` branch and a `release-` tag with the digests pinned in the prod overlay. -Argo tracks `release`; no infrastructure PR is needed per release. +The image jobs are not restricted to `main`, but the `chart` job is: it runs only on +`main` and publishes `oci://ghcr.io/oldsj/charts/mainloop` `0.1.` with both +digests pinned. Deployments track `0.1.*`; no infrastructure PR is needed per release. ## Shared live-development application images diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 80406e20..c72d0642 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -140,97 +140,3 @@ jobs: printf 'Backend: `%s`\n\n' "$backend" printf 'Frontend: `%s`\n' "$frontend" } >> "$GITHUB_STEP_SUMMARY" - - # Pins production to this commit's images. The release branch is the main commit plus one commit - # that adds an images block to the prod overlay; Argo tracks it. Pushes made with GITHUB_TOKEN do - # not start workflows, so this cannot loop. A run releases only while its commit is still the tip of - # main, so a cancelled, failed or re-run older run cannot roll production back. Each release is also - # tagged release-: the release branch is rewritten every time, and the tag keeps the - # previous release reachable for a rollback. - release: - name: Release to the release branch - needs: publish - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write - packages: read - env: - OVERLAY: k8s/apps/mainloop/overlays/prod - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.sha }} - - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Pin the prod overlay to the published digests - id: pin - shell: bash - run: | - set -euo pipefail - tag="${GITHUB_SHA:0:7}" - main_now="$(git ls-remote origin refs/heads/main | cut -f1)" - if [[ "$main_now" != "$GITHUB_SHA" ]]; then - echo "::notice::main is at ${main_now}, not ${GITHUB_SHA}; the run for the newer commit releases" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if grep -q '^images:' "${OVERLAY}/kustomization.yaml"; then - echo "::error::${OVERLAY}/kustomization.yaml already has an images: key; merge the release block by hand" - exit 1 - fi - digest_of() { - local d - d="$(docker buildx imagetools inspect "ghcr.io/oldsj/mainloop-$1:${tag}" --format '{{.Manifest.Digest}}')" - [[ "$d" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "no digest for mainloop-$1:${tag}: '${d}'" >&2; exit 1; } - printf '%s' "$d" - } - backend="$(digest_of backend)" - frontend="$(digest_of frontend)" - { - printf '\nimages:\n' - printf ' - name: ghcr.io/oldsj/mainloop-backend\n digest: %s\n' "$backend" - printf ' - name: ghcr.io/oldsj/mainloop-frontend\n digest: %s\n' "$frontend" - } >> "${OVERLAY}/kustomization.yaml" - rendered="$(kubectl kustomize "${OVERLAY}")" - grep -qF "mainloop-backend@${backend}" <<<"$rendered" - grep -qF "mainloop-frontend@${frontend}" <<<"$rendered" - { - printf '### Release %s\n\n' "$tag" - printf 'Backend: `%s`\n\n' "$backend" - printf 'Frontend: `%s`\n' "$frontend" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Update the release branch - if: steps.pin.outputs.skip != 'true' - shell: bash - run: | - set -euo pipefail - tag="${GITHUB_SHA:0:7}" - # The previous release's subject is "Release "; its tag is release-. - prev="" - if [[ -n "$(git ls-remote origin refs/heads/release)" ]]; then - git fetch --quiet --depth=1 origin refs/heads/release - prev="$(git log -1 --format=%s FETCH_HEAD | sed -n 's/^Release \([0-9a-f]\{7\}\)$/\1/p')" - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -am "Release ${tag}" -m "Source: ${GITHUB_SHA}" - git push --atomic --force origin HEAD:refs/heads/release "HEAD:refs/tags/release-${tag}" - { - printf '\nRelease commit: `%s` (tag `release-%s`)\n\n' "$(git rev-parse HEAD)" "$tag" - if [[ -n "$prev" ]]; then - printf 'Roll back to the previous release:\n\n' - printf '```\ngit fetch origin tag release-%s && git push --force origin release-%s:refs/heads/release\n```\n' "$prev" "$prev" - else - printf 'No previous release to roll back to.\n' - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index b195d69b..c56cbfde 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ secrets.yaml gcp-credentials.json # Personal k8s config (for open source - keeps your domains private) -k8s/apps/mainloop/overlays/*/personal-config-patch.yaml # Logs *.log diff --git a/AGENTS.md b/AGENTS.md index c30a9290..d7d8500c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ When documents differ, do not silently blend future design with current behavior api/ OpenAPI contract (openapi.yaml) and MCP tool manifest (mcp-tools.json) server/ Go server: one binary, cmd/mainloop, with a subcommand per process web/ React app (Vite, TanStack Router, Tailwind); design system in web/design/ -k8s/ Kubernetes bases, overlays and kagent integration (apps/mainloop, integrations/kagent) +charts/ Helm chart for Mainloop (charts/mainloop) +k8s/ kagent integration example (integrations/kagent) docs/specs/ User-visible behavior specifications scripts/ Development and CI helper scripts ``` diff --git a/README.md b/README.md index 49b2e583..88a01998 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ limitation are in [Pull requests](docs/specs/pull-requests.md). The `get_issue`, ## Production Deployment -The Kubernetes manifests under `k8s/apps/mainloop/` provide reusable bases and example overlays. Supply environment-specific images, domains, credentials, and storage through your deployment configuration, and apply production changes through your GitOps workflow. +The [Helm chart](charts/mainloop) deploys Mainloop. Supply environment-specific domains, credentials, and storage through your own values file, and apply production changes through your GitOps workflow ([details](docs/production-rollout.md)). The [Publish images workflow](.github/workflows/images.yml) builds and pushes `linux/arm64` production images on every push to `main`, using native @@ -86,15 +86,14 @@ context); it receives `VITE_API_URL=https://mainloop-api.olds.network` at build Images are published to `ghcr.io/oldsj/mainloop-backend` and `ghcr.io/oldsj/mainloop-frontend`, tagged with the first seven characters of the source commit SHA. Each image's job summary records the full source SHA, tag, -digest, and copyable `image@sha256:...` reference. When both image jobs succeed and -the commit is still the tip of `main`, the `release` job force-pushes the `release` -branch (the `main` commit plus one commit pinning both digests in the prod overlay) and -tags it `release-`. Once the deployment repository points Argo at `release`, a merge to `main` deploys. Rolling -back is pushing the previous release tag back to `release` +digest, and copyable `image@sha256:...` reference. When both image jobs succeed on `main`, +the `chart` job publishes `oci://ghcr.io/oldsj/charts/mainloop` version `0.1.` +with both digests pinned, so a deployment tracking `0.1.*` deploys each merge. Rolling back +is pinning the previous chart version ([details](docs/production-rollout.md#production-changes-are-gitops-only)). The image jobs authenticate with `GITHUB_TOKEN` (`contents: read`, -`packages: write`; the `release` job has `contents: write`) and use separate GitHub Actions caches for each image. Existing +`packages: write`) and use separate GitHub Actions caches for each image. Existing GHCR packages must grant this repository Actions write access. New pushes cancel older image builds for the same ref; a cancelled run may have published only one image, so use a fully successful run for a pin update. diff --git a/ROADMAP.md b/ROADMAP.md index 1b77215b..b2293ea4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,7 +172,7 @@ Messages receive stable identities and are persisted before delivery. Retries wi After a timeout or disconnect, Mainloop reconciles native-session and workspace state before deciding whether to retry. Unknown delivery is not equivalent to failure. Recovery never replays transcript commands automatically. -Production delivery is mechanical: a merge to `main` publishes images, and CI force-pushes a `release` branch carrying their digests, which Argo tracks. Rollback is pushing a previous release commit back to `release`. +Production delivery is mechanical: a merge to `main` publishes images and a chart version pinning their digests, which Argo tracks. Rollback is pinning a previous chart version. Check results and review verdicts bind to an exact candidate identity. Integration review covers combined-system concerns such as duplicated foundations, conflicting abstractions, contract drift, and inconsistent security or data handling—not only whether each feature works independently. diff --git a/docs/production-rollout.md b/docs/production-rollout.md index cdd23d80..2beaaf93 100644 --- a/docs/production-rollout.md +++ b/docs/production-rollout.md @@ -1,37 +1,28 @@ # Production deployment -`k8s/apps/mainloop/overlays/prod` is a generic, single-owner production overlay: the base plus a CloudNativePG database, with the server's settings made explicit. It contains no domains, gateway wiring or credential sources, and `main` carries no image digests. The deployment repository supplies those (see [Site contract](#site-contract)), so the same overlay serves any cluster that provides the prerequisites. +[`charts/mainloop`](../charts/mainloop) deploys a single-owner production instance: the server and web app, an optional CloudNativePG database, and the server's settings as values. It contains no domains, gateway wiring or credential sources; the deployment supplies those (see [Site contract](#site-contract)), so the same chart serves any cluster that provides the prerequisites. [`ci/production-values.yaml`](../charts/mainloop/ci/production-values.yaml) is a complete production values file, and the render tests in `server/internal/deploytest` check it. -Native sessions run as kagent harness Agents on Substrate, so the cluster needs kagent and Substrate before Mainloop can serve a turn. The overlay does not install either. +Native sessions run as kagent harness Agents on Substrate, so the cluster needs kagent and Substrate before Mainloop can serve a turn. The chart does not install either. -## What the base and overlay render +## What the chart renders -The base (`k8s/apps/mainloop/base`) renders: +| Resource | Notes | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Namespace (`createNamespace`), ServiceAccount `mainloop-backend` | The backend's own identity; cross-namespace grants are outside the chart | +| ConfigMap `mainloop-config` | Non-secret settings shared by the server containers (`config`). A checksum of it on the backend pod template rolls the pods when it changes | +| Deployment `mainloop-backend` | initContainer `migrate` runs `mainloop migrate up`. Three containers from the server image: `backend` (`serve-api`, :8000), `mcp` (`serve-mcp`, :8002) and `git` (`serve-git`, :8003 read and :8004 push) | +| Deployment `mainloop-frontend` | The web image: a static Go server (`web/serve`) on :3000 | +| Services | `mainloop-backend` (8000), `mainloop-mcp` (80 to 8002), `mainloop-git-read` (80 to 8003), `mainloop-git-push` (80 to 8004), `mainloop-frontend` (3000) | +| NetworkPolicies | `mainloop-backend-ingress` (`networkPolicy.backendIngress`) and `mainloop-git-ingress` | +| `Cluster/mainloop-pg` (`database.cnpg.enabled`) | Single-instance CNPG database. The server connects to `mainloop-pg-rw` directly, with credentials from the generated `mainloop-pg-app` Secret | -| Resource | Notes | -| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Namespace `mainloop`, ServiceAccount `mainloop-backend`, RBAC | The backend's own permissions; cross-namespace grants are outside the base | -| ConfigMap `mainloop-config` | Non-secret settings shared by the server containers | -| Deployment `mainloop-backend` | initContainer `migrate` runs `mainloop migrate up`. Three containers from the server image: `backend` (`serve-api`, :8000), `mcp` (`serve-mcp`, :8002) and `git` (`serve-git`, :8003 read and :8004 push) | -| Deployment `mainloop-frontend` | The web image: a static Go server (`web/serve`) on :3000 | -| Services | `mainloop-backend` (8000), `mainloop-mcp` (80 to 8002), `mainloop-git-read` (80 to 8003), `mainloop-git-push` (80 to 8004), `mainloop-frontend` (3000) | -| NetworkPolicies | `mainloop-backend-ingress` and `mainloop-git-ingress` | +The production values add `strategy: Recreate` (one pod at a time, no surge), read `mainloop-config` and `mainloop-site` only (`importSecretEnv: false`, no whole-Secret import), take the frontend `ORIGIN` from `mainloop-site`, and replace the backend ingress list so only `ate-system` `atenet-egress` reaches `:8002`. -The prod overlay adds: - -| Resource | Notes | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Cluster/mainloop-pg` | New single-instance CNPG database, 10Gi, on the cluster's default storage class. The server connects to the `mainloop-pg-rw` Service directly | -| `mainloop-config` patch | DB host and name, `FRONTEND_SCHEME=https`, `DEV_MODE` and `IS_TEST_ENV` false, the `KAGENT_*` Agent names and gateway URL, and `SUBSTRATE_ROUTER_ADDRESS`. The domains come from `mainloop-site` | -| Backend patch | `strategy: Recreate`, so one pod runs at a time and no surge pod is started. Every server container reads `mainloop-config` and `mainloop-site` only (no whole-Secret import). Database credentials come from `mainloop-pg-app` | -| Frontend patch | `ORIGIN` from `mainloop-site` | -| `mainloop-backend-ingress` patch | Ingress list replaced, not appended: only `ate-system` `atenet-egress` reaches `:8002`. The base's frontend and Tailscale rules on `:8000` are dropped in prod | - -There is no replica hold, archive, backup or staged-promotion machinery. The database is a new Cluster. A different name from any earlier Cluster means an old data volume is never reattached. +There is no replica hold, archive, backup or staged-promotion machinery. ## Site contract -The overlay needs these objects in the `mainloop` namespace. Any deployment can provide them; the Argo composition for this cluster lives in the infrastructure repository. +The chart needs these objects in the release namespace. Any deployment can provide them; the Argo composition for this cluster lives in the infrastructure repository. | Object | Keys | Used for | | ------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -40,39 +31,33 @@ The overlay needs these objects in the `mainloop` namespace. Any deployment can | Secret `mainloop-pg-app` | `username`, `password` | Generated by CNPG | | Secret `ghcr-secret` | dockerconfigjson | Image pulls | | Gateway routes | frontend `:3000`, API `:8000`, wildcard preview host `:8000` | Never `:8002`. The preview wildcard must match exactly one label under the base host: with base `https://preview.example.test`, previews are `----preview.preview.example.test`, so route and certify `*.preview.example.test`. Keep it off any wildcard that also covers the frontend or API hosts | -| NetworkPolicy peer | gateway pods to backend `:8000` | Added as its own policy, since the prod overlay replaces the base ingress list. The gateway's labels belong to the cluster | -| Images | `ghcr.io/oldsj/mainloop-backend`, `ghcr.io/oldsj/mainloop-frontend` | Pin by digest, in the deployment repository or through the [`release` branch](#production-changes-are-gitops-only). The `Publish images` workflow publishes them on pushes to `main` | +| NetworkPolicy peer | gateway pods to backend `:8000` | Added as its own policy, since the production values replace the default ingress list. The gateway's labels belong to the cluster | +| Images | `ghcr.io/oldsj/mainloop-backend`, `ghcr.io/oldsj/mainloop-frontend` | Pinned by digest in each published chart version ([below](#production-changes-are-gitops-only)) | The frontend image bakes the API URL at build time (`VITE_API_URL`); the runtime `ORIGIN` does not change it. Build it for the deployment's API domain. -Cross-namespace pieces are owned by the cluster repository, not this overlay: the kagent Agents named in `mainloop-config`, the Role granting `mainloop-backend` namespace-wide Secret `get/create/delete` in `kagent`, the MCP `RemoteMCPServer` (source in `k8s/integrations/kagent/`, alongside that Role; no overlay in this repo applies it), and policies that let the backend reach kagent `:8083` and the Substrate router `:8081`. Mainloop creates runtime-owned `mainloop-mcp-` Secrets labeled `mainloop.dev/actor-egress=true` and `mainloop.dev/purpose=mcp`. RBAC cannot constrain dynamic creates by name or label; admission policy can restrict writes, while prefix-scoped reads require a separate broker or authorization boundary. +Cross-namespace pieces are owned by the cluster repository, not this chart: the kagent Agents named in `mainloop-config`, the Role granting `mainloop-backend` namespace-wide Secret `get/create/delete` in `kagent`, the MCP `RemoteMCPServer` (source in `k8s/integrations/kagent/`, alongside that Role; the chart does not render it), and policies that let the backend reach kagent `:8083` and the Substrate router `:8081`. Mainloop creates runtime-owned `mainloop-mcp-` Secrets labeled `mainloop.dev/actor-egress=true` and `mainloop.dev/purpose=mcp`. RBAC cannot constrain dynamic creates by name or label; admission policy can restrict writes, while prefix-scoped reads require a separate broker or authorization boundary. ## Production changes are GitOps-only -Argo CD applies this overlay (with the site layer) from Git. The repository has no Make target that applies, restarts or resets anything in a production cluster. To ship a change: +Argo CD (or any GitOps tool) renders the published chart with the deployment's values. The repository has no Make target that applies, restarts or resets anything in a production cluster. To ship a change: 1. Merge to `main`. The `Publish images` workflow pushes `linux/arm64` images tagged with the first seven characters of the commit SHA. -2. Its `release` job then force-pushes the `release` branch: the merged `main` commit plus one `Release ` commit that adds an `images:` block with those two digests to `k8s/apps/mainloop/overlays/prod/kustomization.yaml`. It also pushes a lightweight tag `release-` for that commit. The job releases only while its commit is still the tip of `main` (so a re-run or late run of an older commit skips with a notice), refuses an overlay that already has an `images:` key, renders the overlay, and pushes only if both digests appear. It uses `GITHUB_TOKEN`, so the push starts no workflow. -3. Argo CD tracks `release` (`targetRevision: release`) for the Mainloop sources and rolls the new digests out. The deployment repository keeps only site values and no per-release pin. A deployment that prefers its own pins can keep a `kustomize.images` list and copy the digests from the job summary instead. - -Only CI writes `release`. Protect it with a GitHub ruleset that restricts creation, update and deletion, with bypass for repository admins and GitHub Actions only (never the Mainloop GitHub App). The push gate also refuses a `release` push from any Mainloop task. +2. Its `chart` job then packages `charts/mainloop` as `oci://ghcr.io/oldsj/charts/mainloop`, version `0.1.` and app version ``, with both image digests written into the chart's values. It renders the chart and pushes only if both digests appear. Run numbers only grow, so a re-run or a late run of an older commit never outranks a newer chart. +3. A deployment that tracks `0.1.*` (Argo `targetRevision: 0.1.*`) rolls the new chart out. The deployment repository keeps only its values and site objects, and needs no change per release. `GET /health` reports `revision`, the commit the running image was built from (`dev` for an unstamped build). After a release, compare it with the merged commit. -Mainloop deploys itself this way, so a bad merge can take down the thing that would fix it, and CI cannot smoke production. Every merge also restarts the backend. Each release rewrites `release`, so the previous one is no longer in its history; the tag keeps it. The release job's summary prints the exact rollback for the release before it. To roll back from any clone: - -```bash -git fetch origin tag release- && git push --force origin release-:refs/heads/release -``` +Mainloop deploys itself this way, so a bad merge can take down the thing that would fix it, and CI cannot smoke production. Every merge also restarts the backend. To roll back, pin the deployment to the previous chart version (each job summary names its version; `helm show chart oci://ghcr.io/oldsj/charts/mainloop --version ` shows its app version), then return to `0.1.*` once `main` is fixed. -Changes to the deployment repository's site ConfigMap do not restart the backend by themselves; bump the annotation it patches onto the pod template. +Changes to the deployment's site ConfigMap do not restart the backend by themselves, because it is read through `envFrom`; change a `server.podAnnotations` value with each site change to roll the pods. For a local image build, use `docker build --target prod server` for the server and `docker build -f web/Dockerfile .` for the web app. ## Network boundary and preview risk - Mainloop does no per-request authentication. Reachability (the tailnet and the gateway) is the access control, and `MAINLOOP_OWNER_ID` scopes workspaces and previews. Do not trust `X-User-ID`. -- On `mainloop-backend-ingress`, this overlay admits only the actor egress proxy, to `:8002`. The gateway's `:8000` peer comes from the deployment repository as a separate policy, because its labels are cluster-specific. Actors reach MCP only through the credential-injecting egress path. The frontend makes no server-side calls to the backend, so prod grants it nothing. +- On `mainloop-backend-ingress`, the production values admit only the actor egress proxy, to `:8002`. The gateway's `:8000` peer comes from the deployment repository as a separate policy, because its labels are cluster-specific. Actors reach MCP only through the credential-injecting egress path. The frontend makes no server-side calls to the backend, so prod grants it nothing. - Previews run agent-controlled code on a host that shares a registrable domain with the API. This is accepted for a single owner and is not a multi-user boundary. The server's unknown-Host 404 (except `/health`), foreign-Origin write rejection and owner scoping are the controls. Verify them through the real gateway before relying on them, together with wildcard DNS and certificate coverage and WebSocket upgrades. - Any `NetworkPolicy` evidence needs an allowed control next to each blocked probe, and must consider every policy selecting the destination, including Cilium policies. @@ -94,7 +79,7 @@ Use an explicit context. ## Verification ```sh -kubectl kustomize k8s/apps/mainloop/overlays/prod +helm template mainloop charts/mainloop -n mainloop -f charts/mainloop/ci/production-values.yaml ``` -Check that the render has no `$patch` directive and that each server container's `envFrom` lists only `mainloop-config` and `mainloop-site`. A repository-only render does not include the site objects above; validate the composed Application on the target cluster. +Check that each server container's `envFrom` lists only `mainloop-config` and `mainloop-site`; `go test ./internal/deploytest/` in `server/` asserts this and more. A repository-only render does not include the site objects above; validate the composed Application on the target cluster. diff --git a/docs/specs/devloop.md b/docs/specs/devloop.md index 98ae676b..c4a83f0f 100644 --- a/docs/specs/devloop.md +++ b/docs/specs/devloop.md @@ -290,12 +290,12 @@ distinct remote state to observe first, unlike a git push or a GitHub mutation. ## Operator configuration -| Setting | Effect | -| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MAINLOOP_DEVLOOP_ENABLED` | Exactly `true` turns on tool visibility ([agentauth.Policy.DevLoop]) and registers the operations reconcile loop in `serve-api`. Off in the base config; on in the prod overlay by owner decision (see the boundary paragraph above). | -| GitHub App configured | Required for `dev_sync`'s fetch step; without it, `dev_sync` fails with a clear detail and the other tools still work. | -| In-cluster (`KUBERNETES_SERVICE_HOST` set) | Required for the Kubernetes client (`devloop.NewInClusterPods`); outside a cluster `serve-api` builds no `OperationsService` and every devloop tool answers `[unavailable] devloop is not available`. | -| RBAC | `k8s/apps/mainloop/overlays/prod/devloop/` holds a namespaced `Role` in `mainloop-dev` (`pods` get/list, `pods/log` get, `pods/exec` create) and a `RoleBinding` to the production `mainloop-backend` ServiceAccount (namespace `mainloop`; confirmed against `k8s/apps/mainloop/base/rbac-backend.yaml`). There is no ClusterRole. It is applied by Argo as a second source of `mainloop-dev-config`, pinned to the same mainloop revision as the production app. Namespace mainloop-dev is owned by the cluster repository's Argo Application `mainloop-dev-config` (automated prune and selfHeal). A hand-applied copy is not tracked by it, `make dev-live-purge` deletes it with the namespace, and nothing recreates it. A `vcluster`-kind target needs a second, separate grant (reading its kubeconfig Secret, plus RBAC inside the vcluster itself); see "vcluster targets" below. Neither exists yet, so a vcluster-kind sync still fails with `devloop.ErrVClusterNotConfigured` in production even though `devloop.OperationsService.VClusters` is now wired (`cmd/mainloop/devloop.go`). | +| Setting | Effect | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MAINLOOP_DEVLOOP_ENABLED` | Exactly `true` turns on tool visibility ([agentauth.Policy.DevLoop]) and registers the operations reconcile loop in `serve-api`. Off in the base config; on in the prod overlay by owner decision (see the boundary paragraph above). | +| GitHub App configured | Required for `dev_sync`'s fetch step; without it, `dev_sync` fails with a clear detail and the other tools still work. | +| In-cluster (`KUBERNETES_SERVICE_HOST` set) | Required for the Kubernetes client (`devloop.NewInClusterPods`); outside a cluster `serve-api` builds no `OperationsService` and every devloop tool answers `[unavailable] devloop is not available`. | +| RBAC | The deployment provides a namespaced `Role` in `mainloop-dev` (`pods` get/list, `pods/log` get, `pods/exec` create) and a `RoleBinding` to the production `mainloop-backend` ServiceAccount (namespace `mainloop`, rendered by `charts/mainloop`). There is no ClusterRole. The infrastructure repository applies it through Argo's `mainloop-dev-config`. Namespace mainloop-dev is owned by the cluster repository's Argo Application `mainloop-dev-config` (automated prune and selfHeal). A hand-applied copy is not tracked by it, `make dev-live-purge` deletes it with the namespace, and nothing recreates it. A `vcluster`-kind target needs a second, separate grant (reading its kubeconfig Secret, plus RBAC inside the vcluster itself); see "vcluster targets" below. Neither exists yet, so a vcluster-kind sync still fails with `devloop.ErrVClusterNotConfigured` in production even though `devloop.OperationsService.VClusters` is now wired (`cmd/mainloop/devloop.go`). | No credential, kubeconfig or Kubernetes client ever reaches a workspace: `serve-mcp`'s process (where every devloop tool handler runs) never imports a Kubernetes client package at all, the same @@ -348,9 +348,8 @@ can grant, and neither of which this slice changes: 1. A `Role` in namespace `kagent-staging` granting `get` on `secrets` with `resourceNames: [vc-kv]` (or whichever vcluster the target names), bound by a `RoleBinding` to ServiceAccount `mainloop-backend` in namespace `mainloop` — `serve-api`'s own ServiceAccount, - confirmed against `k8s/apps/mainloop/base/rbac-backend.yaml` and the existing mainloop-dev - `RoleBinding` in `k8s/apps/mainloop/overlays/prod/devloop/rolebinding.yaml`, which binds the - same ServiceAccount. This is additive to that existing mainloop-dev RBAC (see the table above): + rendered by `charts/mainloop`, and bound by the existing mainloop-dev devloop `RoleBinding` + in the infrastructure repository. This is additive to that existing mainloop-dev RBAC (see the table above): it grants reading one named Secret in a namespace devloop does not otherwise touch, not `pods`/`exec` on the host cluster. This grant lives in the infra repository that owns `kagent-staging`, not here. diff --git a/k8s/apps/mainloop/base/configmap.yaml b/k8s/apps/mainloop/base/configmap.yaml deleted file mode 100644 index bc2329fd..00000000 --- a/k8s/apps/mainloop/base/configmap.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: mainloop-config - namespace: mainloop -data: - HOST: 0.0.0.0 - PORT: '8000' - DB_HOST: mainloop-db-pooler.mainloop - DB_PORT: '5432' - DB_NAME: mainloop - FRONTEND_DOMAIN: mainloop.example.com # Override in kustomize overlay - # Merge tools and owner write routes remain off pending deployed authority proof. - MAINLOOP_MERGE_TOOLS_ENABLED: 'false' - MAINLOOP_MERGE_CONFIGURATIONS: '[]' - # Pinned explicitly so a value baked into an image (e.g. by an ENV in a Dockerfile) can never - # win over the deployed config. Enable only through this checked-in config, not the image. - MAINLOOP_MERGE_PROTECTED_AUTO: 'false' - # The live dev loop (dev_sync and the lease tools) stays off except where an overlay turns it on. - MAINLOOP_DEVLOOP_ENABLED: 'false' diff --git a/k8s/apps/mainloop/base/deployment-backend.yaml b/k8s/apps/mainloop/base/deployment-backend.yaml deleted file mode 100644 index 2485cd5b..00000000 --- a/k8s/apps/mainloop/base/deployment-backend.yaml +++ /dev/null @@ -1,273 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-backend - namespace: mainloop -spec: - replicas: 1 - selector: - matchLabels: - app: mainloop-backend - template: - metadata: - labels: - app: mainloop-backend - spec: - serviceAccountName: mainloop-backend - securityContext: - fsGroup: 1000 - imagePullSecrets: - - name: ghcr-secret - initContainers: - # Brings the schema to the latest version before any listener starts. `migrate up` - # takes the migrator lock, so a restart racing another pod waits instead of failing. - - name: migrate - image: ghcr.io/oldsj/mainloop-backend:latest - command: [/usr/local/bin/mainloop, migrate, up] - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - seccompProfile: - type: RuntimeDefault - envFrom: - - configMapRef: - name: mainloop-config - env: - - name: DB_USER - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: db-username - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: db-password - resources: - requests: - memory: 64Mi - cpu: 50m - limits: - memory: 256Mi - cpu: 500m - containers: - - name: backend - image: ghcr.io/oldsj/mainloop-backend:latest - command: [/usr/local/bin/mainloop, serve-api] - ports: - - containerPort: 8000 - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - seccompProfile: - type: RuntimeDefault - envFrom: - - configMapRef: - name: mainloop-config - - secretRef: - name: mainloop-secrets - env: - - name: DB_USER - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: db-username - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: db-password - - name: AGENT_TOKEN_KEY - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: agent-token-key - - name: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-app-id - - name: GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-app-private-key - resources: - requests: - memory: 1Gi - cpu: 250m - limits: - memory: 2Gi - cpu: 1000m - livenessProbe: - httpGet: - path: /health - port: 8000 - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /health - port: 8000 - initialDelaySeconds: 5 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 - - - name: mcp - image: ghcr.io/oldsj/mainloop-backend:latest - command: [/usr/local/bin/mainloop, serve-mcp] - ports: - - containerPort: 8002 - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - seccompProfile: - type: RuntimeDefault - envFrom: - - configMapRef: - name: mainloop-config - - secretRef: - name: mainloop-secrets - env: - - name: DB_USER - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: db-username - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: db-password - - name: AGENT_TOKEN_KEY - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: agent-token-key - - name: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-app-id - - name: GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-app-private-key - resources: - requests: - memory: 1Gi - cpu: 250m - limits: - memory: 2Gi - cpu: 1000m - readinessProbe: - httpGet: - path: /health - port: 8002 - initialDelaySeconds: 5 - periodSeconds: 5 - livenessProbe: - httpGet: - path: /health - port: 8002 - initialDelaySeconds: 30 - periodSeconds: 10 - - - name: git - image: ghcr.io/oldsj/mainloop-backend:latest - command: [/usr/local/bin/mainloop, serve-git] - ports: - - name: git-read - containerPort: 8003 - - name: git-push - containerPort: 8004 - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - seccompProfile: - type: RuntimeDefault - envFrom: - - configMapRef: - name: mainloop-config - - secretRef: - name: mainloop-secrets - env: - - name: DB_USER - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: db-username - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: db-password - - name: AGENT_TOKEN_KEY - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: agent-token-key - - name: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-app-id - - name: GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-app-private-key - volumeMounts: - - name: git-spool - mountPath: /var/lib/mainloop/git - resources: - requests: - memory: 256Mi - cpu: 100m - ephemeral-storage: 1Gi - limits: - memory: 2Gi - cpu: 1000m - ephemeral-storage: 3Gi - # The read listener answers /health only when the process is up; a failing push - # listener ends the process, so one probe covers both ports. - readinessProbe: - httpGet: - path: /health - port: git-read - initialDelaySeconds: 5 - periodSeconds: 5 - livenessProbe: - httpGet: - path: /health - port: git-read - initialDelaySeconds: 30 - periodSeconds: 10 - volumes: - - name: git-spool - emptyDir: - sizeLimit: 3Gi diff --git a/k8s/apps/mainloop/base/deployment-frontend.yaml b/k8s/apps/mainloop/base/deployment-frontend.yaml deleted file mode 100644 index dac462f9..00000000 --- a/k8s/apps/mainloop/base/deployment-frontend.yaml +++ /dev/null @@ -1,65 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-frontend - namespace: mainloop -spec: - replicas: 1 - selector: - matchLabels: - app: mainloop-frontend - template: - metadata: - labels: - app: mainloop-frontend - spec: - securityContext: - runAsNonRoot: true - runAsUser: 1001 - runAsGroup: 1001 - fsGroup: 1001 - seccompProfile: - type: RuntimeDefault - imagePullSecrets: - - name: ghcr-secret - containers: - - name: frontend - image: ghcr.io/oldsj/mainloop-frontend:latest - ports: - - containerPort: 3000 - securityContext: - runAsNonRoot: true - runAsUser: 1001 - runAsGroup: 1001 - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - seccompProfile: - type: RuntimeDefault - env: - - name: ORIGIN - value: https://mainloop.example.com - resources: - requests: - memory: 128Mi - cpu: 100m - limits: - memory: 256Mi - cpu: 250m - livenessProbe: - httpGet: - path: / - port: 3000 - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: / - port: 3000 - initialDelaySeconds: 10 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 diff --git a/k8s/apps/mainloop/base/kustomization.yaml b/k8s/apps/mainloop/base/kustomization.yaml deleted file mode 100644 index 188d287f..00000000 --- a/k8s/apps/mainloop/base/kustomization.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: mainloop - -resources: - - namespace.yaml - - rbac-backend.yaml - - deployment-backend.yaml - - deployment-frontend.yaml - - service-backend.yaml - - service-frontend.yaml - - configmap.yaml - - service-mcp.yaml - - service-git-read.yaml - - service-git-push.yaml - - networkpolicy.yaml diff --git a/k8s/apps/mainloop/base/namespace.yaml b/k8s/apps/mainloop/base/namespace.yaml deleted file mode 100644 index a511080c..00000000 --- a/k8s/apps/mainloop/base/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: mainloop diff --git a/k8s/apps/mainloop/base/networkpolicy.yaml b/k8s/apps/mainloop/base/networkpolicy.yaml deleted file mode 100644 index 5a098f4b..00000000 --- a/k8s/apps/mainloop/base/networkpolicy.yaml +++ /dev/null @@ -1,57 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: mainloop-backend-ingress -spec: - podSelector: - matchLabels: - app: mainloop-backend - policyTypes: [Ingress] - ingress: - - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: ate-system - podSelector: - matchLabels: - app: atenet-egress - ports: - - protocol: TCP - port: 8002 - - from: - - podSelector: - matchLabels: - app: mainloop-frontend - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: tailscale - podSelector: - matchLabels: - gateway.networking.k8s.io/gateway-name: tailscale-gateway - ports: - - protocol: TCP - port: 8000 ---- -# Separate policy survives overlays that replace the REST/MCP ingress rules. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: mainloop-git-ingress -spec: - podSelector: - matchLabels: - app: mainloop-backend - policyTypes: [Ingress] - ingress: - - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: ate-system - podSelector: - matchLabels: - app: atenet-egress - ports: - - protocol: TCP - port: 8003 - - protocol: TCP - port: 8004 diff --git a/k8s/apps/mainloop/base/rbac-backend.yaml b/k8s/apps/mainloop/base/rbac-backend.yaml deleted file mode 100644 index f1e393eb..00000000 --- a/k8s/apps/mainloop/base/rbac-backend.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: mainloop-backend - namespace: mainloop diff --git a/k8s/apps/mainloop/base/service-backend.yaml b/k8s/apps/mainloop/base/service-backend.yaml deleted file mode 100644 index 82dc7808..00000000 --- a/k8s/apps/mainloop/base/service-backend.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: mainloop-backend - namespace: mainloop -spec: - selector: - app: mainloop-backend - ports: - - protocol: TCP - port: 8000 - targetPort: 8000 - type: ClusterIP diff --git a/k8s/apps/mainloop/base/service-frontend.yaml b/k8s/apps/mainloop/base/service-frontend.yaml deleted file mode 100644 index 343be6fa..00000000 --- a/k8s/apps/mainloop/base/service-frontend.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: mainloop-frontend - namespace: mainloop -spec: - selector: - app: mainloop-frontend - ports: - - port: 3000 - targetPort: 3000 diff --git a/k8s/apps/mainloop/base/service-git-push.yaml b/k8s/apps/mainloop/base/service-git-push.yaml deleted file mode 100644 index 2ca7878a..00000000 --- a/k8s/apps/mainloop/base/service-git-push.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: mainloop-git-push - namespace: mainloop -spec: - selector: - app: mainloop-backend - ports: - - name: git-push - port: 80 - targetPort: 8004 diff --git a/k8s/apps/mainloop/base/service-git-read.yaml b/k8s/apps/mainloop/base/service-git-read.yaml deleted file mode 100644 index abe60148..00000000 --- a/k8s/apps/mainloop/base/service-git-read.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: mainloop-git-read - namespace: mainloop -spec: - selector: - app: mainloop-backend - ports: - - name: git-read - port: 80 - targetPort: 8003 diff --git a/k8s/apps/mainloop/base/service-mcp.yaml b/k8s/apps/mainloop/base/service-mcp.yaml deleted file mode 100644 index 5757bad0..00000000 --- a/k8s/apps/mainloop/base/service-mcp.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: mainloop-mcp -spec: - selector: - app: mainloop-backend - ports: - - name: mcp - port: 80 - targetPort: 8002 diff --git a/k8s/apps/mainloop/overlays/prod/backend-patch.yaml b/k8s/apps/mainloop/overlays/prod/backend-patch.yaml deleted file mode 100644 index c7922acc..00000000 --- a/k8s/apps/mainloop/overlays/prod/backend-patch.yaml +++ /dev/null @@ -1,73 +0,0 @@ -# REST (backend), MCP and Git share one Deployment and one image, and all read the same -# configuration; the migrate initContainer needs only the database. -# -# envFrom has no merge key, so listing it here replaces the base list: the -# whole mainloop-secrets Secret is no longer imported. env is merged by name. -# -# The API process runs the reconcilers, so replace pods instead of surging a second one, and -# size the MCP container for its small footprint (the base requests are for the REST container). -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-backend - namespace: mainloop -spec: - strategy: - type: Recreate - template: - metadata: - annotations: - # Pods read mainloop-config as env vars, which only load at start. Bump this whenever - # config-patch.yaml changes so the rollout picks the new values up. - mainloop.olds.network/config-revision: '3' - spec: - initContainers: - - name: migrate - env: - - name: DB_USER - valueFrom: - secretKeyRef: - name: mainloop-pg-app - key: username - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: mainloop-pg-app - key: password - containers: - - name: backend - envFrom: &envFrom - - configMapRef: - name: mainloop-config - # Site values: FRONTEND_DOMAIN, API_DOMAIN, MAINLOOP_OWNER_ID, SUBSTRATE_PREVIEW_BASE_URL - - configMapRef: - name: mainloop-site - env: &env - - name: DB_USER - valueFrom: - secretKeyRef: - name: mainloop-pg-app - key: username - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: mainloop-pg-app - key: password - - name: AGENT_TOKEN_KEY - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: agent-token-key - - name: mcp - envFrom: *envFrom - env: *env - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 1Gi - - name: git - envFrom: *envFrom - env: *env diff --git a/k8s/apps/mainloop/overlays/prod/config-patch.yaml b/k8s/apps/mainloop/overlays/prod/config-patch.yaml deleted file mode 100644 index b7ef32f1..00000000 --- a/k8s/apps/mainloop/overlays/prod/config-patch.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: mainloop-config - namespace: mainloop -data: - DB_HOST: mainloop-pg-rw.mainloop.svc.cluster.local - DB_PORT: '5432' - DB_NAME: mainloop - # Domains come from the site ConfigMap (mainloop-site), not from this base value. - FRONTEND_DOMAIN: null - FRONTEND_SCHEME: https - MAINLOOP_API_HOSTS: mainloop-backend,mainloop-backend.mainloop,mainloop-backend.mainloop.svc,mainloop-backend.mainloop.svc.cluster.local - DEV_MODE: 'false' - IS_TEST_ENV: 'false' - KAGENT_GATEWAY_URL: http://kagent-controller.kagent.svc.cluster.local:8083 - KAGENT_USER_ID: mainloop - KAGENT_NAMESPACE: kagent - KAGENT_ACTOR_ATESPACE: kagent - KAGENT_MAIN_AGENT: mainloop-main - KAGENT_CLAUDE_AGENT: claude-subscription - KAGENT_CODEX_AGENT: codex-subscription-https - KAGENT_WORKSPACE_CLAUDE_AGENT: claude-workspace - KAGENT_WORKSPACE_CODEX_AGENT: codex-workspace - SUBSTRATE_ROUTER_ADDRESS: http://atenet-router.ate-system.svc.cluster.local:8081 - # Bump mainloop.olds.network/config-revision in backend-patch.yaml when this file changes. - # Owner decision 2026-10-17: Mainloop's own PRs that touch protected paths merge on green - # without an owner card. Matches are still recorded on the proposal (docs/specs/merge-policy.md). - MAINLOOP_MERGE_PROTECTED_AUTO: 'true' - # On by owner decision 2026-10-17: synced agent code runs in mainloop-dev with credentials shared - # with production; see "The boundary this does not give you" in docs/specs/devloop.md. - MAINLOOP_DEVLOOP_ENABLED: 'true' diff --git a/k8s/apps/mainloop/overlays/prod/database.yaml b/k8s/apps/mainloop/overlays/prod/database.yaml deleted file mode 100644 index 96578a63..00000000 --- a/k8s/apps/mainloop/overlays/prod/database.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Fresh CloudNativePG cluster. CNPG generates the application credentials in -# Secret mainloop-pg-app (keys username, password). The backend reads them from there. -# The backend connects to the rw Service directly: one instance, one client app, and no -# pooler whose pool could stall DBOS LISTEN/NOTIFY sessions. -apiVersion: postgresql.cnpg.io/v1 -kind: Cluster -metadata: - name: mainloop-pg - namespace: mainloop -spec: - instances: 1 - storage: - size: 10Gi - bootstrap: - initdb: - database: mainloop - owner: mainloop - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: '1' - memory: 1Gi diff --git a/k8s/apps/mainloop/overlays/prod/devloop/kustomization.yaml b/k8s/apps/mainloop/overlays/prod/devloop/kustomization.yaml deleted file mode 100644 index bf6f556d..00000000 --- a/k8s/apps/mainloop/overlays/prod/devloop/kustomization.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -# No top-level `namespace:`: the RoleBinding below must stay in mainloop-dev even though this -# overlay's own resources render into `mainloop`. This mirrors -# the live instance's kagent/ kustomization, standalone for the same reason (a resource that must -# land in a namespace other than the one `../../base` renders into). -# -# Applied by Argo as a second source of `mainloop-dev-config`. Namespace mainloop-dev has a -# GitOps owner: the cluster repository's Argo Application `mainloop-dev-config` (automated prune -# and selfHeal, CreateNamespace). This directory is a second source of that Application, pinned to -# the same mainloop revision as the production app. Applied by hand it is not tracked by Argo, -# `make dev-live-purge` deletes it with the namespace, and nothing recreates it. -resources: - - role.yaml - - rolebinding.yaml diff --git a/k8s/apps/mainloop/overlays/prod/devloop/role.yaml b/k8s/apps/mainloop/overlays/prod/devloop/role.yaml deleted file mode 100644 index 2c4e23d2..00000000 --- a/k8s/apps/mainloop/overlays/prod/devloop/role.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# What the production backend may do in mainloop-dev: read pod status and logs, and exec into -# mainloop-dev's own containers to copy a synced commit and check the backend's /health. Namespaced -# on purpose (no ClusterRole), so nothing outside mainloop-dev is ever granted. See -# docs/specs/devloop.md. -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: mainloop-devloop - namespace: mainloop-dev -rules: - - apiGroups: [''] - resources: [pods] - verbs: [get, list] - - apiGroups: [''] - resources: [pods/log] - verbs: [get] - - apiGroups: [''] - resources: [pods/exec] - verbs: [create] diff --git a/k8s/apps/mainloop/overlays/prod/devloop/rolebinding.yaml b/k8s/apps/mainloop/overlays/prod/devloop/rolebinding.yaml deleted file mode 100644 index d1d57861..00000000 --- a/k8s/apps/mainloop/overlays/prod/devloop/rolebinding.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Binds the Role to the production backend's ServiceAccount, and to no other subject. See -# docs/specs/devloop.md. -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: mainloop-devloop - namespace: mainloop-dev -subjects: - - kind: ServiceAccount - name: mainloop-backend - namespace: mainloop -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: mainloop-devloop diff --git a/k8s/apps/mainloop/overlays/prod/frontend-patch.yaml b/k8s/apps/mainloop/overlays/prod/frontend-patch.yaml deleted file mode 100644 index 05fa5cfe..00000000 --- a/k8s/apps/mainloop/overlays/prod/frontend-patch.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# The origin is site-specific; take it from the site ConfigMap. -- op: replace - path: /spec/template/spec/containers/0/env - value: - - name: ORIGIN - valueFrom: - configMapKeyRef: - name: mainloop-site - key: FRONTEND_ORIGIN diff --git a/k8s/apps/mainloop/overlays/prod/kustomization.yaml b/k8s/apps/mainloop/overlays/prod/kustomization.yaml deleted file mode 100644 index ba098b3a..00000000 --- a/k8s/apps/mainloop/overlays/prod/kustomization.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -# Generic production overlay. Site-specific values (domains, owner id, gateway -# routes, Secret sources) are supplied by the deployment repository; see -# docs/production-rollout.md for the contract. Image digests are supplied there too, -# or by the `release` branch, where the Publish images workflow adds an `images:` -# block to this file. Main never carries one. - -namespace: mainloop - -resources: - - ../../base - - database.yaml - -patches: - - path: config-patch.yaml - - path: backend-patch.yaml - - path: frontend-patch.yaml - target: - kind: Deployment - name: mainloop-frontend - - path: networkpolicy-patch.yaml - target: - kind: NetworkPolicy - name: mainloop-backend-ingress diff --git a/k8s/apps/mainloop/overlays/prod/networkpolicy-patch.yaml b/k8s/apps/mainloop/overlays/prod/networkpolicy-patch.yaml deleted file mode 100644 index a3f109ed..00000000 --- a/k8s/apps/mainloop/overlays/prod/networkpolicy-patch.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# NetworkPolicies are additive, so replace the base list instead of appending. -# Only the actor egress proxy may reach the MCP listener. The browser reaches -# the API through the gateway, and the deployment repository adds that peer as a -# separate policy, because the gateway's labels are specific to the cluster. -# The frontend makes no server-side calls to the backend. -- op: replace - path: /spec/ingress - value: - - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: ate-system - podSelector: - matchLabels: - app: atenet-egress - ports: - - protocol: TCP - port: 8002