diff --git a/distributed_cli/skills/distributed-graphql/SKILL.md b/distributed_cli/skills/distributed-graphql/SKILL.md index c805d707..49d00681 100644 --- a/distributed_cli/skills/distributed-graphql/SKILL.md +++ b/distributed_cli/skills/distributed-graphql/SKILL.md @@ -185,6 +185,13 @@ distributed scaffold my-service --query-api --read-models --store sqlite Emits typed causal handlers, a service-derived GraphQL engine, `src/query/` permissions, the `graphql` feature, and repository/token-key wiring. +## Client / replica drift (dogfood) + +If unit tests pass but the live UI 500s with schema/surface errors: the API +pod is still compiling or clients/`js/dist` are stale. Rebuild and wait — +don't redesign roles. Bare `__typename` (role fingerprint) ≠ application +surface clients. + ## Reference - Framework docs: `README § GraphQL query service` in the Distributed repo diff --git a/distributed_cli/skills/distributed-usage/SKILL.md b/distributed_cli/skills/distributed-usage/SKILL.md index f76d85c4..91da624e 100644 --- a/distributed_cli/skills/distributed-usage/SKILL.md +++ b/distributed_cli/skills/distributed-usage/SKILL.md @@ -309,6 +309,14 @@ Rules the fixture demonstrates: Run the full app: `cd tests/e2e-ui && make`. Suite: `make test`. +### e2e-ui cluster-dev dogfood (agents) + +Namespace = hops `--name`. Fix the live pods, not a host `make run`. After +source changes: `make gen-client`, restart, **wait until API logs `listening` +and Vite is ready**, then curl the FQDNs. Schema/pure errors usually mean +stale build, not a roles redesign. Full checklist: hops skill +`references/local-workbench.md` § Dogfood. + ## Manifest entrypoint Every service should export `read_model_catalog()` registering its read diff --git a/tests/e2e-ui/.gitignore b/tests/e2e-ui/.gitignore index 0e859a8b..4849ea6f 100644 --- a/tests/e2e-ui/.gitignore +++ b/tests/e2e-ui/.gitignore @@ -14,3 +14,4 @@ e2e/.auth playwright-report test-results blob-report +gitops/cluster/e2e-identity/grants.yaml diff --git a/tests/e2e-ui/README.md b/tests/e2e-ui/README.md index 3d34c7d0..2709df2b 100644 --- a/tests/e2e-ui/README.md +++ b/tests/e2e-ui/README.md @@ -4,6 +4,42 @@ A copyable Distributed service and SvelteKit UI demonstrating one modeled projection from aggregate transition to server read model, generated GraphQL client, optimistic replica update, and causal confirmation. +## Option A — hops local workbench (preferred for cluster-shaped DX) + +One-time: `hops local start` (control plane). Then: + +```bash +cd tests/e2e-ui +# apps (workbench) +hops local up ./gitops/envs/local --name e2e +hops local status +hops local open +# when done +hops local down --name e2e +``` + +Optional **platform XRs** under `gitops/cluster/` (AuthStack + PSQLStack): + +```bash +hops config install /xrs/stacks/k8s/psql +hops config install /xrs/stacks/k8s/auth +kubectl apply -f gitops/cluster/stacks/psql.yaml +kubectl apply -f gitops/cluster/stacks/auth.yaml +``` + +Optional cloud providers (non-secret YAML only): + +```bash +hops local aws --gitops ./gitops/cluster +hops local github --gitops ./gitops/cluster +``` + +Charts: `api/.gitops/deploy`, `ui/.gitops/deploy`. +App Applications: `gitops/envs/local/` (legacy: `gitops/env/local/`). +Control plane: `gitops/cluster/` (`stacks/`, `configurations/`, …). + +## Option B — compose + host processes + ```bash cd tests/e2e-ui make up diff --git a/tests/e2e-ui/api/.gitops/deploy/Chart.yaml b/tests/e2e-ui/api/.gitops/deploy/Chart.yaml new file mode 100644 index 00000000..fde17c03 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: e2e-ui-api +description: Local workbench dogfood chart for e2e-ui API (cluster-dev / package postures) +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/tests/e2e-ui/api/.gitops/deploy/README.md b/tests/e2e-ui/api/.gitops/deploy/README.md new file mode 100644 index 00000000..08a1c32f --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/README.md @@ -0,0 +1,13 @@ +# e2e-ui API chart (workbench) + +Renders: + +- **Deployment / Service** (`appRuntime: cluster-dev` \| `package`) +- **Optional PSQLCluster** when `database.enabled: true` + +OIDC project / demo humans / web app live on the **UI** chart +(`ui/.gitops/deploy` `identity.*`). This chart only consumes residual OIDC +settings (`OIDC_ISSUER`, `OIDC_AUDIENCE`, shared `e2e-ui-oidc` secret keys) so +Bearer tokens map engine roles. + +Platform AuthStack + ProviderConfig stay under `gitops/cluster/`. diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/_helpers.tpl b/tests/e2e-ui/api/.gitops/deploy/templates/_helpers.tpl new file mode 100644 index 00000000..42efd301 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/_helpers.tpl @@ -0,0 +1,9 @@ +{{- define "e2e-ui-api.name" -}} +{{- default .Chart.Name .Values.name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "e2e-ui-api.labels" -}} +app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/deployment-cluster-dev.yaml b/tests/e2e-ui/api/.gitops/deploy/templates/deployment-cluster-dev.yaml new file mode 100644 index 00000000..aad2afda --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/deployment-cluster-dev.yaml @@ -0,0 +1,87 @@ +{{- if or (eq .Values.appRuntime "cluster-dev") (and .Values.local (eq .Values.appRuntime "cluster-dev")) }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "e2e-ui-api.name" . }} + labels: + {{- include "e2e-ui-api.labels" . | nindent 4 }} + hops.ops.com.ai/runtime: cluster-dev +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + hops.ops.com.ai/runtime: cluster-dev + spec: + containers: + - name: api + image: {{ .Values.clusterDev.image | quote }} + workingDir: {{ .Values.clusterDev.workingDir | quote }} + {{- if eq .Values.sourceDelivery.mode "sync" }} + command: + - /bin/bash + - -c + - | + set -e + echo "waiting for source sync at {{ .Values.sourceDelivery.mountPath }}..." + waited=0 + while [ ! -f {{ .Values.sourceDelivery.mountPath }}/.hops-synced ] \ + && [ ! -f {{ .Values.clusterDev.workingDir }}/Cargo.toml ]; do + sleep 2 + waited=$((waited + 2)) + if [ $((waited % 30)) -eq 0 ]; then + echo "still waiting for hops source sync (${waited}s) — is tar watcher running?" + fi + if [ "$waited" -ge 600 ]; then + echo "ERROR: source sync timeout after ${waited}s (no .hops-synced / Cargo.toml)" + exit 1 + fi + done + echo "source present; starting dev process" + command -v cargo + rustc --version + cd {{ .Values.clusterDev.workingDir }} + cargo run -p e2e-runner --bin e2e-ui + {{- else }} + command: + {{- toYaml .Values.clusterDev.command | nindent 12 }} + {{- end }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http + env: + {{- range $k, $v := .Values.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- range $k, $ref := .Values.secretEnv }} + - name: {{ $k }} + valueFrom: + secretKeyRef: + name: {{ $ref.secretName | quote }} + key: {{ $ref.key | quote }} + {{- end }} + # Keep cargo on PATH even if something mangles the environment. + - name: PATH + value: "/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + volumeMounts: + - name: source + mountPath: {{ .Values.sourceDelivery.mountPath }} + - name: cargo-target + mountPath: {{ .Values.clusterDev.workingDir }}/target + volumes: + - name: source + {{- if eq .Values.sourceDelivery.mode "hostPath" }} + hostPath: + path: {{ .Values.sourceDelivery.hostPath | quote }} + type: Directory + {{- else }} + emptyDir: {} + {{- end }} + - name: cargo-target + emptyDir: {} +{{- end }} diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/deployment-package.yaml b/tests/e2e-ui/api/.gitops/deploy/templates/deployment-package.yaml new file mode 100644 index 00000000..679bb3a9 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/deployment-package.yaml @@ -0,0 +1,31 @@ +{{- /* Package Deployment: production/CI image posture. Omitted for local/cluster-dev. */ -}} +{{- if and (not .Values.local) (ne .Values.appRuntime "cluster-dev") (ne .Values.appRuntime "host") }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "e2e-ui-api.name" . }} + labels: + {{- include "e2e-ui-api.labels" . | nindent 4 }} + hops.ops.com.ai/runtime: package +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + hops.ops.com.ai/runtime: package + spec: + containers: + - name: api + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http + env: + - name: BIND + value: {{ .Values.env.BIND | quote }} +{{- end }} diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/psqlcluster.yaml b/tests/e2e-ui/api/.gitops/deploy/templates/psqlcluster.yaml new file mode 100644 index 00000000..12eb9f56 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/psqlcluster.yaml @@ -0,0 +1,15 @@ +{{- if and .Values.database.enabled (or .Values.local (eq .Values.appRuntime "cluster-dev") (eq .Values.appRuntime "host")) }} +# Local-capable PSQLCluster XR. Storage class defaults to values.database.storage.class +# (local-path for k3s/dory; override for other provisioners). +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLCluster +metadata: + name: {{ .Values.database.name }} + labels: + {{- include "e2e-ui-api.labels" . | nindent 4 }} +spec: + clusterName: {{ .Values.database.clusterName }} + storage: + size: {{ .Values.database.storage.size }} + class: {{ .Values.database.storage.class }} +{{- end }} diff --git a/tests/e2e-ui/api/.gitops/deploy/templates/service.yaml b/tests/e2e-ui/api/.gitops/deploy/templates/service.yaml new file mode 100644 index 00000000..b6ff9833 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "e2e-ui-api.name" . }} + labels: + {{- include "e2e-ui-api.labels" . | nindent 4 }} +spec: + selector: + app.kubernetes.io/name: {{ include "e2e-ui-api.name" . }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/tests/e2e-ui/api/.gitops/deploy/values.yaml b/tests/e2e-ui/api/.gitops/deploy/values.yaml new file mode 100644 index 00000000..f1046c62 --- /dev/null +++ b/tests/e2e-ui/api/.gitops/deploy/values.yaml @@ -0,0 +1,67 @@ +# Local workbench values for e2e-ui API. +# Storage class for PSQLCluster: override per local CP (e.g. local-path, hostpath, psql). + +local: false +appRuntime: package # host | cluster-dev | package + +name: e2e-ui-api +image: + repository: ghcr.io/hops-ops/e2e-ui-api + tag: latest + pullPolicy: IfNotPresent + +# Source tree is the *distributed* monorepo root; API Cargo workspace is tests/e2e-ui. +clusterDev: + image: rust:1.94-bookworm + # bash -c keeps Docker ENV (PATH includes /usr/local/cargo/bin). -lc can drop it. + command: + - /bin/bash + - -c + - | + set -e + cd /workspace/tests/e2e-ui + cargo run -p e2e-runner --bin e2e-ui + workingDir: /workspace/tests/e2e-ui + sourcePath: /workspace + +service: + port: 8791 + targetPort: 8791 + +env: + BIND: "0.0.0.0:8791" + # Offline-friendly default until PSQL platform pack is installed. + DATABASE_URL: "sqlite:/tmp/e2e-ui.db?mode=rwc" + # OidcBearer — requires issuer **and** audience (project id). If either is empty + # the API falls back to DevHeaders and live user surfaces misbehave. + OIDC_ISSUER: "http://zitadel-zitadel.auth.svc.cluster.local:8080" + # Residual after Zitadel Project Ready (external-name / status.atProvider.id). + # Override from Application values (gitops/envs/local/api.yaml) for local dogfood. + OIDC_AUDIENCE: "" + OIDC_JWKS_URI: "http://zitadel-zitadel.auth.svc.cluster.local:8080/oauth/v2/keys" + ZITADEL_PROJECT_ID: "" + +# Residual secrets (same e2e-ui-oidc object as the UI chart, in app namespace). +# OIDC app + humans live on the UI chart (identity.*); API only consumes tokens. +secretEnv: + OIDC_CLIENT_ID: + secretName: e2e-ui-oidc + key: OIDC_CLIENT_ID + ZITADEL_SERVICE_USER_TOKEN: + secretName: e2e-ui-oidc + key: ZITADEL_SERVICE_USER_TOKEN + +database: + # Dogfood local CP often lacks PSQLCluster CRD; disable by default for up path. + # Enable when PSQL pack is installed: --set database.enabled=true via Application values. + enabled: false + name: e2e-ui-api-psql + clusterName: local + storage: + size: 2Gi + class: local-path + +sourceDelivery: + mode: none # none | hostPath | sync + hostPath: "" + mountPath: /workspace diff --git a/tests/e2e-ui/gitops/README.md b/tests/e2e-ui/gitops/README.md new file mode 100644 index 00000000..b80301c0 --- /dev/null +++ b/tests/e2e-ui/gitops/README.md @@ -0,0 +1,26 @@ +# e2e-ui local gitops + +```text +gitops/ + cluster/ # fixture CP tree (real meta repos: put this at meta root) + envs/ + local/ # app Applications → worktree namespaces + env/local/ # deprecated alias of envs/local +``` + +```bash +# Bootstrap + cluster gitops apply/watch (stays in foreground = gitops cluster) +hops local start --backend dory --gitops ./gitops/cluster + +# Or cluster watch alone if CP already started +# hops local gitops cluster ./gitops/cluster + +# Per-worktree apps (watches by default) — separate terminal +hops local gitops worktree ./gitops/envs/local --name dogfood + +# One-shot (CI / scripts): add --once +# hops local gitops cluster ./gitops/cluster --once +``` + +On multi-project metas: put `gitops/cluster` at the **meta root**; projects keep +`.gitops/deploy` charts; env Application YAMLs point at those charts. diff --git a/tests/e2e-ui/gitops/cluster/README.md b/tests/e2e-ui/gitops/cluster/README.md new file mode 100644 index 00000000..c68c9fd3 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/README.md @@ -0,0 +1,74 @@ +# `gitops/cluster` — local control-plane configuration + +> **Real meta repos:** put this tree at the **meta root** (`gitops/cluster/`), +> not inside a single project. One local CP (dory) serves every worktree; apps +> only get different **namespaces**. This e2e-ui copy is a self-contained +> fixture — same files, nested under the test app. Point hops with +> `hops local gitops cluster ./gitops/cluster` or `HOPS_LOCAL_CLUSTER` when +> applying a meta-root cluster tree. + +Platform / CP resources for the **shared** local control plane: + +```text +/ + gitops/cluster/ # ← this tree (AuthStack, PSQLStack, configurations) + gitops/envs/local/ # Application YAMLs → namespace = --name + clients/foo/.gitops/deploy/ + platform/api/.gitops/deploy/ +``` + +| Path | Contents | +|------|----------| +| `configurations/` | Crossplane `Configuration` installs (psql-stack, auth-stack, secret-stack) | +| `stacks/psql.yaml` | **`PSQLStack` XR** — CNPG + SC `psql` (local-path) | +| `stacks/auth.yaml` | **`AuthStack` XR** — Zitadel + embedded Postgres | +| `secrets/` | **`SecretStack`** + vault-auth-delegator CRB (ESO + Vault; k8s auth via Helm postStart) | +| `auth/` | Auth residuals (e.g. masterkey ExternalSecret), not the XR claim | +| `providers/` | Provider installs + per-provider DRCs (`helm.yaml` / `helm-drc.yaml`, …) | +| `providerconfigs/` | ProviderConfig shapes (`secretRef` only) | +| `e2e-identity/` | **Moved** — worktree UI chart `identity.*` values (pointer README only) | + +## Apply + watch (local gitops vibes) + +`hops local start --gitops PATH` is bootstrap then **`hops local gitops cluster +PATH`** (apply + watch). Start stays in the foreground until Ctrl+C. Day-to-day +without re-bootstrap: `hops local gitops cluster` alone. Use `--once` for a +single reconcile (CI/scripts). + +```bash +# Bootstrap + cluster apply/watch (Ctrl+C to stop) +hops local start --backend dory --gitops ./gitops/cluster + +# Cluster-only if CP already up +# hops local gitops cluster ./gitops/cluster + +# Per-worktree apps (other terminal) +hops local gitops worktree ./gitops/envs/local --name dogfood +``` + +Crossplane reconciles XRs (`PSQLStack`, `AuthStack`, `SecretStack`, …) after each apply. +Edit `stacks/auth.yaml`, `stacks/psql.yaml`, or `secrets/stack.yaml` → saved → applied → CP converges. + +**SecretStack (local):** install the Configuration from source first +(`hops config install --path …/xrs/stacks/aws/secret`), then apply this cluster tree +so `secrets/stack.yaml` is not stuck Unpacking. + +## vs `gitops/envs/` + +| Tree | Purpose | +|------|---------| +| **cluster/** | CP packages + platform XRs (PSQLStack, AuthStack) | +| **envs/\-web` — redirects + Login V2 baseUri) | + +| Secret | Purpose | +|--------|---------| +| `e2e-human-passwords` | Keys `alice`, `bob`, `admin` (cluster-shared) | +| `zitadel-credentials` | Provider PC / ClusterProviderConfig (`hops local zitadel`) | + +```bash +kubectl -n default create secret generic e2e-human-passwords \ + --from-literal=alice=Password1! \ + --from-literal=bob=Password1! \ + --from-literal=admin=Password1! +``` + +Demo login (every env): **alice / bob / admin · Password1!** + +## e2e-ui app (`ns: --name`) + +| Secret | Keys | Used by | +|--------|------|---------| +| `e2e-ui-oidc` | `AUTH_SECRET`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `ZITADEL_SERVICE_USER_TOKEN` | UI chart `secretEnv` → `secretKeyRef` | + +```bash +# OIDC client secret: regenerate via Management API _generate_client_secret +# Login-client PAT: AuthStack residual secret auth/login-client key pat +kubectl -n dogfood create secret generic e2e-ui-oidc \ + --from-literal=AUTH_SECRET='local-workbench-dogfood-auth-secret-not-for-prod' \ + --from-literal=OIDC_CLIENT_ID='…from Oidc app…' \ + --from-literal=OIDC_CLIENT_SECRET='…from Oidc app create…' \ + --from-literal=ZITADEL_SERVICE_USER_TOKEN="$(kubectl -n auth get secret login-client -o jsonpath='{.data.pat}' | base64 -d)" +``` + +Non-secret OIDC config (issuer, AUTH_URL) stays in `ui/.gitops/deploy/values.yaml`. + +### Login V2 (required for browser sign-in) + +Gitops (UI chart when `identity.enabled`): + +1. **Oidc** app MR — redirects + `loginVersion.loginV2.baseUri` from release ns +2. **Features** MR (`instance.zitadel…/Features`) — instance `loginV2.required` + + `baseUri` so authorize redirects to this UI’s `/login` + +Both use `http://e2e-ui-ui..svc.cluster.local:5180`. Instance Features +is global per AuthStack (last applied worktree wins). Do not hand-`PUT` +`/v2/features/instance` — re-reconcile the UI chart instead. + +Prefer app-level baseUri over instance-wide +defaults so multiple worktrees can coexist. + +## SecretStack / ESO + Vault (`secrets/`) + +Local fixture uses **`backend: vault`** with **`vault.install: true`** (in-cluster +Vault Helm + ESO). No AWS PodIdentity on dory. + +| Resource | Purpose | +|----------|---------| +| `secrets/stack.yaml` | SecretStack XR — ESO + Vault Helm + ClusterSecretStore | +| `secrets/vault-auth-delegator.yaml` | CRB: Vault SA → `system:auth-delegator` (TokenReview) | +| Helm `vault` `server.postStart` | Init/unseal (once), KV mount, k8s auth + ESO role | +| PVC (`dataStorage` 1Gi) | File storage under `/vault/data` (survives pod restarts) | +| ClusterSecretStore `vault` | ESO → Vault (Ready after postStart + CRB) | + +### Persistence + +`server.dev` is **off**. Vault runs standalone with `storage "file"` on a PVC +(default StorageClass, dory: `local-path`). First start initializes and writes: + +```text +/vault/data/.hops-init # Unseal Key 1 + Initial Root Token (chmod 600) +``` + +Later starts: unseal from that file, re-apply k8s auth (idempotent). **KV data +survives pod restarts**; deleting the PVC is a full reset. + +Root token is **not** the fixed string `root` (that was dev-only). For CLI writes: + +```bash +export VAULT_TOKEN="$( + kubectl -n vault exec vault-0 -- \ + awk '/^Initial Root Token:/{print $NF}' /vault/data/.hops-init +)" +export VAULT_ADDR=http://127.0.0.1:8200 +# kubectl port-forward -n vault svc/vault 8200:8200 # if needed +hops secrets sync vault -y +``` + +### Declarative trust path + +1. Apply cluster gitops (SecretStack + CRB). +2. postStart: init (once) → unseal → `secret/` KV v2 → k8s auth for ESO. +3. ClusterSecretStore `vault` Ready. +4. **Write** with `hops secrets sync vault`. +5. **Read** via ExternalSecrets (auth masterkey, UI oidc/humans). + +Install package from source before first apply: + +```bash +hops config install --path /xrs/stacks/aws/secret --context dory +hops local gitops cluster ./gitops/cluster +``` + +Switching an existing **dev/inmem** Vault release to file storage may need a +one-time recreate of the Helm release / PVC (data was ephemeral anyway). + +Do **not** put the SecretStack claim under meta `local/` — that folder is for +ad-hoc colima-style one-off claims, not the workbench cluster gitops path. + +## Rules + +1. **Never** put passwords, PATs, client secrets, or masterkeys in committed YAML. +2. Gitops may name Secrets and keys (`secretRef` / `secretEnv`). +3. Create Secrets before the XRs/Deployments that need them (or accept CrashLoop until present). +4. Cloud: prefer ESO / SOPS; local: `kubectl create` or `hops local zitadel|aws|github`. +5. Platform secrets engine for local CP lives in **`gitops/cluster/secrets/`**, not `local/`. diff --git a/tests/e2e-ui/gitops/cluster/auth/README.md b/tests/e2e-ui/gitops/cluster/auth/README.md new file mode 100644 index 00000000..10c90945 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/auth/README.md @@ -0,0 +1,56 @@ +# Auth (local) + +| File | Kind | +|------|------| +| [`../stacks/auth.yaml`](../stacks/auth.yaml) | `AuthStack` XR — Zitadel + embedded `PSQLCluster` | +| `external-secret-masterkey.yaml` | Optional ESO materialize of `zitadel-masterkey` from Vault | + +## Local addressing (no ingress) + +| | Value | +|--|--------| +| Install namespace | **`auth`** | +| AuthStack XR name | **`zitadel`** | +| Helm Service (default fullname) | **`zitadel-zitadel.auth.svc.cluster.local:8080`** | +| OIDC issuer / `domain` | `http://zitadel-zitadel.auth.svc.cluster.local:8080` | +| Gateway / ingress | disabled — ClusterIP only for now | + +## Apply order + +```bash +hops local start --backend dory --gitops ./gitops/cluster +# or after configurations: +hops local gitops cluster ./gitops/cluster + +kubectl get authstack zitadel -n default +kubectl get svc -n auth +# expect: zitadel-zitadel (API) — not a separate short-name alias +``` + +## e2e-ui identity (worktree) + +App Project / humans / OIDC app render from the **UI** chart when +`gitops/envs/local/ui.yaml` sets `identity.enabled: true` (not cluster gitops). + +ProviderConfig residual (cluster): + +```bash +hops local zitadel --source-context dory --source-namespace auth \ + --domain zitadel-zitadel.auth.svc.cluster.local --port 8080 --insecure +``` + +## Secrets + +Masterkey is **not** inline — `secretRef` to `zitadel-masterkey` in ns `auth`: + +```bash +kubectl -n auth create secret generic zitadel-masterkey \ + --from-literal=masterkey='hops-like-basketball-but-for-ops' # exactly 32 bytes +``` + +Create **before** AuthStack becomes Ready (or re-apply after). Full table: +[../SECRETS.md](../SECRETS.md). + +## Residual + +- Host browser access without ingress is later (gateway / map / promote) diff --git a/tests/e2e-ui/gitops/cluster/auth/external-secret-masterkey.yaml b/tests/e2e-ui/gitops/cluster/auth/external-secret-masterkey.yaml new file mode 100644 index 00000000..3ed7a699 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/auth/external-secret-masterkey.yaml @@ -0,0 +1,27 @@ +# Materialize AuthStack masterkey from Vault so it survives ns/worktree churn. +# Requires ClusterSecretStore vault Ready (Vault Helm postStart + vault-auth-delegator) +# and path auth/zitadel-masterkey written via: hops secrets sync vault +# See SECRETS.md. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: zitadel-masterkey + namespace: auth + labels: + hops.ops.com.ai/fixture: e2e-ui + hops.ops.com.ai/secret: zitadel-masterkey +spec: + refreshInterval: 1m + secretStoreRef: + name: vault + kind: ClusterSecretStore + target: + name: zitadel-masterkey + creationPolicy: Owner + template: + type: Opaque + data: + - secretKey: masterkey + remoteRef: + key: auth/zitadel-masterkey + property: masterkey diff --git a/tests/e2e-ui/gitops/cluster/configurations/auth-stack.yaml b/tests/e2e-ui/gitops/cluster/configurations/auth-stack.yaml new file mode 100644 index 00000000..76c5c0af --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/configurations/auth-stack.yaml @@ -0,0 +1,11 @@ +# Crossplane Configuration for AuthStack (Zitadel + auth XRDs). +# Prefer building/pushing from source into the local registry: +# hops config install /xrs/stacks/k8s/auth +# Depends on: provider-helm, provider-kubernetes, (optional) provider-upjet-zitadel. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: auth-stack +spec: + package: ghcr.io/hops-ops/auth-stack:v1.6.0 + # package: registry.crossplane-system.svc.cluster.local:5000/hops-ops/auth-stack: diff --git a/tests/e2e-ui/gitops/cluster/configurations/psql-stack.yaml b/tests/e2e-ui/gitops/cluster/configurations/psql-stack.yaml new file mode 100644 index 00000000..2427526f --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/configurations/psql-stack.yaml @@ -0,0 +1,12 @@ +# Crossplane Configuration for the PostgreSQL platform stack. +# Prefer building/pushing from source into the local registry: +# hops config install /xrs/stacks/k8s/psql +# Or set package to a published tag when available. +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: psql-stack +spec: + package: ghcr.io/hops-ops/psql-stack:v0.9.1 + # Local registry after hops local start + config install: + # package: registry.crossplane-system.svc.cluster.local:5000/hops-ops/psql-stack: diff --git a/tests/e2e-ui/gitops/cluster/configurations/secret-stack.yaml b/tests/e2e-ui/gitops/cluster/configurations/secret-stack.yaml new file mode 100644 index 00000000..5338668f --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/configurations/secret-stack.yaml @@ -0,0 +1,13 @@ +# Crossplane Configuration for platform SecretStack (ESO + AWS|Vault backends). +# Prefer source install for local dogfood of the multi-backend redesign: +# hops config install --path /xrs/stacks/aws/secret --context dory +# Then this YAML is optional (or pin a published tag once released). +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: secret-stack +spec: + # After `hops config install --path …/aws/secret`, package is local registry: + # package: registry.crossplane-system.svc.cluster.local:5000/hops-ops/secret-stack:dev- + package: ghcr.io/hops-ops/secret-stack:latest + # packagePullPolicy: Always diff --git a/tests/e2e-ui/gitops/cluster/providerconfigs/helm.yaml b/tests/e2e-ui/gitops/cluster/providerconfigs/helm.yaml new file mode 100644 index 00000000..148cb4fc --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providerconfigs/helm.yaml @@ -0,0 +1,8 @@ +apiVersion: helm.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: InjectedIdentity diff --git a/tests/e2e-ui/gitops/cluster/providerconfigs/kubernetes.yaml b/tests/e2e-ui/gitops/cluster/providerconfigs/kubernetes.yaml new file mode 100644 index 00000000..95029bcc --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providerconfigs/kubernetes.yaml @@ -0,0 +1,8 @@ +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: InjectedIdentity diff --git a/tests/e2e-ui/gitops/cluster/providerconfigs/zitadel.yaml b/tests/e2e-ui/gitops/cluster/providerconfigs/zitadel.yaml new file mode 100644 index 00000000..34578c1f --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providerconfigs/zitadel.yaml @@ -0,0 +1,12 @@ +apiVersion: zitadel.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: Secret + secretRef: + namespace: default + name: zitadel-credentials + key: credentials diff --git a/tests/e2e-ui/gitops/cluster/providers/helm-drc.yaml b/tests/e2e-ui/gitops/cluster/providers/helm-drc.yaml new file mode 100644 index 00000000..c9953f16 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/helm-drc.yaml @@ -0,0 +1,28 @@ +# Per-provider DeploymentRuntimeConfig for provider-helm (local dev). +# Colocated with providers/helm.yaml (runtimeConfigRef: local-dev-helm). +# +# Each provider gets its OWN uniquely-named DRC + cluster-admin ServiceAccount. +# Providers must NOT share a DRC: a shared runtime config lets one provider's +# image/SA silently clobber the other's pod (and makes drift impossible to +# attribute). +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: local-dev-helm +spec: + serviceAccountTemplate: + metadata: + name: local-dev-helm +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: local-dev-helm-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: local-dev-helm + namespace: crossplane-system diff --git a/tests/e2e-ui/gitops/cluster/providers/helm.yaml b/tests/e2e-ui/gitops/cluster/providers/helm.yaml new file mode 100644 index 00000000..44d33b64 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/helm.yaml @@ -0,0 +1,9 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-helm +spec: + # Renovate: github-releases crossplane-contrib/provider-helm (cli/renovate.json) + package: xpkg.crossplane.io/crossplane-contrib/provider-helm:v1.3.0 + runtimeConfigRef: + name: local-dev-helm diff --git a/tests/e2e-ui/gitops/cluster/providers/kubernetes-drc.yaml b/tests/e2e-ui/gitops/cluster/providers/kubernetes-drc.yaml new file mode 100644 index 00000000..76f6f5a6 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/kubernetes-drc.yaml @@ -0,0 +1,28 @@ +# Per-provider DeploymentRuntimeConfig for provider-kubernetes (local dev). +# Colocated with providers/kubernetes.yaml (runtimeConfigRef: local-dev-kubernetes). +# +# Each provider gets its OWN uniquely-named DRC + cluster-admin ServiceAccount. +# Providers must NOT share a DRC: a shared runtime config lets one provider's +# image/SA silently clobber the other's pod (and makes drift impossible to +# attribute). +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: local-dev-kubernetes +spec: + serviceAccountTemplate: + metadata: + name: local-dev-kubernetes +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: local-dev-kubernetes-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: local-dev-kubernetes + namespace: crossplane-system diff --git a/tests/e2e-ui/gitops/cluster/providers/kubernetes.yaml b/tests/e2e-ui/gitops/cluster/providers/kubernetes.yaml new file mode 100644 index 00000000..c400c387 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/kubernetes.yaml @@ -0,0 +1,9 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-kubernetes +spec: + # Renovate: github-releases crossplane-contrib/provider-kubernetes (cli/renovate.json) + package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.2.1 + runtimeConfigRef: + name: local-dev-kubernetes diff --git a/tests/e2e-ui/gitops/cluster/providers/zitadel.yaml b/tests/e2e-ui/gitops/cluster/providers/zitadel.yaml new file mode 100644 index 00000000..41d45afd --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/providers/zitadel.yaml @@ -0,0 +1,6 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: crossplane-contrib-provider-upjet-zitadel +spec: + package: xpkg.crossplane.io/crossplane-contrib/provider-upjet-zitadel:v0.1.1 diff --git a/tests/e2e-ui/gitops/cluster/secrets/stack.yaml b/tests/e2e-ui/gitops/cluster/secrets/stack.yaml new file mode 100644 index 00000000..33870bea --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/secrets/stack.yaml @@ -0,0 +1,148 @@ +# Platform SecretStack for local CP (dory workbench). +# +# Install Configuration from source first (breaking API: hops.ops.com.ai): +# hops config install --path /xrs/stacks/aws/secret --context dory +# +# Then: hops local gitops cluster ./gitops/cluster +# +# backend=vault + vault.install for local (no AWS PodIdentity required). +# +# Persistence: file storage on a PVC (not server.dev / inmem). postStart +# init+unseal once, stores root token + unseal key on the volume: +# /vault/data/.hops-init +# K8s auth for ESO is re-applied every start. Seed KV with: +# export VAULT_TOKEN=$(kubectl -n vault exec vault-0 -- \ +# awk '/^Initial Root Token:/{print $NF}' /vault/data/.hops-init) +# hops secrets sync vault +apiVersion: hops.ops.com.ai/v1alpha1 +kind: SecretStack +metadata: + name: external-secrets + namespace: default +spec: + clusterName: dory + backend: vault + namespace: external-secrets + helmProviderConfigRef: + name: default + kubernetesProviderConfigRef: + name: default + labels: + team: platform + hops.ops.com.ai/fixture: e2e-ui + secretStore: + enabled: true + scope: Cluster + name: vault + vault: + install: true + namespace: vault + path: secret + version: v2 + auth: + method: kubernetes + mountPath: kubernetes + role: external-secrets + # Merged into official Vault Helm chart (SecretStack composition defaults + # use server.dev + no PVC; we override for durable local dogfood). + values: + server: + # Leave dev mode — inmem cannot use dataStorage. + dev: + enabled: false + standalone: + enabled: true + config: | + ui = true + disable_mlock = true + listener "tcp" { + tls_disable = 1 + address = "[::]:8200" + cluster_address = "[::]:8201" + } + storage "file" { + path = "/vault/data" + } + dataStorage: + enabled: true + size: 1Gi + # omit storageClass → cluster default (dory: local-path) + postStart: + - /bin/sh + - -c + - | + # NOTE: do not use bare `set -e` around `vault status` — sealed/uninit + # returns exit 2 and would abort the hook (killing the container). + set -u + export VAULT_ADDR=http://127.0.0.1:8200 + INIT_FILE=/vault/data/.hops-init + + vault_status_rc() { + vault status >/dev/null 2>&1 + echo $? + } + + # API is up when status returns 0 (unsealed) or 2 (sealed / not init). + i=0 + while [ "$i" -lt 90 ]; do + rc=$(vault_status_rc) + if [ "$rc" -eq 0 ] || [ "$rc" -eq 2 ]; then + break + fi + i=$((i + 1)) + sleep 1 + done + rc=$(vault_status_rc) + if [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ]; then + echo "vault postStart: API not ready after wait (rc=$rc)" >&2 + exit 1 + fi + + if [ ! -f "$INIT_FILE" ]; then + echo "vault postStart: initializing (1 share / threshold 1)" + vault operator init -key-shares=1 -key-threshold=1 >"$INIT_FILE" + chmod 600 "$INIT_FILE" || true + fi + + UNSEAL_KEY=$(awk '/Unseal Key 1:/{print $NF}' "$INIT_FILE") + ROOT_TOKEN=$(awk '/Initial Root Token:/{print $NF}' "$INIT_FILE") + if [ -z "${UNSEAL_KEY}" ] || [ -z "${ROOT_TOKEN}" ]; then + echo "vault postStart: failed to parse $INIT_FILE" >&2 + cat "$INIT_FILE" >&2 || true + exit 1 + fi + + # Unseal when sealed (status exit 2). + rc=$(vault_status_rc) + if [ "$rc" -ne 0 ]; then + vault operator unseal "$UNSEAL_KEY" >/dev/null + fi + export VAULT_TOKEN="$ROOT_TOKEN" + + # Non-dev installs do not auto-mount secret/ — enable KV v2 for ESO. + if ! vault secrets list -format=json 2>/dev/null | grep -q '"secret/"'; then + vault secrets enable -path=secret kv-v2 + fi + + if ! vault auth list -format=json 2>/dev/null | grep -q '"kubernetes/"'; then + vault auth enable kubernetes + fi + + vault write auth/kubernetes/config \ + kubernetes_host="https://kubernetes.default.svc" \ + token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + disable_iss_validation=true + + printf '%s\n' \ + 'path "secret/data/*" { capabilities = ["read", "list"] }' \ + 'path "secret/metadata/*" { capabilities = ["read", "list"] }' \ + | vault policy write external-secrets - + + vault write auth/kubernetes/role/external-secrets \ + bound_service_account_names=external-secrets \ + bound_service_account_namespaces=external-secrets \ + policies=external-secrets \ + ttl=24h + + echo "vault postStart: unsealed + kubernetes auth ready (init file $INIT_FILE)" diff --git a/tests/e2e-ui/gitops/cluster/secrets/vault-auth-delegator.yaml b/tests/e2e-ui/gitops/cluster/secrets/vault-auth-delegator.yaml new file mode 100644 index 00000000..7b9e772a --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/secrets/vault-auth-delegator.yaml @@ -0,0 +1,17 @@ +# Allow the Vault server SA to call TokenReview (required for kubernetes auth). +# Independent of the Vault pod lifecycle — apply with cluster gitops. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vault-auth-delegator + labels: + hops.ops.com.ai/fixture: e2e-ui + hops.ops.com.ai/component: vault +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: vault + namespace: vault diff --git a/tests/e2e-ui/gitops/cluster/stacks/auth.yaml b/tests/e2e-ui/gitops/cluster/stacks/auth.yaml new file mode 100644 index 00000000..f7779f71 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/stacks/auth.yaml @@ -0,0 +1,50 @@ +# Local AuthStack: Zitadel + embedded PSQLCluster (owned by this XR). +# +# No ingress/gateway for local first step — Service is ClusterIP only. +# Helm chart fullname is release+chart → Service **zitadel-zitadel** (default). +# Issuer / ExternalDomain must match that FQDN (Option A): +# http://zitadel-zitadel.auth.svc.cluster.local:8080 +# (Later: chartValues.fullnameOverride: zitadel if we want a shorter name.) +# +# Install configurations first, then: hops local gitops cluster ./gitops/cluster +apiVersion: hops.ops.com.ai/v1alpha1 +kind: AuthStack +metadata: + name: zitadel + namespace: default +spec: + clusterName: dory + helmProviderConfigRef: + name: default + kubernetesProviderConfigRef: + name: default + + # Install namespace + namespace: auth + + # Must match Helm Service FQDN (default fullname = zitadel-zitadel) + domain: zitadel-zitadel.auth.svc.cluster.local + externalSecure: false + + gateway: + enabled: false + + externalSecrets: + enabled: false + + firstInstance: + org: hops-ops + masterkey: + # Live Secret in install ns (auth) — not inline in gitops. + # kubectl -n auth create secret generic zitadel-masterkey \ + # --from-literal=masterkey='hops-like-basketball-but-for-ops' + # Chart expects the key material under the secret name; see AuthStack XRD. + secretRef: + name: zitadel-masterkey + + # Embedded PG lands in the same install namespace (auth). + database: + embedded: + storage: + size: 2Gi + class: psql diff --git a/tests/e2e-ui/gitops/cluster/stacks/psql.yaml b/tests/e2e-ui/gitops/cluster/stacks/psql.yaml new file mode 100644 index 00000000..dc1a1378 --- /dev/null +++ b/tests/e2e-ui/gitops/cluster/stacks/psql.yaml @@ -0,0 +1,25 @@ +# Platform layer: CNPG + Atlas + StorageClass named `psql`. +# Install package first: `hops config install ` (or apply configurations/psql-stack.yaml). +# +# Dory/k3s uses rancher.io/local-path (default SC). Snapshot class off (no CSI snapshotter). +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLStack +metadata: + name: psql + namespace: default +spec: + clusterName: dory + helmProviderConfigRef: + name: default + kubernetesProviderConfigRef: + name: default + storageClass: + enabled: true + name: psql + provisioner: rancher.io/local-path + allowVolumeExpansion: false + parameters: {} + snapshotClass: + enabled: false + scaleToZeroPlugin: + enabled: false diff --git a/tests/e2e-ui/gitops/env/README.md b/tests/e2e-ui/gitops/env/README.md new file mode 100644 index 00000000..a9623e75 --- /dev/null +++ b/tests/e2e-ui/gitops/env/README.md @@ -0,0 +1,5 @@ +# Deprecated path + +Use **`gitops/envs/local`** instead of `gitops/env/local`. + +This directory remains so existing `hops local up ./gitops/env/local` commands keep working. diff --git a/tests/e2e-ui/gitops/env/local/api.yaml b/tests/e2e-ui/gitops/env/local/api.yaml new file mode 100644 index 00000000..4e953c87 --- /dev/null +++ b/tests/e2e-ui/gitops/env/local/api.yaml @@ -0,0 +1,21 @@ +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-api +spec: + source: + path: ../../../api/.gitops/deploy + # Default delivery = git worktree root (shared across UI/API pods). + helm: + values: + local: true + appRuntime: cluster-dev + database: + enabled: false + # Residual after shared Zitadel Project Ready (project id for role assertion). + # Dogfood live: e2e-ui-dogfood project until cluster Project `e2e-ui` is recreated. + env: + OIDC_AUDIENCE: "385467744310526613" + ZITADEL_PROJECT_ID: "385467744310526613" + syncPolicy: + prune: false diff --git a/tests/e2e-ui/gitops/env/local/ui.yaml b/tests/e2e-ui/gitops/env/local/ui.yaml new file mode 100644 index 00000000..73f2fa27 --- /dev/null +++ b/tests/e2e-ui/gitops/env/local/ui.yaml @@ -0,0 +1,35 @@ +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-ui +spec: + source: + path: ../../../ui/.gitops/deploy + # Default delivery = git worktree root (shared across UI/API pods). + # Do not set deliveryPath unless you need a narrower override. + helm: + values: + local: true + appRuntime: cluster-dev + # Residual: Zitadel project id for role scopes / API audience (must match + # Project Ready external-name). Dogfood live project until cluster e2e-ui + # Project is recreated on current AuthStack. + env: + OIDC_AUDIENCE: "385467744310526613" + ZITADEL_PROJECT_ID: "385467744310526613" + # Identity: cluster Project+roles+humans; OIDC app is worktree-scoped. + # orgId residual after: GET …/management/v1/orgs/me + identity: + enabled: true + # hops-ops org on local AuthStack (GET …/management/v1/orgs/me) + orgId: "385408591454077587" + projectName: e2e-ui + projectNamespace: default + humansNamespace: default + mrNamespace: default + demoUsers: true + providerConfigRef: + name: default + kind: ClusterProviderConfig + syncPolicy: + prune: false diff --git a/tests/e2e-ui/gitops/envs/local/api.yaml b/tests/e2e-ui/gitops/envs/local/api.yaml new file mode 100644 index 00000000..4e953c87 --- /dev/null +++ b/tests/e2e-ui/gitops/envs/local/api.yaml @@ -0,0 +1,21 @@ +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-api +spec: + source: + path: ../../../api/.gitops/deploy + # Default delivery = git worktree root (shared across UI/API pods). + helm: + values: + local: true + appRuntime: cluster-dev + database: + enabled: false + # Residual after shared Zitadel Project Ready (project id for role assertion). + # Dogfood live: e2e-ui-dogfood project until cluster Project `e2e-ui` is recreated. + env: + OIDC_AUDIENCE: "385467744310526613" + ZITADEL_PROJECT_ID: "385467744310526613" + syncPolicy: + prune: false diff --git a/tests/e2e-ui/gitops/envs/local/ui.yaml b/tests/e2e-ui/gitops/envs/local/ui.yaml new file mode 100644 index 00000000..73f2fa27 --- /dev/null +++ b/tests/e2e-ui/gitops/envs/local/ui.yaml @@ -0,0 +1,35 @@ +apiVersion: hops.local/v1alpha1 +kind: Application +metadata: + name: e2e-ui-ui +spec: + source: + path: ../../../ui/.gitops/deploy + # Default delivery = git worktree root (shared across UI/API pods). + # Do not set deliveryPath unless you need a narrower override. + helm: + values: + local: true + appRuntime: cluster-dev + # Residual: Zitadel project id for role scopes / API audience (must match + # Project Ready external-name). Dogfood live project until cluster e2e-ui + # Project is recreated on current AuthStack. + env: + OIDC_AUDIENCE: "385467744310526613" + ZITADEL_PROJECT_ID: "385467744310526613" + # Identity: cluster Project+roles+humans; OIDC app is worktree-scoped. + # orgId residual after: GET …/management/v1/orgs/me + identity: + enabled: true + # hops-ops org on local AuthStack (GET …/management/v1/orgs/me) + orgId: "385408591454077587" + projectName: e2e-ui + projectNamespace: default + humansNamespace: default + mrNamespace: default + demoUsers: true + providerConfigRef: + name: default + kind: ClusterProviderConfig + syncPolicy: + prune: false diff --git a/tests/e2e-ui/ui/.gitops/deploy/Chart.yaml b/tests/e2e-ui/ui/.gitops/deploy/Chart.yaml new file mode 100644 index 00000000..88e9cbc9 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: e2e-ui-ui +description: Local workbench dogfood chart for e2e-ui SvelteKit UI +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/tests/e2e-ui/ui/.gitops/deploy/README.md b/tests/e2e-ui/ui/.gitops/deploy/README.md new file mode 100644 index 00000000..b7337422 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/README.md @@ -0,0 +1,56 @@ +# e2e-ui UI chart (workbench) + +Renders: + +- **Deployment / Service** (`appRuntime: cluster-dev` \| `package`) +- **Optional identity** when `identity.enabled: true`: + - **Cluster-shared:** Project, roles (`user` / `admin`), demo humans + - **Per worktree:** OIDC web app only (redirects + Login V2 `baseUri`) + +The OIDC web app + Login V2 `baseUri` belong with the UI (browser sign-in), +not the API. + +## Identity scope + +| Resource | Scope | K8s name example | +|----------|--------|------------------| +| Project | **Cluster** | `e2e-ui` | +| Roles | **Cluster** | `e2e-role-user`, `e2e-role-admin` | +| HumanUsers | **Cluster** | `e2e-alice` (login `alice`) | +| Password secret | **Cluster** | `e2e-human-passwords` | +| OIDC app | **Worktree** | `e2e-ui-dogfood-web` | + +Demo login (every local env): **alice / bob / admin · Password1!** + +## Values + +| Value | Purpose | +|-------|---------| +| `identity.enabled` | Gate identity templates | +| `identity.orgId` | Residual FirstInstance org id | +| `identity.projectName` | Shared Project name (default `e2e-ui`) | +| `identity.workspace` | Worktree id for OIDC app names only | +| `identity.demoUsers` | Cluster-shared alice / bob / admin | +| `identity.projectNamespace` | Namespace for Project + Role MRs | +| `identity.humansNamespace` | Namespace for HumanUser MRs | +| `identity.mrNamespace` | Namespace for worktree OIDC app MRs | +| `identity.uiBaseURL` | Optional; else `http://e2e-ui-ui..svc…:5180` | +| `identity.passwordSecret.name` | Shared password secret (default `e2e-human-passwords`) | + +```yaml +# gitops/envs/local/ui.yaml +identity: + enabled: true + orgId: "…" # residual after AuthStack Ready + demoUsers: true + providerConfigRef: + name: default + kind: ClusterProviderConfig +``` + +User grants (human → project roles) are residual once per CP after Project + +humans are Ready. Prefer a **ClusterProviderConfig** so all identity MRs share +credentials. + +API still needs residual `OIDC_AUDIENCE` / project id (and app `e2e-ui-oidc` +secret keys) after the shared Project and worktree OIDC app are Ready. diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/_helpers.tpl b/tests/e2e-ui/ui/.gitops/deploy/templates/_helpers.tpl new file mode 100644 index 00000000..571867fb --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/_helpers.tpl @@ -0,0 +1,65 @@ +{{- define "e2e-ui-ui.name" -}} +{{- default .Chart.Name .Values.name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "e2e-ui-ui.labels" -}} +app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Workspace id for multi-worktree OIDC apps (redirects / Login V2 baseUri). +Prefers identity.workspace; else release/workspace namespace (= hops --name). +*/}} +{{- define "e2e-ui-ui.workspace" -}} +{{- if .Values.identity.workspace -}} +{{- .Values.identity.workspace -}} +{{- else -}} +{{- include "e2e-ui-ui.releaseNamespace" . -}} +{{- end -}} +{{- end -}} + +{{/* App namespace: hops injects values.namespace; helm --namespace is Release.Namespace. */}} +{{- define "e2e-ui-ui.releaseNamespace" -}} +{{- coalesce .Values.namespace .Release.Namespace "default" -}} +{{- end -}} + +{{/* Worktree-scoped OIDC app name prefix: e2e-ui- */}} +{{- define "e2e-ui-ui.identityPrefix" -}} +{{- printf "e2e-ui-%s" (include "e2e-ui-ui.workspace" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* Cluster-shared Zitadel Project MR name (one per control plane). */}} +{{- define "e2e-ui-ui.clusterProjectName" -}} +{{- default "e2e-ui" .Values.identity.projectName -}} +{{- end -}} + +{{- define "e2e-ui-ui.identityLabels" -}} +app.kubernetes.io/name: e2e-ui +app.kubernetes.io/component: identity +hops.ops.com.ai/app: e2e-ui +hops.ops.com.ai/workspace: {{ include "e2e-ui-ui.workspace" . | quote }} +{{- end -}} + +{{- define "e2e-ui-ui.identityClusterLabels" -}} +app.kubernetes.io/name: e2e-ui +app.kubernetes.io/component: identity +hops.ops.com.ai/app: e2e-ui +hops.ops.com.ai/identity-scope: cluster +{{- end -}} + +{{/* +UI public base for OIDC redirects + app Login V2 baseUri. +Uses the release/workspace namespace so --name dogfood → e2e-ui-ui.dogfood.svc… +*/}} +{{- define "e2e-ui-ui.uiBaseURL" -}} +{{- if .Values.identity.uiBaseURL -}} +{{- .Values.identity.uiBaseURL | trimSuffix "/" -}} +{{- else -}} +{{- $ns := include "e2e-ui-ui.releaseNamespace" . -}} +{{- $svc := default (include "e2e-ui-ui.name" .) .Values.identity.uiService -}} +{{- $port := default .Values.service.port .Values.identity.uiPort -}} +{{- printf "http://%s.%s.svc.cluster.local:%v" $svc $ns $port -}} +{{- end -}} +{{- end -}} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-cluster-dev.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-cluster-dev.yaml new file mode 100644 index 00000000..81008aa0 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-cluster-dev.yaml @@ -0,0 +1,112 @@ +{{- if eq .Values.appRuntime "cluster-dev" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "e2e-ui-ui.name" . }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/runtime: cluster-dev +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + hops.ops.com.ai/runtime: cluster-dev + spec: + containers: + - name: ui + image: {{ .Values.clusterDev.image | quote }} + workingDir: {{ .Values.clusterDev.workingDir | quote }} + {{- if eq .Values.sourceDelivery.mode "sync" }} + # Wait for tar/mutagen delivery, then run the same clusterDev command. + command: + - /bin/bash + - -c + - | + set -e + echo "waiting for source sync at {{ .Values.sourceDelivery.mountPath }}..." + waited=0 + while [ ! -f {{ .Values.sourceDelivery.mountPath }}/.hops-synced ] \ + && [ ! -f {{ .Values.clusterDev.workingDir }}/package.json ]; do + sleep 2 + waited=$((waited + 2)) + if [ $((waited % 30)) -eq 0 ]; then + echo "still waiting for hops source sync (${waited}s) — is tar watcher running?" + fi + if [ "$waited" -ge 600 ]; then + echo "ERROR: source sync timeout after ${waited}s (no .hops-synced / package.json)" + exit 1 + fi + done + echo "source present; starting dev process" + {{- /* Unwrap Values.clusterDev.command ["/bin/bash","-c","script"] */ -}} + {{- $cmd := .Values.clusterDev.command -}} + {{- if and (kindIs "slice" $cmd) (ge (len $cmd) 3) }} + {{ index $cmd 2 | nindent 14 }} + {{- else }} + cd /workspace/js && npm install && npm run build + cd {{ .Values.clusterDev.workingDir }} + npm install + export DISTRIBUTED_SKIP_CLIENT_COMPILE=1 + export E2E_API_ORIGIN="${E2E_API_ORIGIN:-http://e2e-ui-api:8791}" + npm run dev -- --host 0.0.0.0 --port {{ .Values.service.targetPort }} + {{- end }} + {{- else }} + command: + {{- toYaml .Values.clusterDev.command | nindent 12 }} + {{- end }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http + env: + {{- range $k, $v := .Values.env }} + {{- /* AUTH_URL rewritten from injected workspace namespace when present */ -}} + {{- if and (eq $k "AUTH_URL") $.Values.namespace }} + - name: AUTH_URL + value: {{ printf "http://e2e-ui-ui.%s.svc.cluster.local:5180" $.Values.namespace | quote }} + {{- else }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- end }} + {{- /* Secrets from K8s Secret objects — never plain values in gitops */ -}} + {{- range $k, $ref := .Values.secretEnv }} + - name: {{ $k }} + valueFrom: + secretKeyRef: + name: {{ $ref.secretName | quote }} + key: {{ $ref.key | quote }} + {{- end }} + {{- with .Values.envFromSecrets }} + envFrom: + {{- range . }} + - secretRef: + name: {{ . | quote }} + {{- end }} + {{- end }} + volumeMounts: + - name: source + mountPath: {{ .Values.sourceDelivery.mountPath }} + - name: node-modules + mountPath: {{ .Values.clusterDev.workingDir }}/node_modules + # Keep monorepo js deps across restarts within the same pod lifecycle. + - name: js-node-modules + mountPath: /workspace/js/node_modules + volumes: + - name: source + {{- if eq .Values.sourceDelivery.mode "hostPath" }} + hostPath: + path: {{ .Values.sourceDelivery.hostPath | quote }} + type: Directory + {{- else }} + emptyDir: {} + {{- end }} + - name: node-modules + emptyDir: {} + - name: js-node-modules + emptyDir: {} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-package.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-package.yaml new file mode 100644 index 00000000..403b1cbc --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/deployment-package.yaml @@ -0,0 +1,27 @@ +{{- if and (not .Values.local) (ne .Values.appRuntime "cluster-dev") (ne .Values.appRuntime "host") }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "e2e-ui-ui.name" . }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/runtime: package +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + hops.ops.com.ai/runtime: package + spec: + containers: + - name: ui + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: http +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-human-passwords.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-human-passwords.yaml new file mode 100644 index 00000000..3d4dd4b4 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-human-passwords.yaml @@ -0,0 +1,41 @@ +{{- if and .Values.externalSecrets .Values.externalSecrets.enabled .Values.externalSecrets.humanPasswords .Values.externalSecrets.humanPasswords.enabled }} +{{- $path := .Values.externalSecrets.humanPasswords.vaultPath | default "e2e-ui/shared/human-passwords" -}} +{{- $target := .Values.externalSecrets.humanPasswords.targetName | default "e2e-human-passwords" -}} +{{- $ns := .Values.identity.humansNamespace | default .Values.identity.mrNamespace | default "default" -}} +# Cluster-shared password secret for demo HumanUsers (not per-worktree). +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ $target }} + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/secret: human-passwords +spec: + refreshInterval: {{ .Values.externalSecrets.refreshInterval | default "1m" | quote }} + secretStoreRef: + name: {{ .Values.externalSecrets.secretStoreRef.name | default "vault" | quote }} + kind: {{ .Values.externalSecrets.secretStoreRef.kind | default "ClusterSecretStore" | quote }} + target: + name: {{ $target | quote }} + creationPolicy: Owner + template: + type: Opaque + metadata: + labels: + hops.ops.com.ai/app: e2e-ui + data: + - secretKey: alice + remoteRef: + key: {{ $path | quote }} + property: alice + - secretKey: bob + remoteRef: + key: {{ $path | quote }} + property: bob + - secretKey: admin + remoteRef: + key: {{ $path | quote }} + property: admin +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-oidc.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-oidc.yaml new file mode 100644 index 00000000..f442f658 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/external-secret-oidc.yaml @@ -0,0 +1,45 @@ +{{- if and .Values.externalSecrets .Values.externalSecrets.enabled }} +{{- $ws := include "e2e-ui-ui.workspace" . -}} +{{- $path := .Values.externalSecrets.oidc.vaultPath | default (printf "e2e-ui/%s/oidc" $ws) -}} +{{- $target := .Values.externalSecrets.oidc.targetName | default "e2e-ui-oidc" -}} +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ $target }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/workspace: {{ $ws | quote }} + hops.ops.com.ai/secret: oidc +spec: + refreshInterval: {{ .Values.externalSecrets.refreshInterval | default "1m" | quote }} + secretStoreRef: + name: {{ .Values.externalSecrets.secretStoreRef.name | default "vault" | quote }} + kind: {{ .Values.externalSecrets.secretStoreRef.kind | default "ClusterSecretStore" | quote }} + target: + name: {{ $target | quote }} + creationPolicy: Owner + template: + type: Opaque + metadata: + labels: + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/workspace: {{ $ws | quote }} + data: + - secretKey: AUTH_SECRET + remoteRef: + key: {{ $path | quote }} + property: AUTH_SECRET + - secretKey: OIDC_CLIENT_ID + remoteRef: + key: {{ $path | quote }} + property: OIDC_CLIENT_ID + - secretKey: OIDC_CLIENT_SECRET + remoteRef: + key: {{ $path | quote }} + property: OIDC_CLIENT_SECRET + - secretKey: ZITADEL_SERVICE_USER_TOKEN + remoteRef: + key: {{ $path | quote }} + property: ZITADEL_SERVICE_USER_TOKEN +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-humans.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-humans.yaml new file mode 100644 index 00000000..cf5467f2 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-humans.yaml @@ -0,0 +1,44 @@ +{{- /* + Cluster-shared demo humans (alice / bob / admin). + + One set per local control plane — not per worktree. Login names stay plain + so every env uses the same credentials. Project + roles are also cluster- + shared; only the OIDC web app is worktree-scoped. +*/ -}} +{{- if and .Values.identity.enabled .Values.identity.orgId .Values.identity.demoUsers }} +{{- $ns := .Values.identity.humansNamespace | default .Values.identity.projectNamespace | default .Values.identity.mrNamespace | default "default" -}} +{{- $pc := .Values.identity.providerConfigRef.name | default "default" -}} +{{- $pck := .Values.identity.providerConfigRef.kind | default "ProviderConfig" -}} +{{- $pwSecret := .Values.identity.passwordSecret.name | default "e2e-human-passwords" -}} +{{- range $user := list "alice" "bob" "admin" }} +--- +apiVersion: user.zitadel.m.crossplane.io/v1alpha1 +kind: HumanUser +metadata: + # Stable cluster-scoped names (no worktree prefix). + name: e2e-{{ $user }} + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: e2e-ui + app.kubernetes.io/component: identity + hops.ops.com.ai/app: e2e-ui + hops.ops.com.ai/identity-scope: cluster + hops.ops.com.ai/demo-user: {{ $user | quote }} +spec: + forProvider: + orgId: {{ $.Values.identity.orgId | quote }} + userName: {{ $user | quote }} + firstName: {{ $user | quote }} + lastName: E2E + displayName: {{ $user | quote }} + email: {{ printf "%s@e2e.local" $user | quote }} + isEmailVerified: true + initialSkipPasswordChange: true + initialPasswordSecretRef: + name: {{ $pwSecret | quote }} + key: {{ $user | quote }} + providerConfigRef: + name: {{ $pc | quote }} + kind: {{ $pck | quote }} +{{- end }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-instance-features.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-instance-features.yaml new file mode 100644 index 00000000..2b7f8833 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-instance-features.yaml @@ -0,0 +1,29 @@ +{{- /* + Instance Login V2 features (gitops). + + Zitadel honors instance loginV2.baseUri when required=true. That is what + redirects authorize → custom e2e-ui /login (not stock zitadel-login). + + baseUri tracks the UI release namespace (hops --name / helm --namespace). + Note: Features is instance-global in Zitadel — last applied worktree wins if + several UIs enable this on one AuthStack. +*/ -}} +{{- if and .Values.identity.enabled (ne .Values.identity.instanceLoginV2 false) }} +{{- $uiBase := include "e2e-ui-ui.uiBaseURL" . -}} +{{- $ns := include "e2e-ui-ui.releaseNamespace" . -}} +apiVersion: instance.zitadel.m.crossplane.io/v1alpha1 +kind: Features +metadata: + name: e2e-ui-login-v2 + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityLabels" . | nindent 4 }} +spec: + forProvider: + loginV2: + - required: true + baseUri: {{ $uiBase | quote }} + providerConfigRef: + name: {{ .Values.identity.providerConfigRef.name | default "default" | quote }} + kind: {{ .Values.identity.providerConfigRef.kind | default "ProviderConfig" | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-oidc.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-oidc.yaml new file mode 100644 index 00000000..3f52fca6 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-oidc.yaml @@ -0,0 +1,55 @@ +{{- /* + Worktree-scoped OIDC web app (redirects + Login V2 baseUri differ per env). + + Lives under the cluster-shared Project (identity.projectName / e2e-ui). +*/ -}} +{{- if .Values.identity.enabled }} +{{- $prefix := include "e2e-ui-ui.identityPrefix" . -}} +{{- $project := include "e2e-ui-ui.clusterProjectName" . -}} +{{- $uiBase := include "e2e-ui-ui.uiBaseURL" . -}} +{{/* Worktree OIDC app lives with the UI release (namespace = hops --name). */}} +{{- $ns := .Values.identity.mrNamespace | default (include "e2e-ui-ui.releaseNamespace" .) -}} +apiVersion: application.zitadel.m.crossplane.io/v1alpha1 +kind: Oidc +metadata: + name: {{ $prefix }}-web + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityLabels" . | nindent 4 }} +spec: + forProvider: + name: {{ printf "%s-web" $prefix | quote }} + projectIdRef: + name: {{ $project }} + appType: OIDC_APP_TYPE_WEB + authMethodType: OIDC_AUTH_METHOD_TYPE_BASIC + responseTypes: + - OIDC_RESPONSE_TYPE_CODE + grantTypes: + - OIDC_GRANT_TYPE_AUTHORIZATION_CODE + - OIDC_GRANT_TYPE_REFRESH_TOKEN + accessTokenType: OIDC_TOKEN_TYPE_JWT + accessTokenRoleAssertion: true + idTokenRoleAssertion: true + idTokenUserinfoAssertion: true + devMode: true + version: OIDC_VERSION_1_0 + redirectUris: + - {{ printf "%s/auth/callback/oidc" $uiBase | quote }} + - {{ printf "%s/auth/callback" $uiBase | quote }} + {{- range .Values.identity.extraRedirectUris }} + - {{ . | quote }} + {{- end }} + postLogoutRedirectUris: + - {{ printf "%s/" $uiBase | quote }} + {{- range .Values.identity.extraPostLogoutRedirectUris }} + - {{ . | quote }} + {{- end }} + # Custom Login V2 = app /login pages (not stock zitadel-login). + loginVersion: + - loginV2: + - baseUri: {{ $uiBase | quote }} + providerConfigRef: + name: {{ .Values.identity.providerConfigRef.name | default "default" | quote }} + kind: {{ .Values.identity.providerConfigRef.kind | default "ProviderConfig" | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-project.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-project.yaml new file mode 100644 index 00000000..60df7ddb --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-project.yaml @@ -0,0 +1,26 @@ +{{- /* + Cluster-shared Zitadel Project (one e2e-ui project for the whole CP). + + Roles and demo humans attach here. OIDC apps stay worktree-scoped and + projectIdRef this name. +*/ -}} +{{- if .Values.identity.enabled }} +{{- $name := include "e2e-ui-ui.clusterProjectName" . -}} +{{- $ns := .Values.identity.projectNamespace | default .Values.identity.mrNamespace | default "default" -}} +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Project +metadata: + name: {{ $name }} + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityClusterLabels" . | nindent 4 }} +spec: + forProvider: + name: {{ $name | quote }} + projectRoleAssertion: true + projectRoleCheck: true + hasProjectCheck: true + providerConfigRef: + name: {{ .Values.identity.providerConfigRef.name | default "default" | quote }} + kind: {{ .Values.identity.providerConfigRef.kind | default "ProviderConfig" | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/identity-roles.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-roles.yaml new file mode 100644 index 00000000..502a3c84 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/identity-roles.yaml @@ -0,0 +1,46 @@ +{{- /* + Cluster-shared project roles (user / admin) on the shared e2e-ui Project. +*/ -}} +{{- if and .Values.identity.enabled .Values.identity.orgId }} +{{- $project := include "e2e-ui-ui.clusterProjectName" . -}} +{{- $ns := .Values.identity.projectNamespace | default .Values.identity.mrNamespace | default "default" -}} +{{- $pc := .Values.identity.providerConfigRef.name | default "default" -}} +{{- $pck := .Values.identity.providerConfigRef.kind | default "ProviderConfig" -}} +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Role +metadata: + name: e2e-role-user + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityClusterLabels" . | nindent 4 }} + hops.ops.com.ai/demo-role: user +spec: + forProvider: + orgId: {{ .Values.identity.orgId | quote }} + roleKey: user + displayName: user + projectIdRef: + name: {{ $project }} + providerConfigRef: + name: {{ $pc | quote }} + kind: {{ $pck | quote }} +--- +apiVersion: project.zitadel.m.crossplane.io/v1alpha1 +kind: Role +metadata: + name: e2e-role-admin + namespace: {{ $ns }} + labels: + {{- include "e2e-ui-ui.identityClusterLabels" . | nindent 4 }} + hops.ops.com.ai/demo-role: admin +spec: + forProvider: + orgId: {{ .Values.identity.orgId | quote }} + roleKey: admin + displayName: admin + projectIdRef: + name: {{ $project }} + providerConfigRef: + name: {{ $pc | quote }} + kind: {{ $pck | quote }} +{{- end }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/templates/service.yaml b/tests/e2e-ui/ui/.gitops/deploy/templates/service.yaml new file mode 100644 index 00000000..1bc71f23 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "e2e-ui-ui.name" . }} + labels: + {{- include "e2e-ui-ui.labels" . | nindent 4 }} +spec: + selector: + app.kubernetes.io/name: {{ include "e2e-ui-ui.name" . }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/tests/e2e-ui/ui/.gitops/deploy/values.yaml b/tests/e2e-ui/ui/.gitops/deploy/values.yaml new file mode 100644 index 00000000..f6576dc3 --- /dev/null +++ b/tests/e2e-ui/ui/.gitops/deploy/values.yaml @@ -0,0 +1,154 @@ +# Local workbench values for e2e-ui UI. +# Secrets live in K8s Secrets (secretEnv / envFromSecrets) — not here. + +local: false +appRuntime: package # host | cluster-dev | package + +name: e2e-ui-ui +image: + repository: ghcr.io/hops-ops/e2e-ui-ui + tag: latest + pullPolicy: IfNotPresent + +# Source tree is the *distributed* monorepo root (needs tests/e2e-ui/ui + js/). +# Application deliveryPath points there; workingDir is the Svelte app. +clusterDev: + image: node:22-bookworm + # Use bash -c (not -lc) so container ENV/PATH stay intact. + command: + - /bin/bash + - -c + - | + set -e + cd /workspace/js && npm install && npm run build + cd /workspace/tests/e2e-ui/ui + npm install + # Skip dctl/cargo: generated clients are committed under src/lib/generated. + export DISTRIBUTED_SKIP_CLIENT_COMPILE=1 + # In-cluster API service (not host localhost). + export E2E_API_ORIGIN="${E2E_API_ORIGIN:-http://e2e-ui-api:8791}" + export E2E_BASE_URL="$E2E_API_ORIGIN" + npm run dev -- --host 0.0.0.0 --port 5180 + workingDir: /workspace/tests/e2e-ui/ui + sourcePath: /workspace + +service: + port: 5180 + targetPort: 5180 + +# Non-secret env only (safe in git). +env: + HOST: "0.0.0.0" + PORT: "5180" + DISTRIBUTED_SKIP_CLIENT_COMPILE: "1" + E2E_API_ORIGIN: "http://e2e-ui-api:8791" + E2E_BASE_URL: "http://e2e-ui-api:8791" + PUBLIC_E2E_API_ORIGIN: "http://e2e-ui-api:8791" + # Overridden at render when hops injects namespace (= --name). + AUTH_URL: "http://e2e-ui-ui.default.svc.cluster.local:5180" + AUTH_TRUST_HOST: "true" + AUTH_USE_SECURE_COOKIES: "false" + # AuthStack issuer (Helm default Service fullname) + OIDC_ISSUER: "http://zitadel-zitadel.auth.svc.cluster.local:8080" + # Project id residual after workspace identity Project Ready + OIDC_AUDIENCE: "" + ZITADEL_PROJECT_ID: "" + +# Secret-backed env (keys must exist on Secret e2e-ui-oidc in the app namespace). +# Create live (or ExternalSecrets) — client id/secret from the worktree OIDC app: +# kubectl -n create secret generic e2e-ui-oidc \ +# --from-literal=AUTH_SECRET='…' \ +# --from-literal=OIDC_CLIENT_ID='…' \ +# --from-literal=OIDC_CLIENT_SECRET='…' \ +# --from-literal=ZITADEL_SERVICE_USER_TOKEN="$(kubectl -n auth get secret login-client -o jsonpath='{.data.pat}' | base64 -d)" +secretEnv: + AUTH_SECRET: + secretName: e2e-ui-oidc + key: AUTH_SECRET + OIDC_CLIENT_ID: + secretName: e2e-ui-oidc + key: OIDC_CLIENT_ID + OIDC_CLIENT_SECRET: + secretName: e2e-ui-oidc + key: OIDC_CLIENT_SECRET + # login-client PAT (IAM_LOGIN_CLIENT) for custom /login Session API + CreateCallback + ZITADEL_SERVICE_USER_TOKEN: + secretName: e2e-ui-oidc + key: ZITADEL_SERVICE_USER_TOKEN + +# Optional: mount entire secrets as env (unused by default) +envFromSecrets: [] + +# Zitadel identity on the UI chart (OIDC web client + Login V2 host). +# +# Scope (all gitops when identity.enabled): +# - Project + roles + demo humans — cluster-shared (projectNamespace) +# - OIDC web app — per worktree (redirects + app loginV2.baseUri) +# - Instance Features loginV2 — points authorize → this UI's /login +# (instance-global in Zitadel; last applied worktree wins on one AuthStack) +# +# Enable from Application values when the env needs OIDC dogfood. +identity: + enabled: false + # After AuthStack Ready: GET …/management/v1/orgs/me + orgId: "" + # Worktree id for OIDC app names only (e2e-ui--web). Default: + # release namespace (= hops --name / helm --namespace). + workspace: "" + # Cluster-shared Project MR name (roles + humans hang off this). + projectName: e2e-ui + # Cluster-shared demo humans (alice / bob / admin · Password1!) + demoUsers: true + # Namespace for worktree OIDC app MR. Empty → release namespace. + mrNamespace: "" + # Namespace for shared Project / Role MRs (defaults to mrNamespace). + projectNamespace: default + # Namespace for shared HumanUser MRs (defaults to projectNamespace). + humansNamespace: default + # Gitops Features MR: required Login V2 → uiBaseURL (this chart's /login). + # Set false only if something else owns instance Features. + instanceLoginV2: true + # Optional overrides; defaults = this chart name + service.port + uiService: "" + uiPort: "" + # Optional full override of redirect base (Login V2 baseUri + callbacks) + uiBaseURL: "" + extraRedirectUris: + - http://127.0.0.1:5180/auth/callback/oidc + - http://localhost:5180/auth/callback/oidc + - http://127.0.0.1:18101/auth/callback/oidc + - http://localhost:18101/auth/callback/oidc + extraPostLogoutRedirectUris: + - http://127.0.0.1:5180/ + - http://localhost:5180/ + - http://127.0.0.1:18101/ + - http://localhost:18101/ + passwordSecret: + # Residual Secret keys: alice, bob, admin + # Cluster-shared default name (not per-worktree). + name: e2e-human-passwords + providerConfigRef: + name: default + # Prefer ClusterProviderConfig so shared + worktree MRs share credentials. + kind: ClusterProviderConfig + +# Materialize residual secrets from Vault (ClusterSecretStore "vault"). +# Enable after SecretStack CSS is Ready + hops secrets sync vault. +externalSecrets: + enabled: false + refreshInterval: 1m + secretStoreRef: + name: vault + kind: ClusterSecretStore + oidc: + vaultPath: "" # default e2e-ui//oidc + targetName: e2e-ui-oidc + humanPasswords: + enabled: true + vaultPath: "" # default e2e-ui//human-passwords + targetName: "" # default e2e-human-passwords- + +sourceDelivery: + mode: none # none | hostPath | sync + hostPath: "" + mountPath: /workspace diff --git a/tests/e2e-ui/ui/src/routes/+page.svelte b/tests/e2e-ui/ui/src/routes/+page.svelte index a07875f1..fd140bc6 100644 --- a/tests/e2e-ui/ui/src/routes/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/+page.svelte @@ -198,10 +198,10 @@ query Todos @load {

