diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 7ee54b53..80406e20 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -85,6 +85,62 @@ jobs: printf 'Pin: `%s@%s`\n' "$IMAGE" "$DIGEST" } >> "$GITHUB_STEP_SUMMARY" + # Publishes charts/mainloop to oci://ghcr.io/oldsj/charts/mainloop as 0.1., with this + # commit's image digests pinned in its values. Deployments track the newest 0.1.* version. Run + # numbers only grow, so a re-run or a late older run never outranks a newer commit's chart. + chart: + name: Publish the chart + needs: publish + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - 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: Package and push + shell: bash + env: + VERSION: 0.1.${{ github.run_number }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag="${GITHUB_SHA:0:7}" + 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)" + values=charts/mainloop/values.yaml + yq -i ".images.backend.tag = \"${tag}\" | .images.backend.digest = \"${backend}\"" "$values" + yq -i ".images.frontend.tag = \"${tag}\" | .images.frontend.digest = \"${frontend}\"" "$values" + rendered="$(helm template mainloop charts/mainloop)" + grep -qF "mainloop-backend@${backend}" <<<"$rendered" + grep -qF "mainloop-frontend@${frontend}" <<<"$rendered" + helm package charts/mainloop --version "$VERSION" --app-version "$tag" --destination "$RUNNER_TEMP" + helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin <<<"$GHCR_TOKEN" + helm push "$RUNNER_TEMP/mainloop-${VERSION}.tgz" oci://ghcr.io/oldsj/charts + { + printf '### Chart mainloop %s\n\n' "$VERSION" + printf 'Pull: `oci://ghcr.io/oldsj/charts/mainloop --version %s`\n\n' "$VERSION" + 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 diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 118eb9c5..c3777c5d 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -29,6 +29,10 @@ lint: - linters: [hadolint, terrascan] paths: - '**/*Dockerfile*' + # Helm templates are Go templates, not YAML; helm lint and the deploytest renders cover them. + - linters: [yamllint, prettier, checkov, trivy] + paths: + - charts/*/templates/** # Written by the TanStack Router plugin on every vite run; its output is committed as written - linters: [ALL] paths: diff --git a/Makefile b/Makefile index e3968b82..74d47692 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ dev-reset: ## Wipe the Kind database and restart the backend @./scripts/kind/reset-data.sh # ============================================================================= -# Shared live instance (DevSpace profile `live`, namespace mainloop-dev on internal-01) +# Shared live instance (DevSpace profile `live`, namespace mainloop-dev; needs DEV_LIVE_CONTEXT and MAINLOOP_DEV_CONFIG) # ============================================================================= dev-live: ## Deploy mainloop-dev and live-sync the Go server and web app into it diff --git a/README.md b/README.md index 3225a82d..49b2e583 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,6 @@ database and let `mainloop migrate up` create the schema. Main and child Session credential references; the main thread uses its dedicated main Agent. Native session history from earlier development builds is not carried over. -The [Kind overlay](k8s/apps/mainloop/overlays/kind/README.md) runs the Go/React stack without kagent, +The [Kind setup](docs/dev-environment.md#kind) runs the Go/React stack without kagent, so native sessions, workspaces and schedules are unavailable there. A tracked recipe for installing kagent into a local cluster does not exist yet. diff --git a/charts/mainloop/Chart.yaml b/charts/mainloop/Chart.yaml new file mode 100644 index 00000000..6eb8cca7 --- /dev/null +++ b/charts/mainloop/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: mainloop +description: Mainloop's Go server (REST, MCP and Git listeners) and React app. +type: application +# The Publish images workflow packages this chart as .., with +# appVersion set to the short commit SHA and the image digests pinned in values.yaml. Bump the +# minor version here for a values change a deployment has to act on. +version: 0.1.0 +appVersion: dev diff --git a/charts/mainloop/README.md b/charts/mainloop/README.md new file mode 100644 index 00000000..7acee80d --- /dev/null +++ b/charts/mainloop/README.md @@ -0,0 +1,35 @@ +# Mainloop chart + +Deploys Mainloop's Go server (the REST, MCP and Git listeners in one pod) and the React app, with +an optional database: a CloudNativePG `Cluster` (`database.cnpg.enabled`) or a single local +PostgreSQL StatefulSet (`database.local.enabled`). + +CI publishes it on each push to `main` as `oci://ghcr.io/oldsj/charts/mainloop`, version +`0.1.`, with that commit's image digests pinned in its values. Track `0.1.*` to follow +`main`. + +Every setting and its default is in [values.yaml](values.yaml). Example installs, which the render +tests in `server/internal/deploytest` check: + +- [values-kind.yaml](values-kind.yaml): a local Kind cluster (`make kind-up`, `make dev`; see + [docs/dev-environment.md#kind](../../docs/dev-environment.md#kind)). +- [ci/production-values.yaml](ci/production-values.yaml): a production install with CNPG, kagent + control-plane auth and site settings from a `mainloop-site` ConfigMap. +- [ci/live-dev-values.yaml](ci/live-dev-values.yaml): a live-sync dev instance run by DevSpace + (`make dev-live`; see [docs/dev-environment.md#live-dev-instance](../../docs/dev-environment.md#live-dev-instance)). + +The chart reads, but does not create, Secret `mainloop-secrets` (GitHub App keys, agent token key, +and database credentials when CNPG is off), and with `kagentControlAuth.enabled` Secrets +`kagent-control-token` and `kagent-control-ca`. Routes, gateway network policy peers and kagent +objects belong to the deployment. + +## Kind + +- Images `mainloop-backend:kind` and `mainloop-frontend:kind`, loaded into Kind; no pull secret. +- Settings for `http://localhost:5173` and `MAINLOOP_ALLOW_RESET=1`, which lets `make dev-reset` + wipe the database. The frontend `ORIGIN`, `FRONTEND_SCHEME` and `FRONTEND_DOMAIN` must match the + port forward, so change them together. +- Works without kagent: the web app, the owner API and the MCP and Git listeners (Git transport + off). Native sessions, workspaces and schedules need kagent, so `GET /schedules` returns 502. +- No GitHub App, and Kind's default CNI does not enforce the network policies. The secrets are + random values created on your machine; do not reuse them. diff --git a/charts/mainloop/ci/live-dev-values.yaml b/charts/mainloop/ci/live-dev-values.yaml new file mode 100644 index 00000000..20db0fb8 --- /dev/null +++ b/charts/mainloop/ci/live-dev-values.yaml @@ -0,0 +1,164 @@ +# A generic live-sync instance (DevSpace profile `live`), rendered by server/internal/deploytest. A +# real deployment keeps its own values file and passes it as MAINLOOP_DEV_VALUES. + +# The -dev images carry the Go toolchain (server) and Vite (web), built from main. They are public. +images: + backend: + repository: ghcr.io/oldsj/mainloop-server-dev + tag: main + frontend: + repository: ghcr.io/oldsj/mainloop-web-dev + tag: main + pullPolicy: Always +imagePullSecrets: [] + +# Images are published for arm64 only. +nodeSelector: + kubernetes.io/arch: arm64 + +database: + cnpg: + enabled: true + # local-path storage is deleted with the namespace. + storage: + size: 2Gi + storageClass: local-path + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +kagentControlAuth: + enabled: true + +# Each server container runs watchexec over `go run` from /src, which DevSpace syncs server/ into; +# the same commands run when the pods start without `devspace dev`. Each container rebuilds into +# its own Go build cache, on top of the warm cache baked into the image, so each gets its own +# ephemeral-storage budget; a 3Gi pod total was evicted after about ten hot reloads. +server: + strategy: + type: Recreate + importSecretEnv: false + # The instance starts without the GitHub App; AGENT_TOKEN_KEY is still required. + githubAppOptional: true + command: [watchexec, --restart, --exts, 'go,sql', --stop-timeout, 5s, --, go, run, ./cmd/mainloop] + workingDir: /src + # `go run` compiles first, so the probes wait for a cold build. + livenessProbe: + initialDelaySeconds: 120 + failureThreshold: 6 + readinessProbe: + initialDelaySeconds: 20 + failureThreshold: 60 + api: + resources: + requests: + cpu: 100m + memory: 512Mi + ephemeral-storage: 1Gi + limits: + cpu: '2' + memory: 2Gi + ephemeral-storage: 8Gi + mcp: + resources: + requests: + cpu: 50m + memory: 256Mi + ephemeral-storage: 1Gi + limits: + cpu: '2' + memory: 2Gi + ephemeral-storage: 8Gi + git: + resources: + requests: + cpu: 25m + memory: 128Mi + ephemeral-storage: 1Gi + limits: + cpu: '2' + memory: 2Gi + ephemeral-storage: 8Gi + # The dev image keeps the source it was built from at /src, so migrate runs the image's own + # revision before the first sync. + migrate: + command: [go, run, ./cmd/mainloop, migrate, up] + workingDir: /src + resources: + requests: + cpu: 100m + memory: 256Mi + ephemeral-storage: 1Gi + limits: + cpu: '2' + memory: 2Gi + ephemeral-storage: 8Gi + +# Vite dev server with HMR. The browser calls /api on the same host and Vite forwards it to the +# backend Service. +frontend: + command: [/app/web/node_modules/.bin/vite] + workingDir: /app/web + env: + - name: VITE_HOST + value: 0.0.0.0 + - name: VITE_PORT + value: '3000' + - name: VITE_API_URL + value: /api + - name: MAINLOOP_API_PROXY + value: http://mainloop-backend:8000 + # Vite rejects unknown Host headers; allow the gateway host. + - name: __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS + value: mainloop-dev.example.test + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: '2' + memory: 2Gi + livenessProbe: + initialDelaySeconds: 90 + readinessProbe: + initialDelaySeconds: 20 + failureThreshold: 12 + +config: + FRONTEND_DOMAIN: mainloop-dev.example.test + FRONTEND_SCHEME: https + MAINLOOP_API_HOSTS: mainloop-backend,mainloop-backend.mainloop-dev,mainloop-backend.mainloop-dev.svc,mainloop-backend.mainloop-dev.svc.cluster.local + MAINLOOP_OWNER_ID: dev-owner + # Hosts the MCP listener accepts; in-cluster it must include its own Service name. + MAINLOOP_MCP_HOSTS: mainloop-mcp,mainloop-mcp.mainloop-dev,mainloop-mcp.mainloop-dev.svc,mainloop-mcp.mainloop-dev.svc.cluster.local + # Lets `mainloop migrate reset` run in this prototype instance. + MAINLOOP_ALLOW_RESET: '1' + DEV_MODE: 'true' + IS_TEST_ENV: 'false' + NATIVE_CHILD_KINDS: claude,codex + # Derives the MCP and Git origins (*.mainloop-dev.svc.cluster.local) and the mainloop-dev- prefix + # of this instance's credential Secrets in the kagent namespace. + MAINLOOP_NAMESPACE: mainloop-dev + # On: dev Git goes through mainloop-dev's own read/push proxies, not production's. kagent's + # service policy accepts them once its instance allowlist includes mainloop-dev. + GIT_TRANSPORT_ENABLED: 'true' + PUSH_GATE_ENABLED: 'true' + MAINLOOP_MERGE_TOOLS_ENABLED: 'false' + MAINLOOP_OWNER_HITL_WRITES_ENABLED: 'true' + # Dev-only kagent agents; production agents are never used from here. + KAGENT_NAMESPACE: kagent + KAGENT_ACTOR_ATESPACE: kagent + KAGENT_MAIN_AGENT: claude-workspace-dev + # Codex main uses the dev workspace Agent, like the Claude main. The default (mainloop-main-codex) + # is production's Agent on this shared kagent. + KAGENT_MAIN_CODEX_AGENT: codex-workspace-dev + KAGENT_CLAUDE_AGENT: claude-workspace-dev + KAGENT_CODEX_AGENT: codex-workspace-dev + KAGENT_WORKSPACE_CLAUDE_AGENT: claude-workspace-dev + KAGENT_WORKSPACE_CODEX_AGENT: codex-workspace-dev + KAGENT_SUPERVISOR_CLAUDE_AGENT: claude-workspace-dev + KAGENT_SUPERVISOR_CODEX_AGENT: codex-workspace-dev diff --git a/charts/mainloop/ci/production-values.yaml b/charts/mainloop/ci/production-values.yaml new file mode 100644 index 00000000..59f94013 --- /dev/null +++ b/charts/mainloop/ci/production-values.yaml @@ -0,0 +1,97 @@ +# A generic production install, rendered by server/internal/deploytest. A real deployment keeps its +# own values file (domains, agents, owner decisions) in its deployment repository. + +# A GitOps owner tracks the Namespace, so keep it in the render. +createNamespace: true + +server: + # The API process runs the reconcilers and strict projection readers and writers must stay on + # one model version, so replace pods instead of surging a second one. + strategy: + type: Recreate + podAnnotations: + # mainloop-site is read through envFrom, so editing it does not restart the backend. Change + # this value with each site-config change to roll the backend through GitOps. + example.test/site-config: example-1 + # Only the keys the server needs, not the whole mainloop-secrets item. + importSecretEnv: false + # FRONTEND_DOMAIN, API_DOMAIN, MAINLOOP_OWNER_ID, SUBSTRATE_PREVIEW_BASE_URL and feature flags. + extraEnvFrom: + - configMapRef: + name: mainloop-site + # Idle use is about 50Mi for the API and 25Mi each for MCP and Git; the limits keep headroom. + api: + resources: + requests: + memory: 256Mi + cpu: 250m + limits: + memory: 2Gi + cpu: 1000m + mcp: + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 1Gi + cpu: 500m + git: + resources: + requests: + memory: 128Mi + cpu: 100m + ephemeral-storage: 1Gi + limits: + memory: 2Gi + cpu: 1000m + ephemeral-storage: 3Gi + +frontend: + env: + - name: ORIGIN + valueFrom: + configMapKeyRef: + name: mainloop-site + key: FRONTEND_ORIGIN + +database: + cnpg: + enabled: true + +kagentControlAuth: + enabled: true + +config: + # Domains come from mainloop-site. + 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 + +networkPolicy: + # Only the actor egress proxy reaches MCP. The browser reaches the API through the gateway, whose + # peer the deployment adds as a separate policy. The frontend makes no server-side calls to the + # backend. + backendIngress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ate-system + podSelector: + matchLabels: + app: atenet-egress + ports: + - protocol: TCP + port: 8002 diff --git a/charts/mainloop/templates/_helpers.tpl b/charts/mainloop/templates/_helpers.tpl new file mode 100644 index 00000000..c9082908 --- /dev/null +++ b/charts/mainloop/templates/_helpers.tpl @@ -0,0 +1,162 @@ +{{/* An image reference: repository@digest when a digest is set, else repository:tag. */}} +{{- define "mainloop.image" -}} +{{- if .image.digest -}} +{{ .image.repository }}@{{ .image.digest }} +{{- else -}} +{{ .image.repository }}:{{ .image.tag | default .root.Chart.AppVersion }} +{{- end -}} +{{- end -}} + +{{- define "mainloop.backendImage" -}} +{{ include "mainloop.image" (dict "image" .Values.images.backend "root" .) }} +{{- end -}} + +{{- define "mainloop.frontendImage" -}} +{{ include "mainloop.image" (dict "image" .Values.images.frontend "root" .) }} +{{- end -}} + +{{- define "mainloop.pullPolicy" -}} +{{- with .Values.images.pullPolicy }} +imagePullPolicy: {{ . }} +{{- end }} +{{- end -}} + +{{- define "mainloop.dbHost" -}} +{{- if .Values.database.cnpg.enabled -}} +{{ .Values.database.cnpg.name }}-rw.{{ .Release.Namespace }}.svc.cluster.local +{{- else if .Values.database.local.enabled -}} +mainloop-postgres +{{- else -}} +{{ .Values.database.host }} +{{- end -}} +{{- end -}} + +{{/* DB_USER and DB_PASSWORD, from the CNPG app Secret or mainloop-secrets unless overridden. */}} +{{- define "mainloop.dbEnv" -}} +{{- $c := .Values.database.credentials -}} +{{- $cnpg := .Values.database.cnpg.enabled -}} +- name: DB_USER + valueFrom: + secretKeyRef: + name: {{ $c.secretName | default (ternary (printf "%s-app" .Values.database.cnpg.name) "mainloop-secrets" $cnpg) }} + key: {{ $c.usernameKey | default (ternary "username" "db-username" $cnpg) }} +- name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $c.secretName | default (ternary (printf "%s-app" .Values.database.cnpg.name) "mainloop-secrets" $cnpg) }} + key: {{ $c.passwordKey | default (ternary "password" "db-password" $cnpg) }} +{{- end -}} + +{{- define "mainloop.securityContext" -}} +securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault +{{- end -}} + +{{/* envFrom, env and mounts shared by the three listeners. */}} +{{- define "mainloop.serverEnv" -}} +envFrom: + - configMapRef: + name: mainloop-config + {{- if .Values.server.importSecretEnv }} + - secretRef: + name: mainloop-secrets + {{- end }} + {{- with .Values.server.extraEnvFrom }} + {{- toYaml . | nindent 2 }} + {{- end }} +env: + {{- include "mainloop.dbEnv" . | nindent 2 }} + - 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 + {{- if .Values.server.githubAppOptional }} + optional: true + {{- end }} + - name: GITHUB_APP_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: mainloop-secrets + key: github-app-private-key + {{- if .Values.server.githubAppOptional }} + optional: true + {{- end }} + {{- if .Values.kagentControlAuth.enabled }} + - name: KAGENT_GATEWAY_URL + value: {{ .Values.kagentControlAuth.gatewayURL }} + - name: KAGENT_USER_ID + value: {{ .Values.kagentControlAuth.userID }} + - name: KAGENT_CONTROL_TOKEN_FILE + value: /run/kagent-control-auth/current + - name: SSL_CERT_FILE + value: /run/kagent-control-ca/ca-bundle.crt + {{- end }} + {{- with .Values.server.extraEnv }} + {{- toYaml . | nindent 2 }} + {{- end }} +{{- end -}} + +{{- define "mainloop.serverMounts" -}} +{{- if .Values.kagentControlAuth.enabled }} +- name: kagent-control-auth + mountPath: /run/kagent-control-auth + readOnly: true +- name: kagent-control-ca + mountPath: /run/kagent-control-ca + readOnly: true +{{- end }} +{{- with .Values.server.extraVolumeMounts }} +{{ toYaml . }} +{{- end }} +{{- end -}} + +{{/* One listener container. Takes root, name, subcommand, ports, probe port, resources, defaults. */}} +{{- define "mainloop.listener" -}} +{{- $v := .root.Values -}} +- name: {{ .name }} + image: {{ include "mainloop.backendImage" .root }} + {{- include "mainloop.pullPolicy" .root | nindent 2 }} + {{- if $v.server.command }} + command: {{ toJson $v.server.command }} + args: [{{ .subcommand }}] + {{- else }} + command: [/usr/local/bin/mainloop, {{ .subcommand }}] + {{- end }} + {{- with $v.server.workingDir }} + workingDir: {{ . }} + {{- end }} + ports: + {{- toYaml .ports | nindent 4 }} + {{- include "mainloop.securityContext" .root | nindent 2 }} + {{- include "mainloop.serverEnv" .root | nindent 2 }} + {{- $mounts := trim (include "mainloop.serverMounts" .root) }} + {{- if or .mounts $mounts }} + volumeMounts: + {{- with .mounts }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $mounts }} + {{- . | nindent 4 }} + {{- end }} + {{- end }} + resources: + {{- toYaml .resources | nindent 4 }} + livenessProbe: + {{- toYaml (mergeOverwrite (deepCopy .liveness) $v.server.livenessProbe) | nindent 4 }} + readinessProbe: + {{- toYaml (mergeOverwrite (deepCopy .readiness) $v.server.readinessProbe) | nindent 4 }} +{{- end -}} diff --git a/charts/mainloop/templates/configmap.yaml b/charts/mainloop/templates/configmap.yaml new file mode 100644 index 00000000..868a4f27 --- /dev/null +++ b/charts/mainloop/templates/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: mainloop-config + namespace: {{ .Release.Namespace }} +data: + {{- $data := merge (dict) .Values.config }} + {{- if not (hasKey $data "DB_HOST") }} + {{- $_ := set $data "DB_HOST" (include "mainloop.dbHost" .) }} + {{- end }} + {{- range $k, $v := $data }} + {{- if not (kindIs "invalid" $v) }} + {{ $k }}: {{ $v | toString | quote }} + {{- end }} + {{- end }} diff --git a/k8s/apps/mainloop/overlays/kind/postgres.yaml b/charts/mainloop/templates/database.yaml similarity index 69% rename from k8s/apps/mainloop/overlays/kind/postgres.yaml rename to charts/mainloop/templates/database.yaml index 0507372a..426fbecf 100644 --- a/k8s/apps/mainloop/overlays/kind/postgres.yaml +++ b/charts/mainloop/templates/database.yaml @@ -1,8 +1,34 @@ +{{- with .Values.database.cnpg }} +{{- if .enabled }} +# CNPG generates the application credentials in Secret -app (keys username, password). +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: {{ .name }} + namespace: {{ $.Release.Namespace }} +spec: + instances: {{ .instances }} + storage: + size: {{ .storage.size }} + {{- with .storage.storageClass }} + storageClass: {{ . }} + {{- end }} + bootstrap: + initdb: + database: mainloop + owner: mainloop + resources: + {{- toYaml .resources | nindent 4 }} +{{- end }} +{{- end }} +{{- with .Values.database.local }} +{{- if .enabled }} +--- apiVersion: v1 kind: Service metadata: name: mainloop-postgres - namespace: mainloop + namespace: {{ $.Release.Namespace }} spec: clusterIP: None selector: @@ -15,7 +41,7 @@ apiVersion: apps/v1 kind: StatefulSet metadata: name: mainloop-postgres - namespace: mainloop + namespace: {{ $.Release.Namespace }} spec: serviceName: mainloop-postgres replicas: 1 @@ -29,7 +55,7 @@ spec: spec: containers: - name: postgres - image: postgres:16-alpine + image: {{ .image }} env: - name: POSTGRES_DB value: mainloop @@ -63,13 +89,13 @@ spec: accessModes: [ReadWriteOnce] resources: requests: - storage: 1Gi + storage: {{ .storage }} --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: mainloop-postgres-ingress - namespace: mainloop + namespace: {{ $.Release.Namespace }} spec: podSelector: matchLabels: @@ -83,3 +109,5 @@ spec: ports: - protocol: TCP port: 5432 +{{- end }} +{{- end }} diff --git a/charts/mainloop/templates/deployment-backend.yaml b/charts/mainloop/templates/deployment-backend.yaml new file mode 100644 index 00000000..f22861fb --- /dev/null +++ b/charts/mainloop/templates/deployment-backend.yaml @@ -0,0 +1,143 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mainloop-backend + namespace: {{ .Release.Namespace }} +spec: + replicas: 1 + {{- with .Values.server.strategy }} + strategy: + {{- toYaml . | nindent 4 }} + {{- end }} + selector: + matchLabels: + app: mainloop-backend + template: + metadata: + labels: + app: mainloop-backend + annotations: + # Pods read mainloop-config as env vars, which only load at start. + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- if .Values.kagentControlAuth.enabled }} + mainloop.dev/control-auth: service-token-tls-v1 + {{- end }} + {{- with .Values.server.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: mainloop-backend + securityContext: + fsGroup: 1000 + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + {{- if .Values.kagentControlAuth.enabled }} + # Appends the kagent control CA to the default roots for SSL_CERT_FILE. + - name: kagent-control-trust-bundle + image: {{ include "mainloop.backendImage" . }} + {{- include "mainloop.pullPolicy" . | nindent 10 }} + command: + - sh + - -c + - | + cat /etc/ssl/certs/ca-certificates.crt > /run/kagent-control-ca/ca-bundle.crt + echo >> /run/kagent-control-ca/ca-bundle.crt + cat /run/kagent-control-root/ca.crt >> /run/kagent-control-ca/ca-bundle.crt + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { cpu: 100m, memory: 64Mi } + volumeMounts: + - name: kagent-control-root + mountPath: /run/kagent-control-root + readOnly: true + - name: kagent-control-ca + mountPath: /run/kagent-control-ca + {{- end }} + - name: migrate + image: {{ include "mainloop.backendImage" . }} + {{- include "mainloop.pullPolicy" . | nindent 10 }} + command: {{ toJson .Values.server.migrate.command }} + {{- with .Values.server.migrate.workingDir }} + workingDir: {{ . }} + {{- end }} + {{- include "mainloop.securityContext" . | nindent 10 }} + envFrom: + - configMapRef: + name: mainloop-config + env: + {{- include "mainloop.dbEnv" . | nindent 12 }} + resources: + {{- toYaml .Values.server.migrate.resources | nindent 12 }} + containers: + {{- include "mainloop.listener" (dict + "root" . + "name" "backend" + "subcommand" "serve-api" + "ports" (list (dict "containerPort" 8000)) + "resources" .Values.server.api.resources + "liveness" (dict "httpGet" (dict "path" "/health" "port" 8000) "initialDelaySeconds" 30 "periodSeconds" 10 "timeoutSeconds" 5 "failureThreshold" 3) + "readiness" (dict "httpGet" (dict "path" "/health" "port" 8000) "initialDelaySeconds" 5 "periodSeconds" 5 "timeoutSeconds" 3 "failureThreshold" 3) + ) | nindent 8 }} + {{- include "mainloop.listener" (dict + "root" . + "name" "mcp" + "subcommand" "serve-mcp" + "ports" (list (dict "containerPort" 8002)) + "resources" .Values.server.mcp.resources + "liveness" (dict "httpGet" (dict "path" "/health" "port" 8002) "initialDelaySeconds" 30 "periodSeconds" 10) + "readiness" (dict "httpGet" (dict "path" "/health" "port" 8002) "initialDelaySeconds" 5 "periodSeconds" 5) + ) | nindent 8 }} + {{- /* The read listener answers /health only when the process is up; a failing push + listener ends the process, so one probe covers both ports. */}} + {{- include "mainloop.listener" (dict + "root" . + "name" "git" + "subcommand" "serve-git" + "ports" (list (dict "name" "git-read" "containerPort" 8003) (dict "name" "git-push" "containerPort" 8004)) + "mounts" (list (dict "name" "git-spool" "mountPath" "/var/lib/mainloop/git")) + "resources" .Values.server.git.resources + "liveness" (dict "httpGet" (dict "path" "/health" "port" "git-read") "initialDelaySeconds" 30 "periodSeconds" 10) + "readiness" (dict "httpGet" (dict "path" "/health" "port" "git-read") "initialDelaySeconds" 5 "periodSeconds" 5) + ) | nindent 8 }} + volumes: + - name: git-spool + emptyDir: + sizeLimit: 3Gi + {{- if .Values.kagentControlAuth.enabled }} + - name: kagent-control-auth + secret: + secretName: kagent-control-token + items: + - key: current + path: current + - name: kagent-control-ca + emptyDir: + medium: Memory + sizeLimit: 2Mi + - name: kagent-control-root + secret: + secretName: kagent-control-ca + items: + - key: ca.crt + path: ca.crt + {{- end }} + {{- with .Values.server.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/mainloop/templates/deployment-frontend.yaml b/charts/mainloop/templates/deployment-frontend.yaml new file mode 100644 index 00000000..05223685 --- /dev/null +++ b/charts/mainloop/templates/deployment-frontend.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mainloop-frontend + namespace: {{ .Release.Namespace }} +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 + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: frontend + image: {{ include "mainloop.frontendImage" . }} + {{- include "mainloop.pullPolicy" . | nindent 10 }} + {{- with .Values.frontend.command }} + command: {{ toJson . }} + {{- end }} + {{- with .Values.frontend.workingDir }} + workingDir: {{ . }} + {{- end }} + ports: + - containerPort: 3000 + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + {{- with .Values.frontend.env }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} + {{- $liveness := dict "httpGet" (dict "path" "/" "port" 3000) "initialDelaySeconds" 30 "periodSeconds" 10 "timeoutSeconds" 5 "failureThreshold" 3 }} + {{- $readiness := dict "httpGet" (dict "path" "/" "port" 3000) "initialDelaySeconds" 10 "periodSeconds" 5 "timeoutSeconds" 3 "failureThreshold" 3 }} + livenessProbe: + {{- toYaml (mergeOverwrite $liveness .Values.frontend.livenessProbe) | nindent 12 }} + readinessProbe: + {{- toYaml (mergeOverwrite $readiness .Values.frontend.readinessProbe) | nindent 12 }} diff --git a/charts/mainloop/templates/networkpolicy.yaml b/charts/mainloop/templates/networkpolicy.yaml new file mode 100644 index 00000000..6bffe261 --- /dev/null +++ b/charts/mainloop/templates/networkpolicy.yaml @@ -0,0 +1,37 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: mainloop-backend-ingress + namespace: {{ .Release.Namespace }} +spec: + podSelector: + matchLabels: + app: mainloop-backend + policyTypes: [Ingress] + ingress: + {{- toYaml .Values.networkPolicy.backendIngress | nindent 4 }} +--- +# Separate policy, so a deployment's backendIngress never opens the git listeners to another peer. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: mainloop-git-ingress + namespace: {{ .Release.Namespace }} +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/charts/mainloop/templates/services.yaml b/charts/mainloop/templates/services.yaml new file mode 100644 index 00000000..be2b09d0 --- /dev/null +++ b/charts/mainloop/templates/services.yaml @@ -0,0 +1,77 @@ +{{- if .Values.createNamespace }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Release.Namespace }} +--- +{{- end }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mainloop-backend + namespace: {{ .Release.Namespace }} +--- +apiVersion: v1 +kind: Service +metadata: + name: mainloop-backend + namespace: {{ .Release.Namespace }} +spec: + selector: + app: mainloop-backend + ports: + - protocol: TCP + port: 8000 + targetPort: 8000 + type: ClusterIP +--- +apiVersion: v1 +kind: Service +metadata: + name: mainloop-mcp + namespace: {{ .Release.Namespace }} +spec: + selector: + app: mainloop-backend + ports: + - name: mcp + port: 80 + targetPort: 8002 +--- +apiVersion: v1 +kind: Service +metadata: + name: mainloop-git-read + namespace: {{ .Release.Namespace }} +spec: + selector: + app: mainloop-backend + ports: + - name: git-read + port: 80 + targetPort: 8003 +--- +apiVersion: v1 +kind: Service +metadata: + name: mainloop-git-push + namespace: {{ .Release.Namespace }} +spec: + selector: + app: mainloop-backend + ports: + - name: git-push + port: 80 + targetPort: 8004 +--- +apiVersion: v1 +kind: Service +metadata: + name: mainloop-frontend + namespace: {{ .Release.Namespace }} +spec: + selector: + app: mainloop-frontend + ports: + - port: 3000 + targetPort: 3000 diff --git a/charts/mainloop/values-kind.yaml b/charts/mainloop/values-kind.yaml new file mode 100644 index 00000000..2c2a8e32 --- /dev/null +++ b/charts/mainloop/values-kind.yaml @@ -0,0 +1,34 @@ +# Local Kind cluster for trying Mainloop out (docs/dev-environment.md#kind). Runs the Go server and +# React app with a local PostgreSQL. kagent, Substrate, the GitHub App and Tailscale exposure are +# not configured here, so the stack boots without them. Images are built on the host and loaded +# into Kind; nothing is pulled from ghcr.io. + +images: + backend: + repository: mainloop-backend + tag: kind + frontend: + repository: mainloop-frontend + tag: kind + +# Kind loads the images onto the nodes and has no ghcr.io pull secret to use. +imagePullSecrets: [] + +database: + local: + enabled: true + +config: + # The browser reaches the web app at http://localhost:5173 and the API at http://localhost:8000 + # through port forwards. The Origin and Host checks compare these values exactly, so keep them + # in step with the forwards and with the VITE_API_URL build argument. + FRONTEND_SCHEME: http + FRONTEND_DOMAIN: localhost:5173 + API_DOMAIN: localhost + # Lets `make dev-reset` run `mainloop migrate reset` against this throwaway database. + MAINLOOP_ALLOW_RESET: '1' + +frontend: + env: + - name: ORIGIN + value: http://localhost:5173 diff --git a/charts/mainloop/values.yaml b/charts/mainloop/values.yaml new file mode 100644 index 00000000..99cec7f5 --- /dev/null +++ b/charts/mainloop/values.yaml @@ -0,0 +1,180 @@ +# Defaults render a generic install: the Go server and the web app, reading credentials from the +# mainloop-secrets Secret and connecting to an external PostgreSQL. A deployment sets its domains, +# database and integrations through its own values file; see docs/production-rollout.md. + +images: + # tag defaults to the chart's appVersion. A digest, when set, replaces the tag; published charts + # carry the digests their release built. + backend: + repository: ghcr.io/oldsj/mainloop-backend + tag: '' + digest: '' + frontend: + repository: ghcr.io/oldsj/mainloop-frontend + tag: '' + digest: '' + # Applies to every container. Empty leaves the Kubernetes default. + pullPolicy: '' + +imagePullSecrets: + - name: ghcr-secret + +# Renders the release namespace as an object. Leave it on for a GitOps owner that already tracks +# the Namespace, so a sync never prunes it. +createNamespace: false + +nodeSelector: {} + +# Non-secret settings for every server container (ConfigMap mainloop-config). A key set to null +# drops the default. +config: + HOST: 0.0.0.0 + PORT: '8000' + DB_PORT: '5432' + DB_NAME: mainloop + FRONTEND_DOMAIN: mainloop.example.com + # 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 a deployment turns it on. + MAINLOOP_DEVLOOP_ENABLED: 'false' + +database: + # DB_HOST when neither database below is enabled. + host: mainloop-db-pooler.mainloop + # Where DB_USER and DB_PASSWORD come from. With cnpg.enabled and no name set, the CNPG-generated + # -app Secret (keys username, password). + credentials: + secretName: '' + usernameKey: '' + passwordKey: '' + # A single-instance CloudNativePG Cluster. The server connects to its rw Service directly: one + # client app, and no pooler whose pool could stall DBOS LISTEN/NOTIFY sessions. + cnpg: + enabled: false + name: mainloop-pg + instances: 1 + storage: + size: 10Gi + storageClass: '' + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: '1' + memory: 1Gi + # A throwaway PostgreSQL StatefulSet for the local Kind stack. + local: + enabled: false + image: postgres:16-alpine + storage: 1Gi + +# The three listeners and the migrate initContainer, all from the backend image. +server: + # Pod-level settings for Deployment mainloop-backend. + strategy: {} + podAnnotations: {} + # Imports every key of mainloop-secrets as env vars. Turn off to read only the keys named below. + importSecretEnv: true + # Added after mainloop-config in every server container's envFrom (e.g. a site ConfigMap). + extraEnvFrom: [] + # GitHub App credentials from mainloop-secrets (github-app-id, github-app-private-key). + # Optional lets the server start without them. + githubAppOptional: false + extraEnv: [] + extraVolumeMounts: [] + extraVolumes: [] + # Replaces /usr/local/bin/mainloop; the listener's subcommand is passed as args. + command: [] + workingDir: '' + # Merged over each listener's probes. + livenessProbe: {} + readinessProbe: {} + api: + resources: + requests: + memory: 1Gi + cpu: 250m + limits: + memory: 2Gi + cpu: 1000m + mcp: + resources: + requests: + memory: 1Gi + cpu: 250m + limits: + memory: 2Gi + cpu: 1000m + git: + resources: + requests: + memory: 256Mi + cpu: 100m + ephemeral-storage: 1Gi + limits: + memory: 2Gi + cpu: 1000m + ephemeral-storage: 3Gi + # 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. + migrate: + command: [/usr/local/bin/mainloop, migrate, up] + workingDir: '' + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi + cpu: 500m + +frontend: + # The full env list of the web container. + env: + - name: ORIGIN + value: https://mainloop.example.com + command: [] + workingDir: '' + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 256Mi + cpu: 250m + livenessProbe: {} + readinessProbe: {} + +# Authenticated, TLS calls to kagent's control gateway. Needs Secrets kagent-control-token (key +# current) and kagent-control-ca (key ca.crt) in the release namespace. +kagentControlAuth: + enabled: false + gatewayURL: https://kagent-control.kagent.svc.cluster.local + userID: mainloop + +networkPolicy: + # Ingress to the REST and MCP listeners. The git listeners have their own fixed policy, open only + # to the actor egress proxy. + backendIngress: + - 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 + ports: + - protocol: TCP + port: 8000 diff --git a/devspace.yaml b/devspace.yaml index 615f038b..2bb94e04 100644 --- a/devspace.yaml +++ b/devspace.yaml @@ -13,10 +13,19 @@ vars: DEV_CODEX_HARNESS_IMAGE: command: scripts/dev-live-harness.sh args: [image, codex] + # Live profile: the deployment's own config for its live instance, kept outside this repository + # (docs/dev-environment.md#live-dev-instance). A directory holding values.yaml for the chart and + # kagent/, a kustomization of the instance's dev agents; and the only kube context it may use. + MAINLOOP_DEV_CONFIG: + source: env + default: '' + DEV_LIVE_CONTEXT: + source: env + default: '' # Local Kind stack (`make dev`; see docs/dev-environment.md#kind). Builds the Go server and the React -# app from this checkout, loads both into the Kind cluster and deploys overlays/kind. The live -# profile below replaces this whole section on internal-01. +# app from this checkout, loads both into the Kind cluster and deploys the chart with +# charts/mainloop/values-kind.yaml. The live profile below replaces this whole section. # Images - built locally, then loaded into Kind by DevSpace. No constant tag: each build gets a new # generated tag, so a changed image rolls the pods. A fixed tag would leave them on the old image. @@ -40,14 +49,23 @@ images: # Baked into the bundle at build time; must match the API port forward below. - --build-arg=VITE_API_URL=http://localhost:8000 -# Deployments using Kustomize deployments: mainloop: namespace: mainloop - kubectl: - kustomize: true - manifests: - - k8s/apps/mainloop/overlays/kind + helm: + chart: + name: ./charts/mainloop + valuesFiles: + - charts/mainloop/values-kind.yaml + # The images DevSpace just built and loaded, under their generated tags. + values: + images: + backend: + repository: ${runtime.images.backend.image} + tag: ${runtime.images.backend.tag} + frontend: + repository: ${runtime.images.frontend.image} + tag: ${runtime.images.frontend.tag} # Port forwards: the web app on localhost:5173 and the API on localhost:8000. Ctrl-C stops them. dev: @@ -89,9 +107,9 @@ commands: # Profiles profiles: - # Shared live instance on internal-01 (`make dev-live`). No local builds: images - # come from ghcr (CI publishes the --target=dev images on pushes to main). - # DevSpace owns namespace mainloop-dev and the *-dev kagent agents directly. + # Shared live instance (`make dev-live`). No local builds: images come from ghcr (CI publishes + # the --target=dev images on pushes to main). DevSpace owns the instance's workloads and its + # *-dev kagent agents directly; values and agents come from MAINLOOP_DEV_CONFIG. - name: live patches: - op: remove @@ -101,14 +119,18 @@ profiles: - op: remove path: commands # v2beta1 has no kube-context key, so every live pipeline starts with this pin: - # it refuses any context or namespace other than admin@internal-01/mainloop-dev - # (pass --kube-context admin@internal-01 -n mainloop-dev, as scripts/dev-live.sh does). + # it refuses any context or namespace other than DEV_LIVE_CONTEXT/mainloop-dev + # (pass --kube-context "$DEV_LIVE_CONTEXT" -n mainloop-dev, as scripts/dev-live.sh does). - op: add path: functions value: require_live_target: |- - if [ "${DEVSPACE_CONTEXT}" != "admin@internal-01" ] || [ "${DEVSPACE_NAMESPACE}" != "mainloop-dev" ]; then - echo "profile live runs only against context admin@internal-01, namespace mainloop-dev (got ${DEVSPACE_CONTEXT}/${DEVSPACE_NAMESPACE})" >&2 + if [ -z "${MAINLOOP_DEV_CONFIG}" ] || [ -z "${DEV_LIVE_CONTEXT}" ]; then + echo "profile live needs MAINLOOP_DEV_CONFIG and DEV_LIVE_CONTEXT (see docs/dev-environment.md#live-dev-instance)" >&2 + exit 1 + fi + if [ "${DEVSPACE_CONTEXT}" != "${DEV_LIVE_CONTEXT}" ] || [ "${DEVSPACE_NAMESPACE}" != "mainloop-dev" ]; then + echo "profile live runs only against context ${DEV_LIVE_CONTEXT}, namespace mainloop-dev (got ${DEVSPACE_CONTEXT}/${DEVSPACE_NAMESPACE})" >&2 exit 1 fi # dev and deploy touch only mainloop-dev; the shared kagent namespace changes @@ -139,16 +161,20 @@ profiles: value: mainloop: namespace: mainloop-dev - kubectl: - kustomize: true - manifests: - - k8s/apps/mainloop/overlays/dev-live + helm: + chart: + name: ./charts/mainloop + valuesFiles: + - ${MAINLOOP_DEV_CONFIG}/values.yaml + # Adopts objects an earlier kubectl deployment of the instance created. + upgradeArgs: + - --take-ownership dev-agents: namespace: kagent kubectl: kustomize: true manifests: - - k8s/apps/mainloop/overlays/dev-live/kagent + - ${MAINLOOP_DEV_CONFIG}/kagent # `make dev-harness` records dev images under .devspace/dev-harness/. patches: - target: @@ -255,6 +281,4 @@ profiles: value: /api - name: MAINLOOP_API_PROXY value: http://mainloop-backend:8000 - - name: __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS - value: mainloop-dev.olds.network logs: {} diff --git a/docs/dev-environment.md b/docs/dev-environment.md index 4d03f6fc..70b9a3ac 100644 --- a/docs/dev-environment.md +++ b/docs/dev-environment.md @@ -58,12 +58,21 @@ Without DevSpace, `make kind-up` creates the cluster, builds and loads the image `KIND_CLUSTER_NAME=` picks another cluster, with kubectl context `kind-`. The commands pass their context explicitly. Creating a cluster does switch your kubectl current context to it. -What works without kagent: the web app, the REST API (migrations run on start), and the MCP and Git listeners, which start with Git transport off. Not available: native Claude Code and Codex sessions, workspaces, and schedules. They need a kagent installation that this stack does not set up, so `/schedules` returns 502. GitHub is not configured, so there are no repository reads or pushes. The [Kind overlay README](../k8s/apps/mainloop/overlays/kind/README.md) lists what the overlay changes. +What works without kagent: the web app, the REST API (migrations run on start), and the MCP and Git listeners, which start with Git transport off. Not available: native Claude Code and Codex sessions, workspaces, and schedules. They need a kagent installation that this stack does not set up, so `/schedules` returns 502. GitHub is not configured, so there are no repository reads or pushes. [charts/mainloop/values-kind.yaml](../charts/mainloop/values-kind.yaml) holds the Kind settings. ## Live dev instance `mainloop-dev` is a shared, prototype instance for fast iteration. It runs in namespace `mainloop-dev` on the production cluster, next to production `mainloop`, but DevSpace deploys and owns it directly; it is not Argo-managed. Production still changes only through pull requests, CI and GitOps. +The instance's own settings live with the deployment, outside this repository. Point two variables at them, in the environment or in a git-ignored `.env` at the repository root: + +```bash +DEV_LIVE_CONTEXT=my-cluster # the only kube context the live profile may use +MAINLOOP_DEV_CONFIG=../infrastructure/mainloop-dev # values.yaml for charts/mainloop, and kagent/ +``` + +`$MAINLOOP_DEV_CONFIG/values.yaml` is a values file for `charts/mainloop` (`charts/mainloop/ci/live-dev-values.yaml` is a complete example), and `$MAINLOOP_DEV_CONFIG/kagent` is a kustomization of the instance's `*-dev` kagent Agents, Harnesses, RemoteMCPServer and token RoleBinding. Secrets, the route and any RBAC the instance needs come from the cluster's own GitOps. + ```bash make dev-live # deploy mainloop-dev, then sync and stream logs (Ctrl-C stops sync) make dev-live-status # pods, database, route and dev agents @@ -72,7 +81,7 @@ make dev-harness # build arm64 harness images from ../kagent, apply them t make dev-live-purge # delete namespace mainloop-dev and the *-dev agents (typed confirmation) ``` -All targets use kube context `admin@internal-01` through DevSpace profile `live` in `devspace.yaml`, whose pipelines (`build`, `dev`, `deploy`, `deploy-agents`, `purge`) refuse any other context or namespace. Use these targets or `scripts/dev-live.sh`: raw `devspace sync`, `restart`, `enter`, `logs` and `attach` with `-p live` are not guarded and act on whatever `--kube-context` and `-n` say. This page covers only the `live` profile. +All targets use kube context `$DEV_LIVE_CONTEXT` through DevSpace profile `live` in `devspace.yaml`, whose pipelines (`build`, `dev`, `deploy`, `deploy-agents`, `purge`) refuse any other context or namespace. Use these targets or `scripts/dev-live.sh`: raw `devspace sync`, `restart`, `enter`, `logs` and `attach` with `-p live` are not guarded and act on whatever `--kube-context` and `-n` say. This page covers only the `live` profile. ### Dev harness snapshots @@ -121,32 +130,32 @@ What runs and syncs: the Go server and the React app. - The images already hold the source they were built from, so a pod serves before the first sync lands. A `migrate` initContainer runs `mainloop migrate up` on every pod start, but it sees only that baked-in source, never synced files. After syncing a new migration, apply it yourself in the backend container: ```bash - kubectl --context admin@internal-01 -n mainloop-dev exec deploy/mainloop-backend -c backend -- \ + kubectl --context "$DEV_LIVE_CONTEXT" -n mainloop-dev exec deploy/mainloop-backend -c backend -- \ go run ./cmd/mainloop migrate up ``` (Under `devspace dev` the serving pod is `mainloop-backend-devspace`; see Reset below for addressing it.) -The UI is `https://mainloop-dev.olds.network` (tailnet only, through the same Envoy gateway as production). The browser calls `/api` on that host, and Vite forwards it to the backend Service (`MAINLOOP_API_PROXY`), so the backend needs no route of its own. Vite accepts the gateway host through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS`. +The UI is served at the host the deployment's route gives it. The browser calls `/api` on that host, and Vite forwards it to the backend Service (`MAINLOOP_API_PROXY`), so the backend needs no route of its own. Vite accepts the gateway host through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS`. -Images: the **Publish live dev images** workflow builds arm64 images on each push to `main` and pushes `ghcr.io/oldsj/mainloop-server-dev` (`server/Dockerfile --target dev`: Go toolchain, `git`, `watchexec`) and `ghcr.io/oldsj/mainloop-web-dev` (`web/Dockerfile --target dev`: node 24, pnpm, Vite on `0.0.0.0:3000`), each tagged `` and `main`. The overlay pulls the `main` tag. The pods pull the moving tag and the sync overlays the working tree. The pods carry no pull secret, so after the first build of each of the two `-dev` packages make it public once in the GitHub UI (package settings, change visibility); until then the pods sit in `ImagePullBackOff`. +Images: the **Publish live dev images** workflow builds arm64 images on each push to `main` and pushes `ghcr.io/oldsj/mainloop-server-dev` (`server/Dockerfile --target dev`: Go toolchain, `git`, `watchexec`) and `ghcr.io/oldsj/mainloop-web-dev` (`web/Dockerfile --target dev`: node 24, pnpm, Vite on `0.0.0.0:3000`), each tagged `` and `main`. The live values pull the `main` tag. The pods pull the moving tag and the sync overlays the working tree. The pods carry no pull secret, so after the first build of each of the two `-dev` packages make it public once in the GitHub UI (package settings, change visibility); until then the pods sit in `ImagePullBackOff`. Data: a one-instance CloudNativePG cluster on `local-path` storage. CNPG creates the database credentials. Purging deletes the data. -Reset: the overlay sets `MAINLOOP_ALLOW_RESET=1`, which lets `mainloop migrate reset` drop the schema and migrate back to the latest version. Run it in the backend container of the pod that is serving, then delete that pod so the listeners reconnect. While `devspace dev` is running (`make dev-live`), the serving pod is `mainloop-backend-devspace` and the Deployment's own pod is replaced; otherwise it is the Deployment's `mainloop-backend` pod. Select it by label so both states work: +Reset: `MAINLOOP_ALLOW_RESET=1` in the live values lets `mainloop migrate reset` drop the schema and migrate back to the latest version. Run it in the backend container of the pod that is serving, then delete that pod so the listeners reconnect. While `devspace dev` is running (`make dev-live`), the serving pod is `mainloop-backend-devspace` and the Deployment's own pod is replaced; otherwise it is the Deployment's `mainloop-backend` pod. Select it by label so both states work: ```bash -K="kubectl --context admin@internal-01 -n mainloop-dev" +K="kubectl --context $DEV_LIVE_CONTEXT -n mainloop-dev" POD=$($K get pod -l app=mainloop-backend -o name | head -n1) $K exec "$POD" -c backend -- go run ./cmd/mainloop migrate reset $K delete "$POD" ``` The Go server keeps its tables in the `mainloop` schema. A database first used by the retired Python backend may still have tables in `public`. `migrate reset` does not touch them. To drop them as well: -`kubectl --context admin@internal-01 -n mainloop-dev exec mainloop-pg-1 -c postgres -- psql -d mainloop -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'`. +`kubectl --context "$DEV_LIVE_CONTEXT" -n mainloop-dev exec mainloop-pg-1 -c postgres -- psql -d mainloop -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'`. `make dev-live-purge` drops all the data, along with the namespace. -Settings (`k8s/apps/mainloop/overlays/dev-live`): `DEV_MODE=true`; merge tools and owner policy writes are off; Git transport and the push gate are on, owner-approved for dev, through mainloop-dev's own read/push proxies; every kagent role uses `claude-workspace-dev` or `codex-workspace-dev`; `MAINLOOP_MCP_HOSTS` lists the `mainloop-mcp` Service names in `mainloop-dev`; the `control-auth` component is on. Dev work targets `oldsj/testrepo` only through the dev agents' instructions: Mainloop has no repository allowlist, and the shared production GitHub App can reach every repository it is installed on, so a dev task can push to any repository the App is installed on. The push gate still refuses pushes to default and protected branches, and merge tools stay off, so nothing here merges on its own. +Settings (as in `charts/mainloop/ci/live-dev-values.yaml`): `DEV_MODE=true`; merge tools and owner policy writes are off; Git transport and the push gate are on, owner-approved for dev, through mainloop-dev's own read/push proxies; every kagent role uses `claude-workspace-dev` or `codex-workspace-dev`; `MAINLOOP_MCP_HOSTS` lists the `mainloop-mcp` Service names in `mainloop-dev`; the `control-auth` component is on. Dev work targets `oldsj/testrepo` only through the dev agents' instructions: Mainloop has no repository allowlist, and the shared production GitHub App can reach every repository it is installed on, so a dev task can push to any repository the App is installed on. The push gate still refuses pushes to default and protected branches, and merge tools stay off, so nothing here merges on its own. Secrets are not in this repository. The infrastructure repository supplies them from production's existing items (mainloop-dev shares production's GitHub App, agent token key and kagent control principal). The server needs `agent-token-key` and the kagent control Secrets to start, because it has no fallback for the token key; the GitHub App keys are optional, so without them the instance runs with GitHub unavailable: @@ -168,15 +177,15 @@ Production and mainloop-dev share one kagent principal, so each can list the oth Smoke: `mainloop smoke --read-only` probes a running instance: health, the owner reads, the event stream, the MCP and Git listeners' refusals, and with `--kagent` the kagent probe. It sends no writes and spends no quota, so run it after each deploy. Run it in the backend container, where the in-cluster Service names resolve: ```bash -kubectl --context admin@internal-01 -n mainloop-dev exec deploy/mainloop-backend -c backend -- \ +kubectl --context "$DEV_LIVE_CONTEXT" -n mainloop-dev exec deploy/mainloop-backend -c backend -- \ go run ./cmd/mainloop smoke --read-only --kagent ``` -Git transport and the push gate are on here (owner-approved for dev), so `git-read` and `git-push` report `ok` rather than `skip`. That is Mainloop's own listeners answering through mainloop-dev's own proxies; a native session reaching GitHub for real still needs the kagent side, landing separately: the Harness change naming mainloop-dev's own `readProxyOrigin`/`pushProxyOrigin` (`k8s/apps/mainloop/overlays/dev-live/kagent/claude.yaml`, `codex.yaml`) and kagent's service policy accepting mainloop-dev's Git tuples. The owner's operator applies the Harness change once that policy ships; until then a dev session's Git calls still go nowhere. The live run (`smoke --live`) drives one task from chat to merge and spends quota, so only run it once that kagent-side change has shipped. Run it from an operator machine, not the pod: the pod has no `gh` login, and the live path reads GitHub through the operator's `gh auth status`. It checks `gh` before anything else and refuses, spending no budget, when `gh` is missing or not logged in. Open two port-forwards first, each in its own terminal: +Git transport and the push gate are on here (owner-approved for dev), so `git-read` and `git-push` report `ok` rather than `skip`. That is Mainloop's own listeners answering through mainloop-dev's own proxies; a native session reaching GitHub for real still needs the kagent side, landing separately: the Harness change naming mainloop-dev's own `readProxyOrigin`/`pushProxyOrigin` (`$MAINLOOP_DEV_CONFIG/kagent/claude.yaml`, `codex.yaml`) and kagent's service policy accepting mainloop-dev's Git tuples. The owner's operator applies the Harness change once that policy ships; until then a dev session's Git calls still go nowhere. The live run (`smoke --live`) drives one task from chat to merge and spends quota, so only run it once that kagent-side change has shipped. Run it from an operator machine, not the pod: the pod has no `gh` login, and the live path reads GitHub through the operator's `gh auth status`. It checks `gh` before anything else and refuses, spending no budget, when `gh` is missing or not logged in. Open two port-forwards first, each in its own terminal: ```bash -kubectl --context admin@internal-01 -n mainloop-dev port-forward svc/mainloop-backend 8000:8000 -kubectl --context admin@internal-01 -n mainloop-dev port-forward svc/mainloop-git-push 8004:80 +kubectl --context "$DEV_LIVE_CONTEXT" -n mainloop-dev port-forward svc/mainloop-backend 8000:8000 +kubectl --context "$DEV_LIVE_CONTEXT" -n mainloop-dev port-forward svc/mainloop-git-push 8004:80 ``` Then, from `server/`: @@ -207,12 +216,12 @@ policy entry for `mainloop-dev`); until then they are proven in production. ### Testing approvals on dev -The overlay's `claude-workspace-dev-template` has no approval gate. To test the Approve and +The dev `claude-workspace-dev-template` has no approval gate. To test the Approve and Reject flow on this instance, gate a tool temporarily and restore the template afterwards. 1. Add `tools: [whoami]` and `requireApproval: true` to the template's `mcp` binding (next to - `server`), and apply it with `kubectl --context admin@internal-01 -n kagent apply`. Do not - commit the change; the overlay stays ungated. + `server`), and apply it with `kubectl --context "$DEV_LIVE_CONTEXT" -n kagent apply`. Do not + commit the change; the template stays ungated. 2. A template change reaches only new native sessions. Switch the main chat's provider away and back (`PUT /api/main-thread/provider` with `codex`, then `claude`) so the main chat starts a fresh session on the new template. @@ -221,6 +230,6 @@ Reject flow on this instance, gate a tool temporarily and restore the template a the agent-side connection is cut at about 360 s, an approval after that resumes into a dead call, and the agent reports "The call timed out." This is a known kagent issue; Mainloop's request stays pending and answerable meanwhile. -4. Restore: re-apply the template from `k8s/apps/mainloop/overlays/dev-live/kagent/claude.yaml` +4. Restore: re-apply the template from `$MAINLOOP_DEV_CONFIG/kagent/claude.yaml` (`make dev-agents`), then switch the provider away and back again so the main chat drops the gated session. diff --git a/docs/specs/merge-policy.md b/docs/specs/merge-policy.md index a7b6cc4d..6e403f51 100644 --- a/docs/specs/merge-policy.md +++ b/docs/specs/merge-policy.md @@ -233,7 +233,7 @@ responses and internal generic status/response mutations reject HITL cards. `POST /hitl/{request_id}/respond` accepts `action_id` and a complete typed `response`. This route is disabled unless **`MAINLOOP_OWNER_HITL_WRITES_ENABLED=true`** exactly. Like the policy gate, this is an operator opt-in after proving owner-route isolation, not request authentication. -Only the live dev overlay (`overlays/dev-live`, a single-owner prototype) enables it. Fresh ownership/task checks, exact batch validation, sorted verified +Only the live dev instance (a single-owner prototype; see `charts/mainloop/ci/live-dev-values.yaml`) enables it. Fresh ownership/task checks, exact batch validation, sorted verified leaf locks and route revalidation precede immutable recording. Same action/body is idempotent; another action or contradictory batch cannot take any already-recorded leaf member. diff --git a/k8s/apps/mainloop/overlays/dev-live/backend-patch.yaml b/k8s/apps/mainloop/overlays/dev-live/backend-patch.yaml deleted file mode 100644 index 24999e12..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/backend-patch.yaml +++ /dev/null @@ -1,150 +0,0 @@ -# Like the production overlay, envFrom replaces the base list so the whole -# mainloop-secrets Secret is not imported; env merges by name. GitHub App keys stay -# optional so the instance starts without them; AGENT_TOKEN_KEY is required by the -# server (there is no fallback), so mainloop-secrets must hold agent-token-key. -# Each server container runs watchexec over `go run` from /src, which DevSpace syncs -# server/ into; the same commands run when the pods start without `devspace dev`. -# Each container rebuilds into its own Go build cache, on top of the warm cache baked into -# the image, so each gets its own ephemeral-storage budget; the base 3Gi pod total was -# evicted after about ten hot reloads. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-backend - namespace: mainloop -spec: - strategy: - type: Recreate - template: - spec: - # Images are published for arm64 only. - nodeSelector: - kubernetes.io/arch: arm64 - # The -dev images are public; drop the base ghcr-secret reference. - imagePullSecrets: null - initContainers: - # The dev image keeps the source it was built from at /src, so migrate runs the - # image's own revision before the first sync. - - name: migrate - imagePullPolicy: Always - command: [go, run, ./cmd/mainloop, migrate, up] - workingDir: /src - env: - - name: DB_USER - valueFrom: - secretKeyRef: - name: mainloop-pg-app - key: username - - name: DB_PASSWORD - valueFrom: - secretKeyRef: - name: mainloop-pg-app - key: password - resources: - requests: - cpu: 100m - memory: 256Mi - ephemeral-storage: 1Gi - limits: - cpu: '2' - memory: 2Gi - ephemeral-storage: 8Gi - containers: - - name: backend - imagePullPolicy: Always - command: &watch - - watchexec - - --restart - - --exts - - go,sql - - --stop-timeout - - 5s - - -- - - go - - run - - ./cmd/mainloop - args: [serve-api] - workingDir: /src - envFrom: &envFrom - - configMapRef: - name: mainloop-config - 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: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-app-id - optional: true - - name: GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: mainloop-secrets - key: github-app-private-key - optional: true - # `go run` compiles first, so the probes wait for a cold build. - livenessProbe: &liveness - initialDelaySeconds: 120 - failureThreshold: 6 - readinessProbe: &readiness - initialDelaySeconds: 20 - failureThreshold: 60 - resources: - requests: - cpu: 100m - memory: 512Mi - ephemeral-storage: 1Gi - limits: - cpu: '2' - memory: 2Gi - ephemeral-storage: 8Gi - - name: mcp - imagePullPolicy: Always - command: *watch - args: [serve-mcp] - workingDir: /src - envFrom: *envFrom - env: *env - livenessProbe: *liveness - readinessProbe: *readiness - resources: - requests: - cpu: 50m - memory: 256Mi - ephemeral-storage: 1Gi - limits: - cpu: '2' - memory: 2Gi - ephemeral-storage: 8Gi - - name: git - imagePullPolicy: Always - command: *watch - args: [serve-git] - workingDir: /src - envFrom: *envFrom - env: *env - livenessProbe: *liveness - readinessProbe: *readiness - resources: - requests: - cpu: 25m - memory: 128Mi - ephemeral-storage: 1Gi - limits: - cpu: '2' - memory: 2Gi - ephemeral-storage: 8Gi diff --git a/k8s/apps/mainloop/overlays/dev-live/config-patch.yaml b/k8s/apps/mainloop/overlays/dev-live/config-patch.yaml deleted file mode 100644 index 50f31517..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/config-patch.yaml +++ /dev/null @@ -1,43 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: mainloop-config - namespace: mainloop -data: - DB_HOST: mainloop-pg-rw.mainloop-dev.svc.cluster.local - DB_PORT: '5432' - DB_NAME: mainloop - FRONTEND_DOMAIN: mainloop-dev.olds.network - FRONTEND_SCHEME: https - MAINLOOP_API_HOSTS: mainloop-backend,mainloop-backend.mainloop-dev,mainloop-backend.mainloop-dev.svc,mainloop-backend.mainloop-dev.svc.cluster.local - MAINLOOP_OWNER_ID: dev-owner - # Hosts the MCP listener accepts; in-cluster it must include its own Service name. - MAINLOOP_MCP_HOSTS: mainloop-mcp,mainloop-mcp.mainloop-dev,mainloop-mcp.mainloop-dev.svc,mainloop-mcp.mainloop-dev.svc.cluster.local - # Lets `mainloop migrate reset` run in this prototype instance (docs/dev-environment.md#live-dev-instance). - MAINLOOP_ALLOW_RESET: '1' - DEV_MODE: 'true' - IS_TEST_ENV: 'false' - NATIVE_CHILD_KINDS: claude,codex - # Derives the MCP and Git origins (*.mainloop-dev.svc.cluster.local) and the - # mainloop-dev- prefix of this instance's credential Secrets in the kagent namespace. - MAINLOOP_NAMESPACE: mainloop-dev - # On: dev Git goes through mainloop-dev's own read/push proxies, not production's. - # kagent's service policy accepts them once its instance allowlist includes - # mainloop-dev (see docs/dev-environment.md#live-dev-instance). - GIT_TRANSPORT_ENABLED: 'true' - PUSH_GATE_ENABLED: 'true' - MAINLOOP_MERGE_TOOLS_ENABLED: 'false' - MAINLOOP_OWNER_HITL_WRITES_ENABLED: 'true' - # Dev-only kagent agents; production agents are never used from here. - KAGENT_NAMESPACE: kagent - KAGENT_ACTOR_ATESPACE: kagent - KAGENT_MAIN_AGENT: claude-workspace-dev - # Codex main uses the dev workspace Agent, like the Claude main. The default (mainloop-main-codex) - # is production's Agent on this shared kagent. - KAGENT_MAIN_CODEX_AGENT: codex-workspace-dev - KAGENT_CLAUDE_AGENT: claude-workspace-dev - KAGENT_CODEX_AGENT: codex-workspace-dev - KAGENT_WORKSPACE_CLAUDE_AGENT: claude-workspace-dev - KAGENT_WORKSPACE_CODEX_AGENT: codex-workspace-dev - KAGENT_SUPERVISOR_CLAUDE_AGENT: claude-workspace-dev - KAGENT_SUPERVISOR_CODEX_AGENT: codex-workspace-dev diff --git a/k8s/apps/mainloop/overlays/dev-live/control-auth/backend-patch.yaml b/k8s/apps/mainloop/overlays/dev-live/control-auth/backend-patch.yaml deleted file mode 100644 index 240b1f2d..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/control-auth/backend-patch.yaml +++ /dev/null @@ -1,79 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-backend - namespace: mainloop -spec: - template: - spec: - containers: - - name: backend - env: &env - - name: KAGENT_GATEWAY_URL - value: https://kagent-control.kagent.svc.cluster.local - - name: KAGENT_USER_ID - value: mainloop - - name: KAGENT_CONTROL_TOKEN_FILE - value: /run/kagent-control-auth/current - - name: SSL_CERT_FILE - value: /run/kagent-control-ca/ca-bundle.crt - volumeMounts: &mounts - - name: kagent-control-auth - mountPath: /run/kagent-control-auth - readOnly: true - - name: kagent-control-ca - mountPath: /run/kagent-control-ca - readOnly: true - - name: mcp - env: *env - volumeMounts: *mounts - - name: git - env: *env - volumeMounts: *mounts - volumes: - - name: kagent-control-auth - secret: - secretName: kagent-control-token - items: - - key: current - path: current - - name: kagent-control-ca - emptyDir: - medium: Memory - sizeLimit: 2Mi - - name: kagent-control-root - secret: - secretName: kagent-control-ca - items: - - key: ca.crt - path: ca.crt - initContainers: - # Appends the kagent control CA to the default roots for SSL_CERT_FILE. - - name: kagent-control-trust-bundle - image: ghcr.io/oldsj/mainloop-backend:latest - command: - - sh - - -c - - | - cat /etc/ssl/certs/ca-certificates.crt > /run/kagent-control-ca/ca-bundle.crt - echo >> /run/kagent-control-ca/ca-bundle.crt - cat /run/kagent-control-root/ca.crt >> /run/kagent-control-ca/ca-bundle.crt - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: [ALL] - seccompProfile: - type: RuntimeDefault - resources: - requests: { cpu: 10m, memory: 32Mi } - limits: { cpu: 100m, memory: 64Mi } - volumeMounts: - - name: kagent-control-root - mountPath: /run/kagent-control-root - readOnly: true - - name: kagent-control-ca - mountPath: /run/kagent-control-ca diff --git a/k8s/apps/mainloop/overlays/dev-live/control-auth/kustomization.yaml b/k8s/apps/mainloop/overlays/dev-live/control-auth/kustomization.yaml deleted file mode 100644 index feea23db..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/control-auth/kustomization.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1alpha1 -kind: Component - -# Mirrors production's control-auth patch (it lives on the Argo Application). -# Requires Secrets kagent-control-token (key current) and kagent-control-ca -# (key ca.crt) in mainloop-dev, and mainloop-dev in kagent's control ingress policy. -patches: - - path: backend-patch.yaml diff --git a/k8s/apps/mainloop/overlays/dev-live/database.yaml b/k8s/apps/mainloop/overlays/dev-live/database.yaml deleted file mode 100644 index af2b3d11..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/database.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Small CloudNativePG cluster. CNPG generates the application credentials in -# Secret mainloop-pg-app. local-path storage is deleted with the namespace. -apiVersion: postgresql.cnpg.io/v1 -kind: Cluster -metadata: - name: mainloop-pg -spec: - instances: 1 - storage: - size: 2Gi - storageClass: local-path - bootstrap: - initdb: - database: mainloop - owner: mainloop - resources: - requests: - cpu: 50m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi diff --git a/k8s/apps/mainloop/overlays/dev-live/frontend-patch.yaml b/k8s/apps/mainloop/overlays/dev-live/frontend-patch.yaml deleted file mode 100644 index 9fed9c97..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/frontend-patch.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# Vite dev server with HMR (web/Dockerfile --target dev). The browser calls /api on the -# same host and Vite forwards it to the backend Service (MAINLOOP_API_PROXY, web/vite.config.ts). -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-frontend - namespace: mainloop -spec: - template: - spec: - nodeSelector: - kubernetes.io/arch: arm64 - # The -dev images are public; drop the base ghcr-secret reference. - imagePullSecrets: null - containers: - - name: frontend - imagePullPolicy: Always - command: [/app/web/node_modules/.bin/vite] - workingDir: /app/web - env: - - $patch: replace - - name: VITE_HOST - value: 0.0.0.0 - - name: VITE_PORT - value: '3000' - - name: VITE_API_URL - value: /api - - name: MAINLOOP_API_PROXY - value: http://mainloop-backend:8000 - # Vite rejects unknown Host headers; allow the gateway host. - - name: __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS - value: mainloop-dev.olds.network - resources: - requests: - cpu: 250m - memory: 512Mi - limits: - cpu: '2' - memory: 2Gi - livenessProbe: - initialDelaySeconds: 90 - readinessProbe: - initialDelaySeconds: 20 - failureThreshold: 12 diff --git a/k8s/apps/mainloop/overlays/dev-live/httproute.yaml b/k8s/apps/mainloop/overlays/dev-live/httproute.yaml deleted file mode 100644 index 00f582e3..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/httproute.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# One tailnet host, like production. The browser reaches the API through Vite's -# /api proxy, so the backend needs no route. No timeouts: Vite HMR is a WebSocket. -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: mainloop-frontend -spec: - parentRefs: - - name: tailscale-gateway - namespace: tailscale - hostnames: - - mainloop-dev.olds.network - rules: - - matches: - - path: { type: PathPrefix, value: / } - backendRefs: - - name: mainloop-frontend - port: 3000 - timeouts: - request: 0s - backendRequest: 0s diff --git a/k8s/apps/mainloop/overlays/dev-live/kagent/agent-tokens.yaml b/k8s/apps/mainloop/overlays/dev-live/kagent/agent-tokens.yaml deleted file mode 100644 index 0bbf2c5c..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/kagent/agent-tokens.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Lets mainloop-dev publish and revoke its per-session MCP credential Secrets -# (mainloop-dev-mcp-*) in kagent. The Role is production's, defined in -# k8s/integrations/kagent/agent-tokens.yaml and managed by Argo; only this binding is new. -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: mainloop-dev-agent-tokens - namespace: kagent -subjects: - - kind: ServiceAccount - name: mainloop-backend - namespace: mainloop-dev -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: mainloop-agent-tokens diff --git a/k8s/apps/mainloop/overlays/dev-live/kagent/claude.yaml b/k8s/apps/mainloop/overlays/dev-live/kagent/claude.yaml deleted file mode 100644 index b4d572ee..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/kagent/claude.yaml +++ /dev/null @@ -1,70 +0,0 @@ -apiVersion: api.kagent.dev/v1alpha3 -kind: Harness -metadata: - name: claude-workspace-dev-harness -spec: - claude: {} - workload: - # Production pin; `make dev-harness` overrides it through DevSpace. - image: ghcr.io/oldsj/kagent-claude-harness:0.0.0-4616d73@sha256:960e7d93aec31060a8e909d22eb5aebc394c966cf383569d47f2c47d30b09280 - substrate: - workerPoolRef: - name: kagent-default - snapshotPolicy: - location: s3://ate-snapshots/kagent-dev/ - onQuiesce: Data - sessionIdleTTL: 0s - env: - - name: KAGENT_RUNTIME_GENERATION - value: '3' - - name: GIT_CONFIG_COUNT - value: '2' - - name: GIT_CONFIG_KEY_0 - value: user.name - - name: GIT_CONFIG_VALUE_0 - value: Mainloop dev agent - - name: GIT_CONFIG_KEY_1 - value: user.email - - name: GIT_CONFIG_VALUE_1 - value: 12104969+oldsj@users.noreply.github.com - # mainloop-dev's own Git read/push proxies, named like production's but under - # mainloop-dev.svc.cluster.local (docs/dev-environment.md#live-dev-instance). - git: - origins: - - github.com - readProxyOrigin: http://mainloop-git-read.mainloop-dev.svc.cluster.local - pushProxyOrigin: http://mainloop-git-push.mainloop-dev.svc.cluster.local - extraHTTPSOrigins: - - https://registry.npmjs.org - - https://pypi.org - - https://files.pythonhosted.org - - https://proxy.golang.org - - https://sum.golang.org - - https://storage.googleapis.com ---- -apiVersion: api.kagent.dev/v1alpha3 -kind: AgentTemplate -metadata: - name: claude-workspace-dev-template -spec: - modelConfig: - name: claude-subscription-model - description: mainloop-dev claude workspace - systemPrompt: You are working in a persistent Mainloop development workspace. Work only - in oldsj/testrepo. Preserve uncommitted and unpushed work. Use the repository and - branch prepared for this Session. - tools: - - mcp: - server: - kind: RemoteMCPServer - name: mainloop-dev ---- -apiVersion: api.kagent.dev/v1alpha3 -kind: Agent -metadata: - name: claude-workspace-dev -spec: - templateRef: - name: claude-workspace-dev-template - harnessRef: - name: claude-workspace-dev-harness diff --git a/k8s/apps/mainloop/overlays/dev-live/kagent/codex.yaml b/k8s/apps/mainloop/overlays/dev-live/kagent/codex.yaml deleted file mode 100644 index 406d2234..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/kagent/codex.yaml +++ /dev/null @@ -1,70 +0,0 @@ -apiVersion: api.kagent.dev/v1alpha3 -kind: Harness -metadata: - name: codex-workspace-dev-harness -spec: - codex: {} - workload: - # Production pin; `make dev-harness` overrides it through DevSpace. - image: ghcr.io/oldsj/kagent-codex-harness:0.0.0-4127ba7@sha256:97405041648fc5486c9767185199b39ac45832076310d588a860cbe05f0f2aed - substrate: - workerPoolRef: - name: kagent-default - snapshotPolicy: - location: s3://ate-snapshots/kagent-dev/ - onQuiesce: Data - sessionIdleTTL: 0s - env: - - name: KAGENT_RUNTIME_GENERATION - value: '3' - - name: GIT_CONFIG_COUNT - value: '2' - - name: GIT_CONFIG_KEY_0 - value: user.name - - name: GIT_CONFIG_VALUE_0 - value: Mainloop dev agent - - name: GIT_CONFIG_KEY_1 - value: user.email - - name: GIT_CONFIG_VALUE_1 - value: 12104969+oldsj@users.noreply.github.com - # mainloop-dev's own Git read/push proxies, named like production's but under - # mainloop-dev.svc.cluster.local (docs/dev-environment.md#live-dev-instance). - git: - origins: - - github.com - readProxyOrigin: http://mainloop-git-read.mainloop-dev.svc.cluster.local - pushProxyOrigin: http://mainloop-git-push.mainloop-dev.svc.cluster.local - extraHTTPSOrigins: - - https://registry.npmjs.org - - https://pypi.org - - https://files.pythonhosted.org - - https://proxy.golang.org - - https://sum.golang.org - - https://storage.googleapis.com ---- -apiVersion: api.kagent.dev/v1alpha3 -kind: AgentTemplate -metadata: - name: codex-workspace-dev-template -spec: - modelConfig: - name: codex-subscription-model - description: mainloop-dev codex workspace - systemPrompt: You are working in a persistent Mainloop development workspace. Work only - in oldsj/testrepo. Preserve uncommitted and unpushed work. Use the repository and - branch prepared for this Session. - tools: - - mcp: - server: - kind: RemoteMCPServer - name: mainloop-dev ---- -apiVersion: api.kagent.dev/v1alpha3 -kind: Agent -metadata: - name: codex-workspace-dev -spec: - templateRef: - name: codex-workspace-dev-template - harnessRef: - name: codex-workspace-dev-harness diff --git a/k8s/apps/mainloop/overlays/dev-live/kagent/kustomization.yaml b/k8s/apps/mainloop/overlays/dev-live/kagent/kustomization.yaml deleted file mode 100644 index a35169c5..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/kagent/kustomization.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -# Dev-only kagent agents for mainloop-dev. kagent itself is shared and -# Argo-managed; these objects are not, and only mainloop-dev refers to them. -# Not applied by default: `make dev-live` never touches kagent; run `make dev-agents`. -# They share the kagent-default worker pool (fine for a prototype) but snapshot under -# their own prefix, s3://ate-snapshots/kagent-dev/, never beside production's. -# DevSpace applies them (deployment dev-agents) and `make dev-harness` swaps -# their harness images without touching the production agents. - -namespace: kagent - -resources: - - remotemcpserver.yaml - - claude.yaml - - codex.yaml - - agent-tokens.yaml diff --git a/k8s/apps/mainloop/overlays/dev-live/kagent/remotemcpserver.yaml b/k8s/apps/mainloop/overlays/dev-live/kagent/remotemcpserver.yaml deleted file mode 100644 index e22151dc..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/kagent/remotemcpserver.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: api.kagent.dev/v1alpha3 -kind: RemoteMCPServer -metadata: - name: mainloop-dev - labels: - kagent.dev/discovery: disabled -spec: - description: mainloop-dev control-plane tools with per-session gateway identity. - protocol: STREAMABLE_HTTP - url: http://mainloop-mcp.mainloop-dev.svc.cluster.local/mcp - headersFrom: - - name: Authorization - value: Bearer kagent-credential-injected diff --git a/k8s/apps/mainloop/overlays/dev-live/kustomization.yaml b/k8s/apps/mainloop/overlays/dev-live/kustomization.yaml deleted file mode 100644 index 91044263..00000000 --- a/k8s/apps/mainloop/overlays/dev-live/kustomization.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -# Shared live-sync instance (`make dev-live`). DevSpace deploys and owns this -# namespace directly; it is not Argo-managed. Secrets are supplied separately -# (see docs/dev-environment.md#live-dev-instance). The server needs -# mainloop-secrets/agent-token-key and the control-auth Secrets to start; the GitHub -# App keys are optional. - -namespace: mainloop-dev - -resources: - - ../../base - - database.yaml - - httproute.yaml - -patches: - - path: config-patch.yaml - - path: backend-patch.yaml - - path: frontend-patch.yaml - -# Needs kagent-control-token and kagent-control-ca in mainloop-dev. -components: - - control-auth - -# The -dev images carry the Go toolchain (server) and Vite (web), built from main. -images: - - name: ghcr.io/oldsj/mainloop-backend - newName: ghcr.io/oldsj/mainloop-server-dev - newTag: main - - name: ghcr.io/oldsj/mainloop-frontend - newName: ghcr.io/oldsj/mainloop-web-dev - newTag: main diff --git a/k8s/apps/mainloop/overlays/kind/README.md b/k8s/apps/mainloop/overlays/kind/README.md deleted file mode 100644 index f3dbb565..00000000 --- a/k8s/apps/mainloop/overlays/kind/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Kind overlay - -Runs the Go server and the React app on a local [Kind](https://kind.sigs.k8s.io) cluster, with a -local PostgreSQL. It is for trying Mainloop out on your own machine. It is not a production or -shared-dev layout. - -The recipe is in [docs/dev-environment.md#kind](../../../../../docs/dev-environment.md#kind). This -page lists what the overlay changes and what it leaves out. - -## What the overlay adds to base - -- Images `mainloop-backend:kind` and `mainloop-frontend:kind` in the default `kind` path. DevSpace - replaces these with its own generated tags. Nothing is pulled from `ghcr.io`, and no pull secret - is used. -- `mainloop-postgres`: one PostgreSQL 16 StatefulSet with a 1 Gi volume. The backend reaches it as - `DB_HOST=mainloop-postgres`. -- Settings for `http://localhost:5173` (the web app) and `MAINLOOP_ALLOW_RESET=1`, which lets - `make dev-reset` wipe the database. The frontend `ORIGIN`, `FRONTEND_SCHEME` and `FRONTEND_DOMAIN` - must match the port forward, so change them together. -- Every Service stays ClusterIP. Nothing is exposed on the node; the apps are reached through port - forwards. - -## Works without kagent - -- The web app and the owner API (`serve-api`), with `mainloop migrate up` run on each pod start. -- The MCP (`serve-mcp`) and Git (`serve-git`) listeners start. Git transport is off, so `serve-git` - answers health only. - -## Needs kagent (not installed here) - -- Native Claude Code and Codex sessions, workspaces and their harness Agents. Schedules too: - `GET /schedules` returns 502, and the backend logs `kagent gateway unreachable` warnings. Agent - tool calls to Mainloop need a kagent installation that can reach the MCP Service. - -## Left out - -- **GitHub.** The App keys in `mainloop-secrets` are empty, so there are no repository pushes, PRs or - GitHub reads. -- **Network policy.** The policies in base and this overlay are applied, but Kind's default CNI - (kindnet) does not enforce them. -- **Hardening.** The secrets are random values created on your machine for this cluster only. Keep - the cluster to yourself, and do not reuse these values anywhere else. - -## What has been run - -On a local Kind v0.33.0 cluster (kubectl v1.37.0, DevSpace v6.3.21): - -- `make kind-up` and `make dev` from an empty namespace: pods ready, `/health` returns 200, the web - app loads in a browser with no console errors. -- A code change followed by `make dev` rolled the backend and frontend pods to the new images. -- `make dev-reset` wiped and migrated the database; `make dev-stop` removed the namespace. - -Not run: sessions, workspaces, schedules, GitHub, and any browser flow beyond the home page and -`/health`. diff --git a/k8s/apps/mainloop/overlays/kind/configmap.yaml b/k8s/apps/mainloop/overlays/kind/configmap.yaml deleted file mode 100644 index 55b26347..00000000 --- a/k8s/apps/mainloop/overlays/kind/configmap.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: mainloop-config - namespace: mainloop -data: - # The browser reaches the web app at http://localhost:5173 and the API at http://localhost:8000 - # through port forwards. The Origin and Host checks compare these values exactly, so keep them - # in step with the forwards and with the VITE_API_URL build argument. - FRONTEND_SCHEME: http - FRONTEND_DOMAIN: localhost:5173 - API_DOMAIN: localhost - DB_HOST: mainloop-postgres - # Lets `make dev-reset` run `mainloop migrate reset` against this throwaway database. - MAINLOOP_ALLOW_RESET: '1' diff --git a/k8s/apps/mainloop/overlays/kind/deployments.yaml b/k8s/apps/mainloop/overlays/kind/deployments.yaml deleted file mode 100644 index 51519efd..00000000 --- a/k8s/apps/mainloop/overlays/kind/deployments.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# Kind loads the images onto the nodes and has no ghcr.io pull secret to use. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-backend - namespace: mainloop -spec: - template: - spec: - imagePullSecrets: null ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mainloop-frontend - namespace: mainloop -spec: - template: - spec: - imagePullSecrets: null - containers: - - name: frontend - env: - - name: ORIGIN - value: http://localhost:5173 diff --git a/k8s/apps/mainloop/overlays/kind/kustomization.yaml b/k8s/apps/mainloop/overlays/kind/kustomization.yaml deleted file mode 100644 index a7178503..00000000 --- a/k8s/apps/mainloop/overlays/kind/kustomization.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -# Local Kind cluster for trying Mainloop out (docs/dev-environment.md#kind). Runs the Go server and -# React app with a local PostgreSQL. kagent, Substrate, the GitHub App and Tailscale exposure are -# not configured here, so the stack boots without them. Images are built on the host and loaded -# into Kind; nothing is pulled from ghcr.io. - -resources: - - ../../base - - postgres.yaml - -patches: - - path: configmap.yaml - - path: deployments.yaml - -images: - - name: ghcr.io/oldsj/mainloop-backend - newName: mainloop-backend - newTag: kind - - name: ghcr.io/oldsj/mainloop-frontend - newName: mainloop-frontend - newTag: kind diff --git a/k8s/apps/mainloop/overlays/prod/devloop/kustomization.yaml b/k8s/apps/mainloop/overlays/prod/devloop/kustomization.yaml index c64a8064..bf6f556d 100644 --- a/k8s/apps/mainloop/overlays/prod/devloop/kustomization.yaml +++ b/k8s/apps/mainloop/overlays/prod/devloop/kustomization.yaml @@ -3,7 +3,7 @@ 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 -# overlays/dev-live/kagent/, a standalone kustomization for the same reason (a resource that must +# 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 diff --git a/scripts/dev-live-harness.sh b/scripts/dev-live-harness.sh index 7712fe27..7a926e6d 100755 --- a/scripts/dev-live-harness.sh +++ b/scripts/dev-live-harness.sh @@ -11,7 +11,10 @@ set -euo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) STATE="${ROOT}/.devspace/dev-harness" -MANIFESTS="${ROOT}/k8s/apps/mainloop/overlays/dev-live/kagent" +if [[ -z ${MAINLOOP_DEV_CONFIG-} && -f "${ROOT}/.env" ]]; then + MAINLOOP_DEV_CONFIG=$(sed -n 's/^MAINLOOP_DEV_CONFIG=//p' "${ROOT}/.env" | tail -1) +fi +MANIFESTS="${MAINLOOP_DEV_CONFIG-}/kagent" KAGENT_DIR=${KAGENT_DIR:-${HOME}/dev/kagent} REGISTRY=ghcr.io REPO=oldsj @@ -21,6 +24,8 @@ image() { if [[ -s "${STATE}/${provider}" ]]; then cat "${STATE}/${provider}" else + # DevSpace resolves this for every profile, so print nothing when no live config is set. + [[ -f "${MANIFESTS}/${provider}.yaml" ]] || return 0 sed -n 's/^ *image: *\(.*\)$/\1/p' "${MANIFESTS}/${provider}.yaml" | head -1 fi } diff --git a/scripts/dev-live.sh b/scripts/dev-live.sh index c2b7c8f3..58d0298f 100755 --- a/scripts/dev-live.sh +++ b/scripts/dev-live.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Shared live-sync instance mainloop-dev on internal-01 (DevSpace profile `live`). +# Shared live-sync instance mainloop-dev (DevSpace profile `live`). Needs MAINLOOP_DEV_CONFIG and +# DEV_LIVE_CONTEXT, from the environment or .env (docs/dev-environment.md#live-dev-instance). # # dev-live.sh dev deploy and start syncing (Ctrl-C stops sync; pods keep running). # If MAINLOOP_API_URL is set it first acquires the devloop lease as the @@ -19,9 +20,13 @@ set -euo pipefail cd "$(dirname "$0")/.." -CONTEXT=${DEV_LIVE_CONTEXT:-admin@internal-01} +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi NAMESPACE=mainloop-dev -DEVSPACE=(devspace --kube-context "${CONTEXT}" --namespace "${NAMESPACE}" --profile live) # lease calls the devloop lease REST API (served by the control-plane instance that runs the # devloop tools, not by mainloop-dev itself): POST /devloop/lease/acquire or .../release. @@ -70,6 +75,10 @@ lease-release) *) ;; esac +CONTEXT=${DEV_LIVE_CONTEXT:?set DEV_LIVE_CONTEXT to the kube context of the live instance} +: "${MAINLOOP_DEV_CONFIG:?set MAINLOOP_DEV_CONFIG to the live instance config directory}" +DEVSPACE=(devspace --kube-context "${CONTEXT}" --namespace "${NAMESPACE}" --profile live) + # DevSpace renders manifests with a bare `kubectl create --dry-run=client`, which uses the # kubeconfig's current context. Prepend a credential-free fragment that selects $CONTEXT, # so the render never talks to whatever context happens to be current. @@ -119,7 +128,7 @@ status) grep -E "^NAME|-dev(-|\s|$)" || true ;; *) - sed -n '2,16p' "$0" >&2 + sed -n '2,17p' "$0" >&2 exit 2 ;; esac diff --git a/scripts/kind/deploy.sh b/scripts/kind/deploy.sh index 55b44e07..12dca390 100755 --- a/scripts/kind/deploy.sh +++ b/scripts/kind/deploy.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Apply the Kind overlay and wait until PostgreSQL and both app Deployments are ready. +# Install the chart with the Kind values and wait until PostgreSQL and both app Deployments are ready. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -12,10 +12,11 @@ if ! kubectl --context "${CONTEXT}" get secret mainloop-secrets -n mainloop >/de exit 1 fi -echo "=== Applying the Kind overlay to ${CONTEXT} ===" -kubectl --context "${CONTEXT}" apply -k "${REPO_ROOT}/k8s/apps/mainloop/overlays/kind" +echo "=== Installing the chart into ${CONTEXT} ===" +helm --kube-context "${CONTEXT}" upgrade --install mainloop "${REPO_ROOT}/charts/mainloop" \ + --namespace mainloop --values "${REPO_ROOT}/charts/mainloop/values-kind.yaml" --take-ownership -# The overlay names the images :kind. A rebuild reloads them under the same name, so apply alone +# The Kind values name the images :kind. A rebuild reloads them under the same name, so apply alone # leaves the old pods running. Restart the app pods to pick up the newly loaded images. kubectl --context "${CONTEXT}" rollout restart deployment/mainloop-backend deployment/mainloop-frontend -n mainloop diff --git a/server/internal/deploytest/render_test.go b/server/internal/deploytest/render_test.go index a94fddd3..549e3eb9 100644 --- a/server/internal/deploytest/render_test.go +++ b/server/internal/deploytest/render_test.go @@ -1,4 +1,4 @@ -// Package deploytest renders the tracked Kubernetes manifests offline and checks the properties +// Package deploytest renders the Helm chart (charts/mainloop) offline and checks the properties // the Go server and React app depend on. No cluster is contacted. package deploytest @@ -23,9 +23,18 @@ import ( utilyaml "k8s.io/apimachinery/pkg/util/yaml" ) -const manifests = "../../../k8s/apps/mainloop" +const chart = "../../../charts/mainloop" -// rendered is one kustomization after `kubectl kustomize`, split by kind. +// installs are the values files a deployment of the chart is rendered with. The production and +// live-dev files are generic stand-ins; real deployments keep theirs in their own repository. +var installs = map[string]struct{ namespace, values string }{ + "defaults": {"mainloop", ""}, + "production": {"mainloop", "ci/production-values.yaml"}, + "live-dev": {"mainloop-dev", "ci/live-dev-values.yaml"}, + "kind": {"mainloop", "values-kind.yaml"}, +} + +// rendered is one install after `helm template`, split by kind. type rendered struct { deployments map[string]appsv1.Deployment statefulSets map[string]appsv1.StatefulSet @@ -39,19 +48,27 @@ type rendered struct { objects []string } -// render builds a kustomization with the local kubectl (client-side, offline). +// render templates the chart with the local helm (client-side, offline). func render(t *testing.T, path string) rendered { t.Helper() - kubectl, err := exec.LookPath("kubectl") + helm, err := exec.LookPath("helm") if err != nil { - t.Skip("kubectl is not installed") + t.Skip("helm is not installed") + } + install, ok := installs[path] + if !ok { + t.Fatalf("no install %q", path) } - cmd := exec.CommandContext(t.Context(), kubectl, "kustomize", filepath.Join(manifests, path)) + args := []string{"template", "mainloop", chart, "--namespace", install.namespace} + if install.values != "" { + args = append(args, "--values", filepath.Join(chart, install.values)) + } + cmd := exec.CommandContext(t.Context(), helm, args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { - t.Fatalf("kubectl kustomize %s: %v\n%s", path, err, stderr.String()) + t.Fatalf("helm template %s: %v\n%s", path, err, stderr.String()) } r := rendered{ @@ -166,10 +183,10 @@ func serverContainers(pod corev1.PodSpec) []corev1.Container { return out } -var overlays = []string{"base", "overlays/prod", "overlays/dev-live", "overlays/kind"} +var installNames = []string{"defaults", "production", "live-dev", "kind"} func TestServerContainersRunTheGoBinary(t *testing.T) { - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { pod := render(t, path).backend(t) for _, c := range serverContainers(pod) { @@ -205,7 +222,7 @@ func TestServeSubcommandsAndPorts(t *testing.T) { "mcp": {"serve-mcp", []int32{8002}}, "git": {"serve-git", []int32{8003, 8004}}, } - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { cs := containers(render(t, path).backend(t)) for name, w := range want { @@ -227,7 +244,7 @@ func TestServeSubcommandsAndPorts(t *testing.T) { } func TestProbesAreHTTPOrTCP(t *testing.T) { - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { r := render(t, path) for name, d := range r.deployments { @@ -256,7 +273,7 @@ func TestProbesAreHTTPOrTCP(t *testing.T) { } func TestMigrateInitContainerRunsFirst(t *testing.T) { - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { pod := render(t, path).backend(t) var migrate *corev1.Container @@ -290,7 +307,7 @@ func TestMigrateInitContainerRunsFirst(t *testing.T) { func TestSecretsAreReferencedNotEmbedded(t *testing.T) { secrets := []string{"DB_PASSWORD", "AGENT_TOKEN_KEY", "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY"} - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { for _, c := range serverContainers(render(t, path).backend(t)) { env := envByName(c) @@ -307,9 +324,9 @@ func TestSecretsAreReferencedNotEmbedded(t *testing.T) { } } -// Base and prod require the GitHub App keys from mainloop-secrets; dev-live makes them optional. +// Every install but the live dev instance requires the GitHub App keys from mainloop-secrets. func TestGitHubAppKeysComeFromMainloopSecrets(t *testing.T) { - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { for name, c := range containers(render(t, path).backend(t)) { env := envByName(c) @@ -322,7 +339,7 @@ func TestGitHubAppKeysComeFromMainloopSecrets(t *testing.T) { if ref.SecretKeyRef.Name != "mainloop-secrets" || ref.SecretKeyRef.Key != key { t.Errorf("%s: %s refs %s/%s", name, envName, ref.SecretKeyRef.Name, ref.SecretKeyRef.Key) } - if optional := ref.SecretKeyRef.Optional != nil && *ref.SecretKeyRef.Optional; optional != (path == "overlays/dev-live") { + if optional := ref.SecretKeyRef.Optional != nil && *ref.SecretKeyRef.Optional; optional != (path == "live-dev") { t.Errorf("%s: %s optional=%v in %s", name, envName, optional, path) } } @@ -337,7 +354,7 @@ func TestGitHubAppKeysComeFromMainloopSecrets(t *testing.T) { } func TestGitListenersAreInternalAndShareTheBackendEnvironment(t *testing.T) { - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { r := render(t, path) pod := r.backend(t) @@ -401,7 +418,7 @@ func envEqual(a, b corev1.EnvVar) bool { // The git ports accept traffic only from the egress proxy, and no NetworkPolicy adds another peer. func TestNoNetworkPolicyOpensGitPortsToAnotherPeer(t *testing.T) { - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { allowed := map[intstr.IntOrString]bool{} for _, policy := range render(t, path).policies { @@ -445,7 +462,7 @@ func TestNoClusterScopedSecretAccess(t *testing.T) { grants := func(rule rbacv1.PolicyRule) bool { return (slices.Contains(rule.Resources, "secrets") || slices.Contains(rule.Resources, "*")) && len(rule.Verbs) > 0 } - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { r := render(t, path) defined := map[string]bool{} @@ -476,13 +493,12 @@ func TestNoClusterScopedSecretAccess(t *testing.T) { func TestFeatureFlagsDefaultOff(t *testing.T) { flags := []string{"GIT_TRANSPORT_ENABLED", "PUSH_GATE_ENABLED", "MAINLOOP_MERGE_TOOLS_ENABLED", "MAINLOOP_OWNER_HITL_WRITES_ENABLED", "MAINLOOP_OWNER_POLICY_WRITES_ENABLED"} - // The live dev instance is a single-owner prototype, so the owner turned HITL answers and, - // owner-approved for dev, Git transport and the push gate on there (through mainloop-dev's own - // proxies). Every other overlay, prod included, keeps every flag off. + // The live dev instance is a single-owner prototype, so it may turn HITL answers, Git transport + // and the push gate on (through its own proxies). Every other install keeps every flag off. allowed := map[string][]string{ - "overlays/dev-live": {"GIT_TRANSPORT_ENABLED", "PUSH_GATE_ENABLED", "MAINLOOP_OWNER_HITL_WRITES_ENABLED"}, + "live-dev": {"GIT_TRANSPORT_ENABLED", "PUSH_GATE_ENABLED", "MAINLOOP_OWNER_HITL_WRITES_ENABLED"}, } - for _, path := range overlays { + for _, path := range installNames { t.Run(path, func(t *testing.T) { data := render(t, path).configMaps["mainloop-config"].Data for _, flag := range flags { @@ -498,11 +514,11 @@ func TestFeatureFlagsDefaultOff(t *testing.T) { } func TestProdIsNotTheLiveDevInstance(t *testing.T) { - data := render(t, "overlays/prod").configMaps["mainloop-config"].Data + data := render(t, "production").configMaps["mainloop-config"].Data if data["MAINLOOP_ALLOW_RESET"] != "" { t.Error("prod must not allow `migrate reset`") } - for _, c := range render(t, "overlays/prod").backend(t).Containers { + for _, c := range render(t, "production").backend(t).Containers { if strings.Contains(c.Image, "-dev") { t.Errorf("prod container %s runs dev image %s", c.Name, c.Image) } @@ -510,7 +526,7 @@ func TestProdIsNotTheLiveDevInstance(t *testing.T) { } func TestLiveDev(t *testing.T) { - r := render(t, "overlays/dev-live") + r := render(t, "live-dev") pod := r.backend(t) t.Run("images are the arm64 main dev images", func(t *testing.T) { @@ -562,7 +578,7 @@ func TestLiveDev(t *testing.T) { if env["VITE_API_URL"].Value != "/api" || env["VITE_HOST"].Value != "0.0.0.0" || env["VITE_PORT"].Value != "3000" { t.Errorf("vite env: %+v", env) } - if env["__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"].Value != "mainloop-dev.olds.network" { + if env["__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"].Value != "mainloop-dev.example.test" { t.Errorf("allowed host: %q", env["__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"].Value) } }) @@ -615,39 +631,11 @@ func TestLiveDev(t *testing.T) { }) } -// The backend publishes per-session MCP credential Secrets into kagent, so its ServiceAccount -// needs the production Role there. `make dev-agents` applies the binding. -func TestLiveDevBackendMayPublishAgentCredentials(t *testing.T) { - live := render(t, "overlays/dev-live") - if got := live.backend(t).ServiceAccountName; got != "mainloop-backend" { - t.Fatalf("backend ServiceAccount %q, want mainloop-backend", got) - } - var binding *rbacv1.RoleBinding - for _, b := range render(t, "overlays/dev-live/kagent").roleBindings { - if b.Name == "mainloop-dev-agent-tokens" { - binding = &b - } - } - if binding == nil { - t.Fatal("no RoleBinding mainloop-dev-agent-tokens in the dev-live kagent render") - } - if binding.Namespace != "kagent" { - t.Errorf("namespace %q, want kagent", binding.Namespace) - } - if binding.RoleRef.Kind != "Role" || binding.RoleRef.Name != "mainloop-agent-tokens" { - t.Errorf("roleRef %+v, want Role mainloop-agent-tokens", binding.RoleRef) - } - want := rbacv1.Subject{Kind: "ServiceAccount", Name: "mainloop-backend", Namespace: "mainloop-dev"} - if len(binding.Subjects) != 1 || binding.Subjects[0] != want { - t.Errorf("subjects %+v, want only %+v", binding.Subjects, want) - } -} - -// The Kind overlay (`make dev`) runs the same Go images as prod against a local PostgreSQL. It must +// The Kind install (`make dev`) runs the same Go images as prod against a local PostgreSQL. It must // boot without kagent, the GitHub App or a registry pull, and its Services and origin must match // the port forwards in devspace.yaml and the Makefile. -func TestKindOverlayBootsWithoutKagentOrGitHubApp(t *testing.T) { - r := render(t, "overlays/kind") +func TestKindBootsWithoutKagentOrGitHubApp(t *testing.T) { + r := render(t, "kind") for _, o := range r.objects { if lower := strings.ToLower(o); strings.Contains(lower, "kagent") || strings.Contains(lower, "substrate") {