Distributed is a state-of-the-art framework - for building distributed systems and realtime applications. + for building distributed systems, and realtime applications.

- Not a partial toolkit. An end-to-end stack — domain, service, query edge, and live client — + Not a partial toolkit. An end-to-end cloud native stack — domain, service, query edge, live client, and even gitops — so engineers who care about quality code can stay on the model and still ship polished, fast, maintainable products.

diff --git a/tests/e2e-ui/ui/src/routes/login/+page.server.ts b/tests/e2e-ui/ui/src/routes/login/+page.server.ts index ad29d05b..63b34527 100644 --- a/tests/e2e-ui/ui/src/routes/login/+page.server.ts +++ b/tests/e2e-ui/ui/src/routes/login/+page.server.ts @@ -19,6 +19,8 @@ export const load: PageServerLoad = async (event) => { return { authRequest, + // Workspace-prefixed when identity.workspace is set (e.g. dogfood-alice). + // Local dogfood also seeds plain alice/bob/admin for the same password. demoHint: 'Demo: alice / bob / admin · Password1!' }; }; diff --git a/tests/e2e-ui/ui/vite.config.ts b/tests/e2e-ui/ui/vite.config.ts index c71fdbab..bbdcaa81 100644 --- a/tests/e2e-ui/ui/vite.config.ts +++ b/tests/e2e-ui/ui/vite.config.ts @@ -1,3 +1,6 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { sveltekit } from '@sveltejs/kit/vite'; import { distributedGraphqlProxy, @@ -9,13 +12,28 @@ import { distributedViteOptions } from './distributed.config.js'; const api = process.env.E2E_API_ORIGIN || process.env.E2E_BASE_URL || 'http://127.0.0.1:8791'; +// Cluster-dev node images often lack cargo/dctl. Prefer committed generated +// clients when present so vite can start without a Rust toolchain. +const generatedReady = distributedViteOptions.clients.every((client) => + existsSync(resolve(distributedViteOptions.cwd, client.out, 'sveltekit.ts')) +); +const skipClientCompile = + process.env.DISTRIBUTED_SKIP_CLIENT_COMPILE === '1' || + (process.env.DISTRIBUTED_SKIP_CLIENT_COMPILE !== '0' && generatedReady); + export default defineConfig({ - plugins: [distributedSvelteKit(distributedViteOptions), sveltekit()], + plugins: [ + ...(skipClientCompile ? [] : [distributedSvelteKit(distributedViteOptions)]), + sveltekit() + ], css: { devSourcemap: true }, // blob-domain pure package (wasm-pack --features wasm) assetsInclude: ['**/*.wasm'], server: { port: 5180, + // hops local cluster-DNS mode uses svc.ns.svc.cluster.local Host headers. + host: true, + allowedHosts: true, // GraphQL-only public API (commands are mutations, not POST /todo.*). proxy: distributedGraphqlProxy(api) },