Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3baee40745 | ||
|
|
2cb2e8e286 | ||
|
|
6dc2b3a355 | ||
|
|
6134408bad | ||
|
|
36fc7c3b74 | ||
|
|
180fd74369 | ||
|
|
78e0d35199 | ||
|
|
e80a6fc641 | ||
|
|
577d4a14fa | ||
|
|
2636741033 | ||
|
|
4cf5815f52 | ||
|
|
cb8a915bfe | ||
|
|
d8e7017862 | ||
|
|
814d453bd5 | ||
|
|
0f375f684b | ||
|
|
98155f51a3 | ||
|
|
9ecd5a87b8 | ||
|
|
df33e858ce | ||
|
|
7d4568ff72 | ||
|
|
263473c2cb | ||
|
|
169beabdc2 | ||
|
|
932e1f6f32 | ||
|
|
87e578c6db | ||
|
|
c86ddfbec9 | ||
|
|
3b3eea5ede | ||
|
|
8dd682e160 | ||
|
|
dd4b0f5c72 | ||
|
|
1565bb2657 | ||
|
|
37bfe14380 | ||
|
|
f5948dccca | ||
|
|
65aa122ca9 | ||
|
|
34643df667 | ||
|
|
9c56a93ffb | ||
|
|
caf0db43f7 | ||
|
|
75cdae8172 | ||
|
|
945441e402 | ||
|
|
4042dcc7d2 | ||
|
|
4e648305dc | ||
|
|
671c08f57a | ||
|
|
5ffb30f9dd | ||
|
|
ede3887880 | ||
|
|
4a1b33cff5 | ||
|
|
4eb1e74ebb | ||
|
|
7d3df8ac2a | ||
|
|
6f75859b3c | ||
|
|
bd7bce6b39 | ||
|
|
faefd7d4d3 | ||
|
|
7e0fc65a0a | ||
|
|
bc5bdcd4c0 | ||
|
|
18eb2edfb7 | ||
|
|
0b30320f79 | ||
|
|
5dac9c3f34 | ||
|
|
655e491414 | ||
|
|
004c6101f4 | ||
|
|
e922033543 | ||
|
|
af93855841 | ||
|
|
5ec96533e5 | ||
|
|
051df1a96b | ||
|
|
7add862ca6 | ||
|
|
684e447943 | ||
|
|
4e0f4c87ca | ||
|
|
d3f60be7e6 | ||
|
|
4f9c09aa30 | ||
|
|
3e34b7bd54 | ||
|
|
3f279cbb19 | ||
|
|
54b81309be | ||
|
|
268e79369e | ||
|
|
f977650c30 | ||
|
|
bb021e49a7 | ||
|
|
bca49760b1 | ||
|
|
f470bcab93 | ||
|
|
d31cd1cde6 | ||
|
|
2527b4957e | ||
|
|
22a268cd29 | ||
|
|
6012f52b6a | ||
|
|
cf663ec015 | ||
|
|
5e75f25b45 | ||
|
|
ca9c4682dd | ||
|
|
8eabff4d44 | ||
|
|
6cde53fb05 | ||
|
|
9560169625 | ||
|
|
4ee67c1797 | ||
|
|
2bb35ac291 | ||
|
|
0782431509 | ||
|
|
e2929b82b0 | ||
|
|
d19f1d9066 | ||
|
|
7241bc952e | ||
|
|
8a50e0d335 | ||
|
|
1484745a01 | ||
|
|
9dc3038051 | ||
|
|
a06b5fb1a2 | ||
|
|
675809dcdf | ||
|
|
f43883d34e | ||
|
|
74fe777aeb | ||
|
|
9963b3d055 | ||
|
|
e8fa9d6f18 | ||
|
|
18289d0eb6 | ||
|
|
fac1cf9aa3 | ||
|
|
f7ded021ec | ||
|
|
dbc4966aeb | ||
|
|
d38984f03f | ||
|
|
0aa853f300 | ||
|
|
610dcc166d | ||
|
|
26a1910bca | ||
|
|
d411200359 | ||
|
|
071174a414 | ||
|
|
bc98da0308 | ||
|
|
7456318242 | ||
|
|
d9a20e2798 | ||
|
|
36231f57e2 | ||
|
|
4343cdc684 | ||
|
|
187473bd92 | ||
|
|
9659d1a56f | ||
|
|
75d6f36639 | ||
|
|
dcd107b336 | ||
|
|
04a4d68118 | ||
|
|
a773c9225a | ||
|
|
50bbf3a64d | ||
|
|
7685705165 | ||
|
|
ed4d37235f | ||
|
|
0655cdb8cd | ||
|
|
7ff5862f42 | ||
|
|
fceb34c1d2 | ||
|
|
d5e12b3df1 | ||
|
|
36ba00f540 | ||
|
|
db53daea72 | ||
|
|
919d96f3f8 | ||
|
|
d846f17bf8 | ||
|
|
1648c08839 | ||
|
|
385237bdaa |
@@ -111,6 +111,28 @@ jobs:
|
||||
echo "GOPROXY=https://proxy.golang.org,direct"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: zen streaming-fix floor — go.mod must pin github.com/hanzoai/zen >= v1.4.1
|
||||
# The SSE body-close fix (zen commit 50328b8, first released in zen v1.4.1)
|
||||
# is what makes streaming completions return a body instead of an empty
|
||||
# stream. A stale-branch merge that reverts go.mod's zen pin below the floor
|
||||
# silently re-breaks streaming, and `next build`'s ignoreBuildErrors hides
|
||||
# the runtime break — so no image may be cut on a regressed pin. This is the
|
||||
# durable root-cause guard: it reads the EFFECTIVE module version (post-MVS,
|
||||
# exactly what the build links) and fails the PR/push below the floor.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FLOOR="v1.4.1"
|
||||
V="$(go list -m -f '{{.Version}}' github.com/hanzoai/zen)"
|
||||
echo "effective github.com/hanzoai/zen = ${V} (floor ${FLOOR})"
|
||||
# semver-correct compare: the lowest of {V, FLOOR} under `sort -V` must be
|
||||
# the FLOOR, i.e. V >= FLOOR. (sort -V orders v1.4.2 above v1.4.10 too.)
|
||||
low="$(printf '%s\n%s\n' "$V" "$FLOOR" | sort -V | head -1)"
|
||||
if [ "$low" != "$FLOOR" ]; then
|
||||
echo "::error::github.com/hanzoai/zen is pinned at ${V}, below the streaming-fix floor ${FLOOR} — this re-breaks SSE streaming (empty completions). Re-pin zen to >= ${FLOOR} in go.mod before merging."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: zen ${V} is at or above the streaming-fix floor ${FLOOR}"
|
||||
|
||||
- name: positive proof — clients/controlplane is unreachable from the default build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
+161
-37
@@ -124,20 +124,6 @@ jobs:
|
||||
echo "sha_short=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_OUTPUT"
|
||||
echo "Next release: v${version} (git_max='${git_max:-none}' container_max='${cont_max:-none}')"
|
||||
|
||||
# Console-embed cachebust. The console clone+build layer is keyed on this;
|
||||
# prefer hanzoai/console main HEAD so a CONSOLE-ONLY change re-embeds without
|
||||
# needing a cloud commit (cloud-sha alone froze the embed between cloud pushes).
|
||||
# git ls-remote must CLEAR the extraheader actions/checkout installs (it carries
|
||||
# THIS repo's GITHUB_TOKEN, which 404s the cross-repo console lookup); gh is not
|
||||
# on the runner. If resolution yields nothing, fall back to the cloud sha — still
|
||||
# unique per cloud commit, so the embed is never frozen. Either way THIS build
|
||||
# busts (new value) and re-clones console main fresh.
|
||||
console_head="$(git -c 'http.https://github.com/.extraheader=' ls-remote \
|
||||
"https://x-access-token:${GH_PAT}@github.com/hanzoai/console.git" refs/heads/main 2>/dev/null | cut -f1 || true)"
|
||||
cachebust="${console_head:-$GITHUB_SHA}"
|
||||
echo "cachebust=${cachebust}" >> "$GITHUB_OUTPUT"
|
||||
echo "console cachebust: ${cachebust} (console_head='${console_head:-none}')"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
@@ -160,8 +146,12 @@ jobs:
|
||||
# Direct credential first (repo/org secret — works on private repos,
|
||||
# where the Free plan hides org KMS secrets); KMS kubeconfig fallback.
|
||||
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
||||
echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"; exit 0
|
||||
if echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "mirror login failed (registry.hanzo.ai unreachable) — mirror skipped, release continues"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
[ -z "${KMS_CLIENT_ID:-}" ] && { echo "no KMS creds — mirror skipped"; exit 0; }
|
||||
TOKEN=$(curl -sf "$KMS_ENDPOINT/v1/kms/auth/login" -H 'Content-Type: application/json' -d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r '.accessToken // empty')
|
||||
@@ -180,8 +170,11 @@ jobs:
|
||||
UP=$(echo "$CFG" | jq -r '.auths["registry.hanzo.ai"].auth // empty' | base64 -d)
|
||||
[ -z "$UP" ] && { echo "no registry auth — mirror skipped"; exit 0; }
|
||||
echo "::add-mask::${UP#*:}"
|
||||
echo "${UP#*:}" | docker login registry.hanzo.ai -u "${UP%%:*}" --password-stdin
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
|
||||
if echo "${UP#*:}" | docker login registry.hanzo.ai -u "${UP%%:*}" --password-stdin; then
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "mirror login failed (registry.hanzo.ai unreachable) — mirror skipped, release continues"
|
||||
fi
|
||||
|
||||
- name: Log in to ghcr.io (GH_PAT — writes the cloud package despite its ai-repo linkage)
|
||||
uses: docker/login-action@v3
|
||||
@@ -190,6 +183,39 @@ jobs:
|
||||
username: hanzo-dev
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Resolve decomplection artifact digests (the Go-only build's prebuilt inputs)
|
||||
id: artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# cloud compiles ONLY Go; it pulls three prebuilt artifacts (console SPA,
|
||||
# agent-skills catalog, native flags staticlib). Resolve each published
|
||||
# :latest to an IMMUTABLE digest so THIS release is reproducible (pinned,
|
||||
# not floating :latest) AND a console/skills/flags change is picked up —
|
||||
# its CI republished :latest, so this resolves to the NEW digest. A MISSING
|
||||
# artifact FAILS the release HERE, before build/smoke/push/tag: the receipt
|
||||
# invariant means we never tag an image that couldn't embed the real console.
|
||||
command -v crane >/dev/null 2>&1 || {
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||
| tar -xz -C "$HOME/.local/bin" crane
|
||||
}
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
resolve() {
|
||||
local repo="$1" d
|
||||
d="$(crane digest "ghcr.io/hanzoai/${repo}:latest" 2>/dev/null || true)"
|
||||
[ -n "$d" ] || { echo "::error::decomplection artifact ghcr.io/hanzoai/${repo}:latest is not published — refusing to cut a release that would embed a stale/placeholder ${repo}"; return 1; }
|
||||
printf 'ghcr.io/hanzoai/%s@%s' "$repo" "$d"
|
||||
}
|
||||
CONSOLE_IMAGE="$(resolve console-embed)" || exit 1
|
||||
SKILLS_IMAGE="$(resolve agent-skills)" || exit 1
|
||||
FLAGS_IMAGE="$(resolve cloud-flags)" || exit 1
|
||||
{
|
||||
echo "console_image=${CONSOLE_IMAGE}"
|
||||
echo "skills_image=${SKILLS_IMAGE}"
|
||||
echo "flags_image=${FLAGS_IMAGE}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "resolved: console=${CONSOLE_IMAGE} skills=${SKILLS_IMAGE} flags=${FLAGS_IMAGE}"
|
||||
|
||||
- name: OCI labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -216,11 +242,13 @@ jobs:
|
||||
load: true
|
||||
tags: cloud:smoke
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Bust the console clone+build layer every release (the cloud commit sha is
|
||||
# unique per push) so the embed re-fetches console main HEAD fresh — never the
|
||||
# frozen snapshot the persistent BuildKit cache would otherwise serve forever.
|
||||
# cloud compiles ONLY Go: pull the three prebuilt artifacts pinned to the
|
||||
# digests resolved above (reproducible, and fresh — a console/skills/flags
|
||||
# change is a new digest). No node/python/rust toolchain in this build.
|
||||
build-args: |
|
||||
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
|
||||
CONSOLE_IMAGE=${{ steps.artifacts.outputs.console_image }}
|
||||
SKILLS_IMAGE=${{ steps.artifacts.outputs.skills_image }}
|
||||
FLAGS_IMAGE=${{ steps.artifacts.outputs.flags_image }}
|
||||
# GIT_AUTH_TOKEN: BuildKit secret the Dockerfile consumes to fetch private
|
||||
# cross-org Go modules (hanzoai/*, luxfi/*) over authenticated git.
|
||||
secrets: |
|
||||
@@ -462,10 +490,12 @@ jobs:
|
||||
ghcr.io/hanzoai/cloud:sha-${{ steps.ver.outputs.sha_short }}
|
||||
ghcr.io/hanzoai/cloud:latest
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# SAME cachebust as the smoke build → every layer is a cache hit from step 1
|
||||
# and the pushed image is byte-identical to the one the smoke test proved.
|
||||
# SAME artifact digests as the smoke build → every layer is a cache hit from
|
||||
# step 1 and the pushed image is byte-identical to the one smoke proved.
|
||||
build-args: |
|
||||
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
|
||||
CONSOLE_IMAGE=${{ steps.artifacts.outputs.console_image }}
|
||||
SKILLS_IMAGE=${{ steps.artifacts.outputs.skills_image }}
|
||||
FLAGS_IMAGE=${{ steps.artifacts.outputs.flags_image }}
|
||||
secrets: |
|
||||
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -499,12 +529,25 @@ jobs:
|
||||
# newest-first and version tags are monotonic, so the highest version
|
||||
# is always among the most-recent versions; paginating the WHOLE
|
||||
# registry history is what livelocked this step as tags accumulated.
|
||||
# Fail-CLOSED. An ORPHANED container tag — image pushed by a run that
|
||||
# died or was cancelled after imagetools-create but before its git tag —
|
||||
# MUST raise the floor, or a later run reassigns that same number to a
|
||||
# different image (an ambiguous mutable prod tag; the v1.801.50 flip). A
|
||||
# git-only floor can't see the orphan, so if the container-tag lookup
|
||||
# ERRORS (vs legitimately returning no tags) we retry the whole attempt
|
||||
# rather than silently proceeding — a version with a pushed image is never
|
||||
# reused. (Reordering git-tag before imagetools-create is the WRONG fix: it
|
||||
# reintroduces the phantom "tag ⇔ no image" this workflow exists to prevent.)
|
||||
cont_max=""
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
cont_max="$(GH_TOKEN="$GH_PAT" gh api \
|
||||
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
|
||||
--jq '.[].metadata.container.tags[]?' 2>/dev/null \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
if cont_raw="$(GH_TOKEN="$GH_PAT" gh api \
|
||||
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
|
||||
--jq '.[].metadata.container.tags[]?' 2>/dev/null)"; then
|
||||
cont_max="$(printf '%s\n' "$cont_raw" \
|
||||
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
|
||||
else
|
||||
echo " container-tag lookup failed — retry so an orphaned tag can't be reused (attempt $attempt)"; sleep 3; continue
|
||||
fi
|
||||
fi
|
||||
max="$(printf '%s\n%s\n%s\n' "1.786.0" "$git_max" "$cont_max" \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
|
||||
@@ -534,8 +577,12 @@ jobs:
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
for MT in "${V}" "${VER}" "${major}.${minor}"; do
|
||||
crane copy "$SHA_IMG" "registry.hanzo.ai/hanzoai/cloud:${MT}" \
|
||||
|| echo "::warning::mirror registry.hanzo.ai/hanzoai/cloud:${MT} failed"
|
||||
# Bounded: registry.hanzo.ai can *hang* (not just fail), and this
|
||||
# is best-effort — an unbounded crane copy once livelocked the whole
|
||||
# tag step and held the serialized release lane. timeout makes the
|
||||
# mirror truly best-effort so the git-tag receipt below always runs.
|
||||
timeout 120 crane copy "$SHA_IMG" "registry.hanzo.ai/hanzoai/cloud:${MT}" \
|
||||
|| echo "::warning::mirror registry.hanzo.ai/hanzoai/cloud:${MT} failed or timed out"
|
||||
done
|
||||
fi
|
||||
git tag -a "$V" -m "release $V — image ghcr.io/hanzoai/cloud:$V (retagged from sha-${{ steps.ver.outputs.sha_short }}, smoke-passed ${GITHUB_SHA})"
|
||||
@@ -552,9 +599,86 @@ jobs:
|
||||
echo "::error::could not acquire a free version tag after 8 attempts"
|
||||
exit 1
|
||||
|
||||
# Deploy = a declared-tag bump in hanzoai/universe crs/cloud.yaml — Hanzo CD
|
||||
# (the ArgoCD instance in ns hanzo-cd) syncs universe→cluster and the operator
|
||||
# reconciles the CR. The old notify-universe repository_dispatch hub is retired
|
||||
# (its flagged-sender dispatches were silently suppressed anyway); the native
|
||||
# release path (release.go rolloutRelease) and deliberate promote commits own
|
||||
# the bump.
|
||||
# ── Promote: the declared-tag bump that makes the release DEPLOY ─────────────
|
||||
# The tag minted above is the receipt for a pushed, smoke-passed image; THIS job
|
||||
# records it as the desired state Hanzo CD reconciles. The universe-crs ArgoCD
|
||||
# Application (ns hanzo-cd, `automated` sync + selfHeal) syncs
|
||||
# infra/k8s/operator/crs/*.yaml → cluster and the operator rolls the Deployment,
|
||||
# so a tag bump committed here reaches api.hanzo.ai with NO hand-dispatch and NO
|
||||
# hand-edit of the CR.
|
||||
#
|
||||
# This is the SAME yq-bump → `deploy(<svc>): <tag>` universe commit the hanzoai/ci
|
||||
# reusable (build.yml deploy step) does for every other service. cloud owns it
|
||||
# HERE because its image is built by this workflow, not the ci reusable — its
|
||||
# hanzo.yml carries no main `images:` entry and `# NO deploy`, so the shared
|
||||
# deploy step never bumps cloud's CR. A direct in-cluster CR patch is NOT enough:
|
||||
# ArgoCD selfHeal reverts any live edit not also recorded in git within ~45s.
|
||||
# The retired notify-universe repository_dispatch had no receiver after the
|
||||
# image-update.yml deploy hub was deleted in the Hanzo CD cutover; the git commit
|
||||
# IS the sanctioned path now.
|
||||
promote:
|
||||
needs: build-amd64
|
||||
# Only a real release promotes: build+smoke+push+tag all succeeded, so a
|
||||
# proven v* image exists. A failure earlier leaves version_v empty → skipped.
|
||||
if: ${{ needs.build-amd64.outputs.version_v != '' }}
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- name: Record the proven tag in universe crs/cloud.yaml (Hanzo CD rolls it)
|
||||
env:
|
||||
# GH_PAT already pushes this repo's git tags above (contents:write on the
|
||||
# hanzoai org), so it writes hanzoai/universe too — the SAME token the ci
|
||||
# reusable falls back to for the universe deploy commit.
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
VERSION_V: ${{ needs.build-amd64.outputs.version_v }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -n "${GH_PAT:-}" ] || { echo "::error::no GH_PAT — cannot record the declared-tag bump in universe"; exit 1; }
|
||||
|
||||
# Bare arc runners ship no yq — provision the static binary (sudo-free,
|
||||
# same pattern the ci reusable and this workflow's kubectl/crane fetches use).
|
||||
if ! command -v yq >/dev/null 2>&1; then
|
||||
mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
|
||||
curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \
|
||||
-o "$HOME/.local/bin/yq" && chmod +x "$HOME/.local/bin/yq"
|
||||
fi
|
||||
|
||||
git clone -q --depth 1 \
|
||||
"https://x-access-token:${GH_PAT}@github.com/hanzoai/universe.git" \
|
||||
"$RUNNER_TEMP/universe"
|
||||
CR="$RUNNER_TEMP/universe/infra/k8s/operator/crs/cloud.yaml"
|
||||
[ -f "$CR" ] || { echo "::error::crs/cloud.yaml not found in universe"; exit 1; }
|
||||
|
||||
CUR="$(yq -r '.spec.image.tag // ""' "$CR")"
|
||||
echo "cloud CR: ${CUR:-<empty>} → ${VERSION_V}"
|
||||
|
||||
# Monotonic guard: never roll the CR BACKWARD. Release runs finish under a
|
||||
# serialized lane but a slow older run must never overwrite a newer promote.
|
||||
# Skip iff the CR already holds a semver >= the version we just cut.
|
||||
CURN="${CUR#v}"; NEWN="${VERSION_V#v}"
|
||||
if printf '%s' "$CURN" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
top="$(printf '%s\n%s\n' "$CURN" "$NEWN" | sort -V | tail -1)"
|
||||
if [ "$top" = "$CURN" ] && [ "$CURN" != "$NEWN" ]; then
|
||||
echo "::notice::cloud CR already at v${CURN} (≥ ${VERSION_V}) — not rolling back"; exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
yq -i ".spec.image.tag = \"${VERSION_V}\"" "$CR"
|
||||
if git -C "$RUNNER_TEMP/universe" diff --quiet; then
|
||||
echo "::notice::crs/cloud.yaml already at ${VERSION_V} — nothing to record"; exit 0
|
||||
fi
|
||||
git -C "$RUNNER_TEMP/universe" -c user.name=hanzo-ci -c user.email=dev@hanzo.ai \
|
||||
commit -qam "deploy(cloud): ${VERSION_V} (${GITHUB_REPOSITORY}@$(echo "${GITHUB_SHA}" | cut -c1-7))"
|
||||
|
||||
# Rebase-safe push: universe main advances on every service's deploy, so a
|
||||
# concurrent commit must not make cloud's promote lose the whole roll. Retry
|
||||
# a few times, rebasing between attempts.
|
||||
for attempt in $(seq 1 5); do
|
||||
if git -C "$RUNNER_TEMP/universe" push -q origin HEAD:main; then
|
||||
echo "recorded deploy(cloud): ${VERSION_V} — Hanzo CD (universe-crs) will roll it to api.hanzo.ai"
|
||||
exit 0
|
||||
fi
|
||||
echo " universe push lost the race — rebasing (attempt ${attempt})"
|
||||
git -C "$RUNNER_TEMP/universe" pull -q --rebase origin main || true
|
||||
sleep 3
|
||||
done
|
||||
echo "::error::could not record the cloud tag bump in universe after 5 attempts"; exit 1
|
||||
|
||||
@@ -6,6 +6,44 @@ artifact serves `api.hanzo.ai`, `api.lux.cloud`, `api.zoo.cloud`, `api.osage.clo
|
||||
and every white-label reseller. Brand, enabled subsystems, and org scope are
|
||||
deployment configuration.
|
||||
|
||||
## Open Cloud planes
|
||||
|
||||
Spec home: HIP-0129 `hip-0129-open-cloud-planes` (hips repo). This section is a
|
||||
map, not the spec. One noun, one owner, one route family. No plane reads another
|
||||
plane's store; imports flow custody-ward only (channels -> integrations, never
|
||||
reverse).
|
||||
|
||||
| Route | Noun | Owner | Tier |
|
||||
| --- | --- | --- | --- |
|
||||
| `/v1/connectors` | Custody: per-user BYO external accounts | `clients/integrations` (extends; user scope new) | In flight (branch `feat/connectors`) |
|
||||
| `/v1/channels` | Transport: portable message envelope, DM pairing, send + inbox | `clients/channels` (new) | Planned (branch `feat/channels` reserved; no transport code yet) |
|
||||
| `/v1/sync` | Data: bidirectional sync engine | `clients/sync` | Shipped |
|
||||
| `/v1/automations` | Workflows: flows/runs, goja piece runtime | `clients/automations` | Shipped |
|
||||
| `/v1/compute/bots` | Hosting: `@hanzo/bot` Node containers | `clients/bots` | Shipped |
|
||||
| `/v1/tasks` | Durable engine | `clients/tasks` | Shipped |
|
||||
| `/v1/gpus` + fleet | BYO GPU presence | `clients/fleet` + `clients/visor` | Shipped |
|
||||
| IAM | Identity: users, orgs, roles | IAM | Shipped |
|
||||
| KMS | Secret custody: sealed secrets | `clients/kms` | Shipped |
|
||||
|
||||
Custody invariants: secrets sealed in KMS at
|
||||
`/orgs/{org}/users/{user}/connectors/{provider}/{label}`, never in SQLite rows;
|
||||
verify before store. Refresh is single-flight with rotation resealing; the CLI
|
||||
does local browser PKCE and posts the bundle to
|
||||
`POST /v1/connectors/:provider/credential`; cloud owns device-code flows.
|
||||
|
||||
Transport invariants: typed actions (`command|url|select|approval`), no raw
|
||||
string sniffing; pairing codes 8 chars, 1h TTL, max 3 pending per account,
|
||||
owner bootstrap on first approval.
|
||||
|
||||
Container boundary is permanent for native-module, host-filesystem, loop-state,
|
||||
and vendor-Node work (agent loop, exec/PTY, harnesses, browser, voice, codecs,
|
||||
Node-bound channels, plugin SDK/loader). The Node plugin SDK is never ported to
|
||||
Go; cloud extensibility is connectors/automations/tools.
|
||||
|
||||
Port roadmap (P1-P15) lives in HIP-0129; do not restate it here. Every claim
|
||||
carries its tier: Shipped (on main, named package/route), In flight (named
|
||||
pre-main branch), Planned (backlog id or named reservation).
|
||||
|
||||
## Framework doctrine
|
||||
|
||||
One way to do everything. Composable, orthogonal, DRY. A new subsystem is a
|
||||
@@ -184,3 +222,39 @@ Import path (already-incorporated orgs): Google Drive → data room, a Google Sh
|
||||
captable, via the `google` OAuth provider now completed in `clients/integrations`
|
||||
(token custodied in KMS; the automations `google` connector shares the same token).
|
||||
Runbook: `docs/company-dogfood.md`.
|
||||
|
||||
## Deploy plane (`clients/deploy`, `/v1/deploy`)
|
||||
|
||||
Native ArgoCD-grade GitOps console over the operator-managed fleet, parallel to
|
||||
`/v1/git`: each `hanzo.ai/v1` App CR IS the Application, and the plane OBSERVES the
|
||||
operator's reconcile — `GET /v1/deploy/applications` (fleet list), `/{name}/tree`
|
||||
(ownerRef resource tree + per-node health/sync), `/{name}/resource/{ref}` (live
|
||||
manifest + desired-vs-live diff), `/{name}/logs`; `POST /{name}/rollback` pins the CR
|
||||
image to a prior semver and `/{name}/sync` requests a reconcile. SUPERADMIN-only on
|
||||
`c.IsAdmin()`, fail-closed; Secret nodes are never surfaced. `engine.go` embeds the argo
|
||||
`gitops-engine` (`hanzoai/deploy/gitops-engine` v0.7.2, no replace) in-process for the
|
||||
reconcile half behind `DEPLOY_ENGINE_ENABLED` (default off), with a prune-safety fuse.
|
||||
|
||||
## The `hanzo` CLI targets THIS binary — one contract, one IAM login
|
||||
|
||||
The `hanzo` CLI (`cli/`) is the same unified binary; its control-plane verbs speak the
|
||||
routes THIS process serves, authorized off a plain `hanzo login` (the IAM access token is
|
||||
the final bearer fallback — no `--platform-token`). The ONE contract, no TS-Dokploy drift:
|
||||
|
||||
- `hanzo apps list|get` → `GET /v1/paas/apps[/{app}]` (`clients/paas` fleet drift board)
|
||||
- `hanzo deploy <app>` → `POST /v1/paas/apps/{app}/deploy` — a zero-downtime ROLLING
|
||||
RESTART (stamps the Deployment pod-template `hanzo.ai/restartedAt` annotation; never
|
||||
changes the declared TAG — that stays a git commit CD reconciles). `--env` picks the ns.
|
||||
- `hanzo clusters list|get` → `GET /v1/clusters` (`clients/visor`, tenant-scoped)
|
||||
- `hanzo build` → `POST /v1/runner` (native buildkit fabric)
|
||||
|
||||
`/v1/paas/*` auth mirrors `/v1/runner` (`clients/platform/runner.go`): the `guard` admits a
|
||||
validated principal who is SuperAdmin OR OrgAdmin, then each handler CONFINES a non-super
|
||||
caller to the platform namespaces its own validated org owns (`scopedNamespaces`, keyed on
|
||||
`principal.Org` — a tenant admin can never observe/restart another org's, or a platform,
|
||||
app; `?org=` cannot widen it). The rolling restart needs `patch` on `apps/deployments`
|
||||
(ClusterRole/cloud, universe `infra/k8s/cloud/rbac.yaml`). There is NO `/v1/apps`,
|
||||
`/v1/org/{org}/cluster`, or `/v1/platform/projects` CLI path — the first two never existed
|
||||
here (TS-Dokploy contract, 404), and `/v1/platform/*` needs a co-resident IAM store this
|
||||
deployment does not fold in (IAM runs as a separate svc) so it 500s; the live apps backend
|
||||
is `/v1/paas`, whose board reads k8s directly with no IAM-store dependency.
|
||||
|
||||
@@ -24,7 +24,7 @@ OPENAPI_DIR ?= ../openapi
|
||||
# forces the fork to modernc too so the whole binary registers "sqlite" once.
|
||||
CGO_ENABLED ?= 0
|
||||
|
||||
.PHONY: help native webui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
|
||||
.PHONY: help native webui deploy-ui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
|
||||
|
||||
help: ## Show this help.
|
||||
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@@ -40,6 +40,17 @@ webui: ## Build the real console static bundle into webui/dist (go:embed source)
|
||||
cp -r "$(CONSOLE_DIR)/out/." webui/dist/
|
||||
@echo ">> embedded real console bundle into webui/dist (index.html $$(wc -c < webui/dist/index.html) bytes)"
|
||||
|
||||
deploy-ui: ## Build the monochrome ArgoCD dashboard bundle into clients/deploy/webui/dist (go:embed source). DEPLOY_DIR=<path to hanzoai/deploy>.
|
||||
@command -v yarn >/dev/null 2>&1 || { echo "yarn is required to build the deploy dashboard bundle"; exit 1; }
|
||||
@test -f "$(DEPLOY_DIR)/ui/package.json" || { echo "deploy checkout not found at $(DEPLOY_DIR) — set DEPLOY_DIR=<path to hanzoai/deploy on rebrand/hanzo-monochrome>"; exit 1; }
|
||||
@test -d "$(DEPLOY_DIR)/ui/node_modules" || (cd "$(DEPLOY_DIR)/ui" && yarn install --frozen-lockfile)
|
||||
cd "$(DEPLOY_DIR)/ui" && NODE_OPTIONS=--max-old-space-size=8192 yarn build
|
||||
# Overlay the fresh bundle, keeping only the tracked fallback (.gitignore +
|
||||
# index.html shell); the real 43MB bundle is build-time-only (gitignored).
|
||||
find clients/deploy/webui/dist -mindepth 1 -maxdepth 1 ! -name .gitignore -exec rm -rf {} +
|
||||
cp -r "$(DEPLOY_DIR)/ui/dist/app/." clients/deploy/webui/dist/
|
||||
@echo ">> embedded monochrome ArgoCD bundle into clients/deploy/webui/dist (index.html $$(wc -c < clients/deploy/webui/dist/index.html) bytes)"
|
||||
|
||||
agentskills: ## Regenerate the FULL agent-skills catalog into clients/agentskills/catalog (go:embed source) from the openapi SOT. OPENAPI_DIR=<path to openapi>.
|
||||
@test -f "$(OPENAPI_DIR)/skills.py" || { echo "openapi checkout not found at $(OPENAPI_DIR) — set OPENAPI_DIR=<path> or clone hanzoai/openapi"; exit 1; }
|
||||
# skills.py rewrites the whole catalog dir; the .gitignore keeps only the tiny
|
||||
|
||||
+31
-18
@@ -51,6 +51,7 @@ import (
|
||||
// owns process-lifetime resources, a Shutdown); Wire references them directly.
|
||||
"github.com/hanzoai/cloud/clients/account"
|
||||
"github.com/hanzoai/cloud/clients/admin"
|
||||
"github.com/hanzoai/cloud/clients/admission"
|
||||
"github.com/hanzoai/cloud/clients/ads"
|
||||
"github.com/hanzoai/cloud/clients/affiliates"
|
||||
"github.com/hanzoai/cloud/clients/agent"
|
||||
@@ -65,14 +66,14 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/bots"
|
||||
"github.com/hanzoai/cloud/clients/captable"
|
||||
"github.com/hanzoai/cloud/clients/catalogsync"
|
||||
"github.com/hanzoai/cloud/clients/cloudflare"
|
||||
"github.com/hanzoai/cloud/clients/code"
|
||||
"github.com/hanzoai/cloud/clients/company"
|
||||
"github.com/hanzoai/cloud/clients/connectorruntime"
|
||||
"github.com/hanzoai/cloud/clients/content"
|
||||
"github.com/hanzoai/cloud/clients/crm"
|
||||
"github.com/hanzoai/cloud/clients/cron"
|
||||
"github.com/hanzoai/cloud/clients/dataroom"
|
||||
"github.com/hanzoai/cloud/clients/deploy"
|
||||
"github.com/hanzoai/cloud/clients/dns"
|
||||
"github.com/hanzoai/cloud/clients/do"
|
||||
"github.com/hanzoai/cloud/clients/entitlements"
|
||||
"github.com/hanzoai/cloud/clients/eval"
|
||||
@@ -236,7 +237,10 @@ func Wire() []cloud.MountSpec {
|
||||
// CommerceClient is wired directly in pickCommerceClient).
|
||||
{Name: "commerce", Mount: mountCommerce},
|
||||
{Name: "licensing", Mount: licensing.Mount},
|
||||
{Name: "plans", Mount: plan.Mount, OwnsHealth: true},
|
||||
// clients/plan.Mount. Enable id normalized "plans" -> "plan" to match the
|
||||
// package + generated cmd/plan (one subsystem, one name). Its product routes
|
||||
// stay /v1/plans/* (incl. the OwnsHealth /v1/plans/health probe) — unchanged.
|
||||
{Name: "plan", Mount: plan.Mount, OwnsHealth: true},
|
||||
{Name: "pricing", Mount: pricing.Mount, OwnsHealth: true},
|
||||
// /v1/s3/buckets/* + /v1/s3/health. Mounts BEFORE provisioning (120) so its static
|
||||
// routes win over provisioning's /v1/s3/:name. OwnsHealth (real fail-closed probe).
|
||||
@@ -250,6 +254,9 @@ func Wire() []cloud.MountSpec {
|
||||
{Name: "do", Mount: do.Mount},
|
||||
{Name: "platform", Mount: platform.Mount, OwnsHealth: true},
|
||||
{Name: "projects", Mount: projects.Mount},
|
||||
// The /v1/dns forward head: relays the console DNS dashboard to the DNS
|
||||
// control plane under the caller's own validated bearer (clients/dns).
|
||||
{Name: "dns", Mount: dns.Mount},
|
||||
{Name: "prompts", Mount: prompts.Mount},
|
||||
{Name: "agents", Mount: agents.Mount, Shutdown: agents.Shutdown},
|
||||
// The unified AI login manager registry (/v1/links). Mounts AFTER agents so
|
||||
@@ -314,6 +321,11 @@ func Wire() []cloud.MountSpec {
|
||||
{Name: "graph", Mount: graph.Mount},
|
||||
{Name: "security", Mount: security.Mount, Shutdown: ctxShutdown(security.Shutdown), OwnsHealth: true},
|
||||
{Name: "integrations", Mount: integrations.Mount, Shutdown: integrations.Shutdown},
|
||||
// Per-org Cloudflare asset plane /v1/integrations/cloudflare/{pages,workers,r2,kv,d1}/*.
|
||||
// Mounts AFTER integrations because it reads the org's Cloudflare token through
|
||||
// the integrations custody seam (integrations.TokenFor) — one token, one
|
||||
// custody boundary. Stateless: no store, no shutdown.
|
||||
{Name: "cloudflare", Mount: cloudflare.Mount},
|
||||
{Name: "sbom", Mount: sbom.Mount, OwnsHealth: true},
|
||||
{Name: "team", Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
|
||||
{Name: "settings", Mount: settings.Mount, Shutdown: settings.Shutdown},
|
||||
@@ -336,23 +348,24 @@ func Wire() []cloud.MountSpec {
|
||||
{Name: "evals", Mount: eval.Mount},
|
||||
{Name: "treasury", Mount: treasury.Mount, Shutdown: ctxShutdown(treasury.Shutdown)},
|
||||
{Name: "admin", Mount: admin.Mount},
|
||||
// Launch-control (per-service waitlist mode) folded into the flags engine: the
|
||||
// mode IS the switch waitlist.<svc>, the board is the /v1/admin/services lens,
|
||||
// and /v1/featuregate/mode is served by flags. featuregate is no longer a mounted
|
||||
// subsystem — it exposes only the native Enforce middleware (wired in serve.go),
|
||||
// a consumer of flags.WaitlistModeForHost.
|
||||
// Launch-control gate (per-service waitlist): the COMPLETE feature — host→service
|
||||
// registry + brand seed + the waitlist.<svc> switch registration + the
|
||||
// /v1/flags/waitlist (and /v1/admission/mode compat) mode read + the Enforce
|
||||
// middleware — COMPOSING the flags engine one-way (flags.Bool/Register/
|
||||
// SetPlatformSwitch; flags never imports admission). Mounts AFTER flags so the
|
||||
// engine's platform-switch plane is installed first; the admin board is the
|
||||
// /v1/admin/services lens over it. Owns the registry store handle → Shutdown.
|
||||
{Name: "admission", Mount: admission.Mount, Shutdown: ctxShutdown(admission.Shutdown)},
|
||||
// Tasks: the durable workflow/UI surface AND platform cron (durable schedules
|
||||
// on the same shared engine, replacing every k8s CronJob). cron was a separate
|
||||
// Wire entry; it mounts no routes and only registers schedules, so it is folded
|
||||
// in as a sub-mount of tasks.Mount — ONE tasks subsystem.
|
||||
{Name: "tasks", Mount: tasks.Mount},
|
||||
// Platform cron: durable schedules on the shared tasks engine replacing
|
||||
// every k8s CronJob — entries are cron.hanzo.ai ConfigMaps (universe git),
|
||||
// runs visible in the Tasks console. Mounts no routes; starts after the
|
||||
// engine is wired.
|
||||
{Name: "cron", Mount: cron.Mount},
|
||||
// Automations: the connector catalogue + flow engine AND native single-connector
|
||||
// execution (POST /v1/automations/connectors/:id/run, HIP-0126). The connector
|
||||
// runner mounts no other routes, so it is folded in as a sub-mount of
|
||||
// automations.Mount (was a separate "connectorruntime" entry) — ONE subsystem.
|
||||
{Name: "automations", Mount: automations.Mount, Shutdown: automations.Shutdown},
|
||||
// Native single-connector execution (HIP-0126): runs an ActivePieces JS
|
||||
// connector action in-process via goja (clients/connectorruntime), retiring
|
||||
// the standalone auto Node engine. Mounts POST /v1/automations/connectors/:id/run,
|
||||
// paired with the automations catalogue above; STAGED like the rest.
|
||||
{Name: "connectorruntime", Mount: connectorruntime.Mount},
|
||||
// Unified tool plane: /v1/tools/* — the ONE registry (connectors, functions,
|
||||
// agents, skills, external MCP servers, full-cloud-control /v1 routes), per-org
|
||||
// activation, and the unified MCP endpoint. Sources register into it from their
|
||||
|
||||
@@ -21,15 +21,19 @@ import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/commerceclient"
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
financeclient "github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/commerce"
|
||||
commercebilling "github.com/hanzoai/commerce/api/billing"
|
||||
commercestore "github.com/hanzoai/commerce/api/store"
|
||||
commercedatastore "github.com/hanzoai/commerce/datastore"
|
||||
commercemid "github.com/hanzoai/commerce/middleware"
|
||||
"github.com/hanzoai/commerce/middleware/iammiddleware"
|
||||
commercensctx "github.com/hanzoai/commerce/util/nscontext"
|
||||
log "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
@@ -164,6 +168,19 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
|
||||
commerceinproc.SetApp(app)
|
||||
commerceclient.PublishEmbedded(embedded)
|
||||
|
||||
// Usage-cap enforcement on the FINANCE path. The unified binary records usage in
|
||||
// the finance ledger (fin.RecordUsage), NOT commerce's transaction store — which
|
||||
// it leaves empty — so the cap must read spend from, and fire alerts on, the
|
||||
// finance ledger. Two seams, both org-wide (the finance Entry carries no scope;
|
||||
// per-scope caps are a follow-up):
|
||||
// - SetPeriodSpendReader: AuthorizeSpendCap's scopeSpentCents reads the org's
|
||||
// finance period spend instead of the empty commerce transaction ledger, so
|
||||
// a real LLM request increments the cap's `spent` and trips the 402.
|
||||
// - SetUsageHook: after each finance debit, fire the org's spend-alerts on the
|
||||
// SAME crossing (the alert half), reading the same finance spend + debouncing.
|
||||
commercebilling.SetPeriodSpendReader(financePeriodSpend)
|
||||
financeclient.SetUsageHook(fireCapAlert)
|
||||
|
||||
lg.Info("commerce embedded natively (hanzoai/commerce module on the shared zip app)",
|
||||
"data_dir", dataDir,
|
||||
"brand", deps.Brand,
|
||||
@@ -215,3 +232,41 @@ func mountCommerceFailClosed(app *zip.App) {
|
||||
app.All(p+"/*", failed)
|
||||
}
|
||||
}
|
||||
|
||||
// financePeriodSpend is the usage-cap's period-spend source (injected into commerce
|
||||
// via SetPeriodSpendReader). It returns the org's finance-ledger usage in cents since
|
||||
// the start of the CURRENT UTC month — the window the cap resets on (mirrors
|
||||
// commerce periodStartUTC). Org-wide: the finance Entry carries no project/service,
|
||||
// so scope args are ignored and the org total is returned (what the covering
|
||||
// org-wide spend-alert row binds on). A finance impl without the sum capability, or a
|
||||
// split deploy (no co-resident finance), reports 0 — the cap can never over-count.
|
||||
func financePeriodSpend(ctx context.Context, org string, test bool, _, _ string) (int64, error) {
|
||||
fin := financeclient.Current()
|
||||
if fin == nil {
|
||||
return 0, nil
|
||||
}
|
||||
summer, ok := fin.(interface {
|
||||
SumUsageSince(context.Context, string, bool, int64) (int64, error)
|
||||
})
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
n := time.Now().UTC()
|
||||
since := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.UTC).Unix()
|
||||
return summer.SumUsageSince(ctx, org, test, since)
|
||||
}
|
||||
|
||||
// fireCapAlert fires the org's spend-alerts after a finance usage debit — the alert
|
||||
// half of the cap on the finance path (wired via finance.SetUsageHook). It resolves
|
||||
// the org's commerce datastore (where the spend-alert rows live) and calls the
|
||||
// exported commerce trigger, which reads the org's period spend via financePeriodSpend
|
||||
// and stamps/debounces. Detached + best-effort; never blocks the money path. Runs in
|
||||
// its own goroutine (the hook is invoked with `go`), so a background context is right.
|
||||
func fireCapAlert(org string, test bool, project, service string) {
|
||||
if strings.TrimSpace(org) == "" {
|
||||
return
|
||||
}
|
||||
ctx := commercensctx.WithNamespace(context.Background(), org)
|
||||
db := commercedatastore.New(ctx)
|
||||
commercebilling.FireSpendAlerts(ctx, db, org, test, project, service, nil)
|
||||
}
|
||||
|
||||
+87
-86
@@ -23,92 +23,93 @@ var frozen = []struct {
|
||||
ownsHealth bool
|
||||
hasShutdown bool
|
||||
}{
|
||||
{"pubsub", false, true}, // was order 5
|
||||
{"kafka", false, true}, // was order 6
|
||||
{"agentskills", false, false}, // was order 8
|
||||
{"flags", true, true}, // was order 9; native engine: /v1/flags health + store shutdown
|
||||
{"kms", true, false}, // was order 10
|
||||
{"metrics", false, false}, // was order 40
|
||||
{"ingress", false, true}, // was order 42
|
||||
{"account", false, false}, // was order 48
|
||||
{"iam", false, false}, // was order 50
|
||||
{"base", true, true}, // was order 60; per-org embed added Shutdown (#298)
|
||||
{"o11y", false, true}, // ONE observability subsystem (was co-owned orders 69+70): read plane + the hanzoai/o11y module wildcard folded in as MountO11y's terminal sub-mount. OwnsHealth=false keeps /v1/o11y/health the generic always-ok route the module co-entry used to trigger.
|
||||
{"authz", false, false}, // was order 70
|
||||
{"commerce", false, false}, // was order 100
|
||||
{"licensing", false, false}, // was order 110
|
||||
{"plans", true, false}, // was order 111
|
||||
{"pricing", true, false}, // was order 112
|
||||
{"storage", true, false}, // was order 118
|
||||
{"provisioning", false, false}, // was order 120
|
||||
{"billing", false, false}, // was order 121
|
||||
{"account-bridge", false, false}, // was order 122
|
||||
{"do", false, false}, // was order 123
|
||||
{"platform", true, false}, // was order 124
|
||||
{"projects", false, false}, // was order 125
|
||||
{"prompts", false, false}, // was order 126
|
||||
{"agents", false, true}, // was order 127
|
||||
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
|
||||
{"wallets", false, true}, // was order 127
|
||||
{"x402", false, true}, // new: x402 pay-per-use settlement (after wallets)
|
||||
{"paas", true, false}, // was order 128
|
||||
{"deploy", true, false}, // after paas (release seam), before functions
|
||||
{"functions", false, false}, // was order 128
|
||||
{"tracker", false, false}, // was order 129
|
||||
{"templates", false, false}, // was order 129
|
||||
{"framework", false, true}, // was order 129
|
||||
{"knowledge", false, false}, // was order 130
|
||||
{"content", false, true}, // new: marketing content loop (after knowledge)
|
||||
{"catalogsync", false, true}, // new: reverse loop (product.created → render) after content
|
||||
{"ml", true, false}, // was order 130
|
||||
{"usage", false, false}, // was order 131
|
||||
{"crm", false, false}, // was order 131
|
||||
{"marketing", false, true}, // new: marketing domain fold (after crm)
|
||||
{"ads", false, true}, // new: ads domain fold (after crm)
|
||||
{"social", false, true}, // new: /v1/social fold (after crm)
|
||||
{"analytics", true, false}, // was order 132
|
||||
{"git", false, false}, // was order 132
|
||||
{"sync", false, true}, // /v1/sync engine (owns per-org DB handles → Shutdown)
|
||||
{"visor", false, false}, // was order 133
|
||||
{"captable", false, true}, // was order 133
|
||||
{"code", false, true}, // was order 134
|
||||
{"zero-trust", false, false}, // was order 134
|
||||
{"dataroom", true, true}, // was order 134
|
||||
{"graph", false, false}, // was order 135
|
||||
{"security", true, true}, // was order 136
|
||||
{"integrations", false, true}, // was order 137
|
||||
{"sbom", true, false}, // was order 137
|
||||
{"team", false, true}, // was order 138
|
||||
{"settings", false, true}, // was order 138
|
||||
{"notify", true, false}, // was order 139
|
||||
{"gateway", false, false}, // was order 139
|
||||
{"entitlements", false, true}, // was order 139
|
||||
{"exec", false, false}, // was order 140
|
||||
{"websearch", false, false}, // was order 141
|
||||
{"world", false, true}, // was order 142
|
||||
{"runtime", false, false}, // was order 143; was "bot" until the transport was named for what it is
|
||||
{"authors", false, true}, // was order 143
|
||||
{"bots", false, false}, // was order 143
|
||||
{"audit", false, false}, // was order 144
|
||||
{"affiliates", false, false}, // was order 144
|
||||
{"sign", true, true}, // was order 145
|
||||
{"product", false, false}, // was order 145
|
||||
{"evals", false, false}, // was order 145
|
||||
{"treasury", false, true}, // was order 146
|
||||
{"admin", false, false}, // was order 146
|
||||
{"tasks", false, false}, // was order 147
|
||||
{"cron", false, false}, // durable platform cron on the shared engine (post-freeze add)
|
||||
{"automations", false, true}, // was order 148
|
||||
{"connectorruntime", false, false}, // new: native single-connector exec via goja (after automations, HIP-0126)
|
||||
{"tools", false, true}, // new: unified tool plane (after automations)
|
||||
{"marketplace", false, true}, // new: marketplace over the tool plane (after tools)
|
||||
{"referrals", false, false}, // was order 149
|
||||
{"guide", false, true}, // new: Business AI Guide (after referrals, before ai)
|
||||
{"company", false, true}, // new: Hanzo Company formation state machine (after guide)
|
||||
{"agent", false, false}, // new: /v1/agent tool-calling round (before zen/ai catch-all)
|
||||
{"zen", false, false}, // zen* claim middleware before ai's catch-all (hip-00NN)
|
||||
{"ai", false, false}, // was order 150
|
||||
{"plugins", false, false}, // was order 900
|
||||
{"pubsub", false, true}, // was order 5
|
||||
{"kafka", false, true}, // was order 6
|
||||
{"agentskills", false, false}, // was order 8
|
||||
{"flags", true, true}, // was order 9; native engine: /v1/flags health + store shutdown
|
||||
{"kms", true, false}, // was order 10
|
||||
{"metrics", false, false}, // was order 40
|
||||
{"ingress", false, true}, // was order 42
|
||||
{"account", false, false}, // was order 48
|
||||
{"iam", false, false}, // was order 50
|
||||
{"base", true, true}, // was order 60; per-org embed added Shutdown (#298)
|
||||
{"o11y", false, true}, // ONE observability subsystem (was co-owned orders 69+70): read plane + the hanzoai/o11y module wildcard folded in as MountO11y's terminal sub-mount. OwnsHealth=false keeps /v1/o11y/health the generic always-ok route the module co-entry used to trigger.
|
||||
{"authz", false, false}, // was order 70
|
||||
{"commerce", false, false}, // was order 100
|
||||
{"licensing", false, false}, // was order 110
|
||||
{"plan", true, false}, // was order 111; enable id normalized plans->plan (routes stay /v1/plans/*)
|
||||
{"pricing", true, false}, // was order 112
|
||||
{"storage", true, false}, // was order 118
|
||||
{"provisioning", false, false}, // was order 120
|
||||
{"billing", false, false}, // was order 121
|
||||
{"account-bridge", false, false}, // was order 122
|
||||
{"do", false, false}, // was order 123
|
||||
{"platform", true, false}, // was order 124
|
||||
{"projects", false, false}, // was order 125
|
||||
{"dns", false, false}, // new: /v1/dns zone plane (after projects)
|
||||
{"prompts", false, false}, // was order 126
|
||||
{"agents", false, true}, // was order 127
|
||||
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
|
||||
{"wallets", false, true}, // was order 127
|
||||
{"x402", false, true}, // new: x402 pay-per-use settlement (after wallets)
|
||||
{"paas", true, false}, // was order 128
|
||||
{"deploy", true, false}, // after paas (release seam), before functions
|
||||
{"functions", false, false}, // was order 128
|
||||
{"tracker", false, false}, // was order 129
|
||||
{"templates", false, false}, // was order 129
|
||||
{"framework", false, true}, // was order 129
|
||||
{"knowledge", false, false}, // was order 130
|
||||
{"content", false, true}, // new: marketing content loop (after knowledge)
|
||||
{"catalogsync", false, true}, // new: reverse loop (product.created → render) after content
|
||||
{"ml", true, false}, // was order 130
|
||||
{"usage", false, false}, // was order 131
|
||||
{"crm", false, false}, // was order 131
|
||||
{"marketing", false, true}, // new: marketing domain fold (after crm)
|
||||
{"ads", false, true}, // new: ads domain fold (after crm)
|
||||
{"social", false, true}, // new: /v1/social fold (after crm)
|
||||
{"analytics", true, false}, // was order 132
|
||||
{"git", false, false}, // was order 132
|
||||
{"sync", false, true}, // /v1/sync engine (owns per-org DB handles → Shutdown)
|
||||
{"visor", false, false}, // was order 133
|
||||
{"captable", false, true}, // was order 133
|
||||
{"code", false, true}, // was order 134
|
||||
{"zero-trust", false, false}, // was order 134
|
||||
{"dataroom", true, true}, // was order 134
|
||||
{"graph", false, false}, // was order 135
|
||||
{"security", true, true}, // was order 136
|
||||
{"integrations", false, true}, // was order 137
|
||||
{"cloudflare", false, false}, // new: /v1/cloudflare edge plane (after integrations)
|
||||
{"sbom", true, false}, // was order 137
|
||||
{"team", false, true}, // was order 138
|
||||
{"settings", false, true}, // was order 138
|
||||
{"notify", true, false}, // was order 139
|
||||
{"gateway", false, false}, // was order 139
|
||||
{"entitlements", false, true}, // was order 139
|
||||
{"exec", false, false}, // was order 140
|
||||
{"websearch", false, false}, // was order 141
|
||||
{"world", false, true}, // was order 142
|
||||
{"runtime", false, false}, // was order 143; was "bot" until the transport was named for what it is
|
||||
{"authors", false, true}, // was order 143
|
||||
{"bots", false, false}, // was order 143
|
||||
{"audit", false, false}, // was order 144
|
||||
{"affiliates", false, false}, // was order 144
|
||||
{"sign", true, true}, // was order 145
|
||||
{"product", false, false}, // was order 145
|
||||
{"evals", false, false}, // was order 145
|
||||
{"treasury", false, true}, // was order 146
|
||||
{"admin", false, false}, // was order 146
|
||||
{"admission", false, true}, // launch-control gate: composes flags (registry+seed+mode route+Enforce); Shutdown closes the registry store
|
||||
{"tasks", false, false}, // was order 147; platform cron folded in as a sub-mount of tasks.Mount (was a separate entry)
|
||||
{"automations", false, true}, // was order 148; connectorruntime (POST /v1/automations/connectors/:id/run) folded in as a sub-mount of automations.Mount
|
||||
{"tools", false, true}, // new: unified tool plane (after automations)
|
||||
{"marketplace", false, true}, // new: marketplace over the tool plane (after tools)
|
||||
{"referrals", false, false}, // was order 149
|
||||
{"guide", false, true}, // new: Business AI Guide (after referrals, before ai)
|
||||
{"company", false, true}, // new: Hanzo Company formation state machine (after guide)
|
||||
{"agent", false, false}, // new: /v1/agent tool-calling round (before zen/ai catch-all)
|
||||
{"zen", false, false}, // zen* claim middleware before ai's catch-all (hip-00NN)
|
||||
{"ai", false, false}, // was order 150
|
||||
{"plugins", false, false}, // was order 900
|
||||
}
|
||||
|
||||
// TestWireOrderMatchesFrozen proves the composition root's mount order is
|
||||
|
||||
+50
-4
@@ -37,10 +37,10 @@ type keyResolver interface {
|
||||
// A brief cache keeps the hot auth path off the network; it caches misses too, so a
|
||||
// bad key cannot hammer IAM.
|
||||
type iamKeys struct {
|
||||
base string
|
||||
auth string // client_secret_basic, or "" when unconfigured
|
||||
http *http.Client
|
||||
cache cache[string, *idClaims]
|
||||
base string
|
||||
auth string // client_secret_basic, or "" when unconfigured
|
||||
http *http.Client
|
||||
cache cache[string, *idClaims]
|
||||
}
|
||||
|
||||
// newIAMKeys reads the same IAM env clients/account does. With no confidential
|
||||
@@ -55,6 +55,52 @@ func newIAMKeys() *iamKeys {
|
||||
}
|
||||
}
|
||||
|
||||
// sharedKeys memoizes ONE API-key resolver (and its 60s cache) for the whole
|
||||
// binary. The identity boundary (SanitizeIdentity, via newIdentityValidator) and
|
||||
// any subsystem that must resolve a key OUT-OF-BAND of the Authorization header
|
||||
// (analytics capture: a project key posted in the SDK body/query) both go through
|
||||
// this ONE seam, so a key resolves to the SAME org either way and IAM sees one
|
||||
// warm cache — never a second, drifting resolver.
|
||||
var (
|
||||
sharedKeysOnce sync.Once
|
||||
sharedKeysInst *iamKeys
|
||||
)
|
||||
|
||||
func sharedKeys() *iamKeys {
|
||||
sharedKeysOnce.Do(func() { sharedKeysInst = newIAMKeys() })
|
||||
return sharedKeysInst
|
||||
}
|
||||
|
||||
// maxKeyOrgLen bounds a resolved org key the same way principal.MaxOrgLen does: the
|
||||
// org becomes a warehouse partition key, so an over-long value (malformed / hostile)
|
||||
// is refused rather than stored.
|
||||
const maxKeyOrgLen = 128
|
||||
|
||||
// OrgForKey resolves an opaque Hanzo API key (hk-/sk-/pk-/fw_/hz_) to the org it
|
||||
// belongs to — the SAME owner org SanitizeIdentity mints when that key arrives as a
|
||||
// bearer — through the ONE IAM key seam (get-user?accessKey). It is the exported
|
||||
// door a keyed, bearer-less SDK path uses to attribute a project key to a tenant.
|
||||
//
|
||||
// FAILS CLOSED: ("", false) for a non-key-shaped string, an unknown/unresolvable
|
||||
// key, an unconfigured resolver, or an out-of-bounds org — never a fabricated or
|
||||
// default tenant, so a bad key can never be written into another org's partition.
|
||||
// The isAPIKey prefix gate keeps garbage strings off the IAM network path.
|
||||
func OrgForKey(ctx context.Context, key string) (string, bool) {
|
||||
key = strings.TrimSpace(key)
|
||||
if !isAPIKey(key) {
|
||||
return "", false
|
||||
}
|
||||
claims := sharedKeys().resolve(ctx, key)
|
||||
if claims == nil {
|
||||
return "", false
|
||||
}
|
||||
owner := strings.TrimSpace(claims.Owner)
|
||||
if owner == "" || len(owner) > maxKeyOrgLen {
|
||||
return "", false
|
||||
}
|
||||
return owner, true
|
||||
}
|
||||
|
||||
// iamHost is the standalone IAM origin cloud talks to; iamCred is the service
|
||||
// credential (client_secret_basic) it presents — the ONE IAM identity, shared by
|
||||
// the API-key resolver here and the /v1/iam edge (iam_edge.go), so both
|
||||
|
||||
+36
-15
@@ -42,6 +42,7 @@ type idClaims struct {
|
||||
|
||||
Owner string `json:"owner"` // org slug (the org)
|
||||
Project string `json:"project"` // org SUB-SCOPE within owner (empty ⟹ default project)
|
||||
BillingAccount string `json:"billing_account"` // WHO PAYS, stated by IAM (empty ⟹ pre-claim token)
|
||||
Name string `json:"name"` // display name (id fallback)
|
||||
PreferredUsername string `json:"preferred_username"` // id fallback
|
||||
Email string `json:"email"`
|
||||
@@ -62,6 +63,20 @@ func (c *idClaims) mintedProject() string {
|
||||
return strings.TrimSpace(c.Project)
|
||||
}
|
||||
|
||||
// mintedBillingAccount returns the funding account to stamp into
|
||||
// X-Billing-Account-Id, or "" when the header must be OMITTED (a token minted
|
||||
// before IAM shipped the claim, or one IAM could not attribute).
|
||||
//
|
||||
// WHO PAYS IS NOT A CLIENT'S TO NAME. This rides the validated `billing_account`
|
||||
// claim — IAM's signed statement, resolved at the identity boundary from the real
|
||||
// grant context — exactly like `owner` and `project`. It mirrors the edge
|
||||
// (iamauth.Claims.MintedBillingAccount) byte-for-byte, so the in-binary path binds
|
||||
// the same header the gateway would, and ai/object.Payer reads the same payer on
|
||||
// both. The raw client copy is deleted on ingress and NEVER restored.
|
||||
func (c *idClaims) mintedBillingAccount() string {
|
||||
return strings.TrimSpace(c.BillingAccount)
|
||||
}
|
||||
|
||||
// userID resolves the canonical user id: sub, then preferred_username, then
|
||||
// name. IAM may leave sub empty. This is the STABLE identifier (a UUID when IAM
|
||||
// sets sub) stamped as X-User-Id and consumed as the attribution key everywhere.
|
||||
@@ -123,7 +138,7 @@ func newIdentityValidator(issuer, jwksURL string, audiences []string, ttl time.D
|
||||
issuers: trustedIssuers(issuer),
|
||||
audiences: audiences,
|
||||
cache: newJWKSCache(jwksURL, ttl),
|
||||
keys: newIAMKeys(),
|
||||
keys: sharedKeys(), // ONE resolver+cache, shared with OrgForKey (analytics capture)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +212,16 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fail SECURE on a misconfigured (empty) trust set: an empty issuer OR audience
|
||||
// allowlist must REJECT every token, never silently disable that axis. In
|
||||
// production both are always resolved non-empty (BrandIssuers + the baked
|
||||
// audience defaults, unioned in config.go so they are "never empty"), so this
|
||||
// fires ONLY on an operator misconfiguration (CLOUD_JWT_AUDIENCES="" emptying the
|
||||
// resolved set, or an empty issuer set) — and then it denies, it never admits (I2).
|
||||
if len(v.issuers) == 0 || len(v.audiences) == 0 {
|
||||
return nil, fmt.Errorf("identity validator misconfigured: empty issuer or audience allowlist")
|
||||
}
|
||||
|
||||
// Reject a missing issuer: an empty issuer must never pass the set check.
|
||||
if claims.Issuer == "" {
|
||||
return nil, fmt.Errorf("missing issuer")
|
||||
@@ -221,14 +246,13 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
|
||||
// gates on owner == :org. Without this, a real client_credentials machine token
|
||||
// (aud == its per-org clientId, never in the allowlist) fails here and the
|
||||
// sync silently stays pending — the activation blocker.
|
||||
expected := jwt.Expected{}
|
||||
if len(v.audiences) > 0 {
|
||||
auds := v.audiences
|
||||
if mach := kmsMachineAudience(claims.Owner); mach != "" {
|
||||
auds = append(append(make([]string, 0, len(v.audiences)+1), v.audiences...), mach)
|
||||
}
|
||||
expected.AnyAudience = jwt.Audience(auds)
|
||||
// The audience allowlist is guaranteed non-empty (checked above), so the
|
||||
// audience axis is ALWAYS enforced — never silently skipped.
|
||||
auds := v.audiences
|
||||
if mach := kmsMachineAudience(claims.Owner); mach != "" {
|
||||
auds = append(append(make([]string, 0, len(v.audiences)+1), v.audiences...), mach)
|
||||
}
|
||||
expected := jwt.Expected{AnyAudience: jwt.Audience(auds)}
|
||||
if err := claims.Claims.ValidateWithLeeway(expected, 2*time.Minute); err != nil {
|
||||
return nil, fmt.Errorf("claims: %w", err)
|
||||
}
|
||||
@@ -425,14 +449,11 @@ func trustedIssuers(primary string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// issuerAllowed reports whether iss is one of the trusted issuers. An empty set
|
||||
// (no primary, no brands — never the case in production) skips the check, matching
|
||||
// the prior "empty issuer disables the check" behavior; a non-empty set is
|
||||
// fail-secure (a token whose iss is not in the set is rejected).
|
||||
// issuerAllowed reports whether iss is one of the trusted issuers. It is
|
||||
// fail-secure in BOTH directions: an empty trusted set matches NOTHING (deny), so
|
||||
// a misconfiguration that empties the issuer allowlist rejects every token instead
|
||||
// of silently disabling the check (I2); a non-empty set rejects any iss not in it.
|
||||
func issuerAllowed(iss string, trusted []string) bool {
|
||||
if len(trusted) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, t := range trusted {
|
||||
if iss == t {
|
||||
return true
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestValidate_FailSecureOnEmptyTrustSet proves I2: a validator whose resolved
|
||||
// issuer OR audience allowlist is empty REJECTS an otherwise-valid, correctly
|
||||
// signed token — the axis is never silently disabled. Production always resolves
|
||||
// non-empty sets; this guards the misconfiguration path (CLOUD_JWT_AUDIENCES=""
|
||||
// or an empty issuer set), which must fail closed, not open.
|
||||
func TestValidate_FailSecureOnEmptyTrustSet(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("genkey: %v", err)
|
||||
}
|
||||
jwks := jwksServer(t, &key.PublicKey)
|
||||
future := time.Now().Add(time.Hour)
|
||||
tok := signWith(t, key, tokenClaims("hanzo-console", "acme", "", false, future))
|
||||
|
||||
// Sanity: a properly configured validator accepts the token.
|
||||
if _, err := newIdentityValidator(testIssuer, jwks.URL, []string{"hanzo-console"}, 0).validate(tok); err != nil {
|
||||
t.Fatalf("baseline valid token must be accepted, got %v", err)
|
||||
}
|
||||
|
||||
// Empty audience set → deny.
|
||||
if _, err := newIdentityValidator(testIssuer, jwks.URL, nil, 0).validate(tok); err == nil {
|
||||
t.Error("empty audience allowlist must REJECT (fail-secure), not accept")
|
||||
}
|
||||
|
||||
// Empty issuer set → deny (construct directly; trustedIssuers never yields empty
|
||||
// with a primary, so bypass it to exercise the guard).
|
||||
vEmptyIss := &identityValidator{issuers: nil, audiences: []string{"hanzo-console"}, cache: newJWKSCache(jwks.URL, 0), keys: newIAMKeys()}
|
||||
if _, err := vEmptyIss.validate(tok); err == nil {
|
||||
t.Error("empty issuer allowlist must REJECT (fail-secure), not accept")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustedIssuers_WhiteLabel proves the in-binary validator's trusted-issuer
|
||||
// set is the primary issuer UNIONED with every white-label brand issuer plus the
|
||||
// WHITELABEL_ISSUERS override, deduped, primary-first.
|
||||
@@ -40,7 +75,8 @@ func TestTrustedIssuers_WhiteLabel(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIssuerAllowed proves the set membership check: brand issuers pass, an
|
||||
// outsider is rejected, and an empty set (never in prod) skips the check.
|
||||
// outsider is rejected, and an empty set is fail-secure — it matches NOTHING (I2),
|
||||
// so a misconfiguration that empties the allowlist denies every token.
|
||||
func TestIssuerAllowed(t *testing.T) {
|
||||
set := []string{"https://hanzo.id", "https://lux.id"}
|
||||
if !issuerAllowed("https://lux.id", set) {
|
||||
@@ -49,8 +85,8 @@ func TestIssuerAllowed(t *testing.T) {
|
||||
if issuerAllowed("https://attacker.id", set) {
|
||||
t.Error("attacker.id must be rejected")
|
||||
}
|
||||
if !issuerAllowed("anything", nil) {
|
||||
t.Error("empty set must skip the check (matches prior empty-issuer behavior)")
|
||||
if issuerAllowed("anything", nil) {
|
||||
t.Error("empty set must DENY (fail-secure), never skip the check")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/hanzoai/cloud/clients"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/gatewaypolicy"
|
||||
"github.com/hanzoai/cloud/clients/gateway/edge"
|
||||
"github.com/hanzoai/cloud/clients/money"
|
||||
"github.com/hanzoai/cloud/clients/s3admin"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
@@ -90,6 +90,7 @@ func BuildDeps(cfg *Config) Deps {
|
||||
// commerce URL yields a !Enabled() client, so the wrap is a transparent
|
||||
// pass-through and a dev deployment is never blocked.
|
||||
deps.Metering = buildMeteringClient(cfg, logger)
|
||||
wireTierReader(deps.Metering, logger)
|
||||
deps.AI = meteredAIClient(pickAIClient(cfg, logger), deps)
|
||||
wireFinance(cfg, logger)
|
||||
deps.O11y = pick(cfg, logger, "o11y", "O11y", cfg.O11yZAPAddr, clients.O11yRPCAt, clients.DisabledO11y)
|
||||
@@ -107,7 +108,7 @@ func BuildDeps(cfg *Config) Deps {
|
||||
// working *Store (static-only if the SQLite file can't open), so the edge
|
||||
// middleware is never left without a policy source — a store-open error is
|
||||
// logged, not fatal.
|
||||
gp, err := gatewaypolicy.New(cfg.DataDir, cfg.AdminOrg, staticEdgePolicy(cfg))
|
||||
gp, err := edge.New(cfg.DataDir, cfg.AdminOrg, staticEdgePolicy(cfg))
|
||||
if err != nil {
|
||||
logger.Warn("gateway policy store degraded to static-only", "err", err)
|
||||
}
|
||||
@@ -117,10 +118,10 @@ func BuildDeps(cfg *Config) Deps {
|
||||
}
|
||||
|
||||
// staticEdgePolicy projects the static env/flag edge config into the boot-default
|
||||
// policy the gatewaypolicy.Store layers runtime overrides on top of. A disabled
|
||||
// policy the edge.Store layers runtime overrides on top of. A disabled
|
||||
// per-IP limiter (CLOUD_EDGE_RATELIMIT=false) maps to PerIPRPM 0 (a live no-op).
|
||||
func staticEdgePolicy(cfg *Config) gatewaypolicy.Policy {
|
||||
p := gatewaypolicy.Policy{
|
||||
func staticEdgePolicy(cfg *Config) edge.Policy {
|
||||
p := edge.Policy{
|
||||
CORSOrigins: cfg.CORSOrigins,
|
||||
WindowSec: cfg.EdgeRateWindowSec,
|
||||
}
|
||||
@@ -157,9 +158,15 @@ func buildMeteringClient(cfg *Config, log luxlog.Logger) *metering.Client {
|
||||
httpClient = commerceinproc.Client(0) // in-process dispatch; no network timeout
|
||||
}
|
||||
m, err := metering.New(metering.Config{
|
||||
BaseURL: base,
|
||||
Token: cfg.CommerceServiceToken,
|
||||
Org: cfg.Brand, // X-Org-Id default for S2S; per-request org overrides.
|
||||
BaseURL: base,
|
||||
Token: cfg.CommerceServiceToken,
|
||||
Org: cfg.Brand, // X-Org-Id default for S2S; per-request org overrides.
|
||||
// Honor the documented METERING_TEST env: when "true", route every debit to
|
||||
// commerce's TEST/sandbox books (fin.RecordUsage in.Test=true) so a staging /
|
||||
// canary deployment records NO real money — and the usage-cap read (org.TestMode
|
||||
// via SQUARE_ENVIRONMENT=sandbox) sees the SAME test books. Unset in prod → live,
|
||||
// unchanged. Without this the flag was silently ignored (always live).
|
||||
Test: strings.EqualFold(strings.TrimSpace(os.Getenv(metering.EnvTest)), "true"),
|
||||
FailOpen: cfg.BillingFailOpen,
|
||||
HTTPClient: httpClient, // nil off the co-resident path → metering builds its own
|
||||
})
|
||||
@@ -169,6 +176,11 @@ func buildMeteringClient(cfg *Config, log luxlog.Logger) *metering.Client {
|
||||
log.Error("billing: invalid commerce URL, gate disabled", "err", err)
|
||||
m, _ = metering.New(metering.Config{})
|
||||
}
|
||||
// Observe every cap-check fail-open (timeout / slow / broken commerce) — a cap that
|
||||
// silently allows must never be silent. The completion still proceeds (fail-open).
|
||||
metering.OnCapError = func(err error) {
|
||||
log.Warn("spend-cap check failed open (allowing completion) — commerce authorize slow/unavailable", "err", err)
|
||||
}
|
||||
if m.Enabled() {
|
||||
log.Info("billing gate enabled", "commerce", boolStr(inProcess, "in-process", "http:"+base), "fail_open", cfg.BillingFailOpen)
|
||||
} else {
|
||||
@@ -184,6 +196,28 @@ func boolStr(b bool, t, f string) string {
|
||||
return f
|
||||
}
|
||||
|
||||
// wireTierReader installs the embedded ai module's per-tier SKU gate reader so it
|
||||
// resolves the caller's commerce subscription tier through the SAME co-resident
|
||||
// commerce client the metering gate bills over — in-process (commerceinproc) when
|
||||
// commerce is folded in, S2S HTTP with the service token otherwise — NEVER an authed
|
||||
// self-call to the cloud edge. That self-call is the toothless-gate bug: the edge
|
||||
// 401/403s a service call to /v1/billing/*, so the ai module's own HTTP lookup always
|
||||
// returned "" in-cluster and every tier-gated SKU failed OPEN. This mirrors
|
||||
// wireFinance's SetBalanceReader: cloud owns the co-resident read, ai stays
|
||||
// transport-agnostic. Fail-safe is preserved — Client.Tier folds a commerce error or
|
||||
// an unknown plan to "", which the gate treats as ALLOW, so a commerce blip never
|
||||
// locks out a paying caller. No-op when commerce is unreachable (metering !Enabled),
|
||||
// leaving ai's standalone HTTP fallback in place.
|
||||
func wireTierReader(m *metering.Client, log luxlog.Logger) {
|
||||
if m == nil || !m.Enabled() {
|
||||
return
|
||||
}
|
||||
aiobject.SetTierReader(func(ctx context.Context, subject, namespace string) (string, error) {
|
||||
return m.Tier(ctx, subject, namespace)
|
||||
})
|
||||
log.Info("ai per-tier SKU gate wired to co-resident commerce (in-process tier read, fail-safe)")
|
||||
}
|
||||
|
||||
// wireFinance constructs the ONE in-process finance ledger (per-org SQLite
|
||||
// double-entry prepaid wallet), publishes it for every money consumer to resolve by
|
||||
// the narrow finance.Client, and installs the embedded ai router's balance-read +
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// CallerBearer relays the caller's OWN validated JWT bearer and nothing else: a JWT
|
||||
// passes through unchanged, an opaque API key is not relayable, and no credential
|
||||
// yields "". This is the token a downstream org-scoped service (the DNS forward
|
||||
// head) re-validates to enforce tenant isolation across the hop.
|
||||
func TestCallerBearer(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
app.Get("/probe", func(c *zip.Ctx) error { return c.Bytes(200, []byte(CallerBearer(c))) })
|
||||
|
||||
probe := func(setup func(*http.Request)) string {
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
if setup != nil {
|
||||
setup(req)
|
||||
}
|
||||
res, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
return string(b)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(*http.Request)
|
||||
want string
|
||||
}{
|
||||
{"jwt bearer relayed unchanged", func(r *http.Request) { r.Header.Set("Authorization", "Bearer jwt.header.sig") }, "jwt.header.sig"},
|
||||
{"X-Authorization fallback", func(r *http.Request) { r.Header.Set("X-Authorization", "Bearer x.y.z") }, "x.y.z"},
|
||||
{"opaque hk- api key is NOT relayable", func(r *http.Request) { r.Header.Set("Authorization", "Bearer hk-secret") }, ""},
|
||||
{"opaque sk- api key is NOT relayable", func(r *http.Request) { r.Header.Set("Authorization", "Bearer sk-secret") }, ""},
|
||||
{"no credential yields empty", nil, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := probe(c.setup); got != c.want {
|
||||
t.Errorf("%s: CallerBearer = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-8
@@ -474,11 +474,14 @@ func (e *Env) freshAccessToken() string {
|
||||
return tok
|
||||
}
|
||||
|
||||
// platformToken resolves the platform control-plane service token. The
|
||||
// platform REST surface is machine-to-machine (it cannot validate IAM user
|
||||
// tokens), so apps/clusters/redeploy authenticate with this, sourced from
|
||||
// (in precedence) the bound --platform-token flag, the environment, then the
|
||||
// credential store. Never hardcoded.
|
||||
// platformToken resolves the bearer the platform control plane authenticates
|
||||
// apps/clusters/redeploy with. ONE identity authorizes everything: after a plain
|
||||
// `hanzo login` the IAM access token is the FINAL fallback, so no separate
|
||||
// --platform-token is needed — the platform verifies the IAM JWT (signature,
|
||||
// issuer, expiry) and org-scopes the caller. A dedicated service token still
|
||||
// wins when present (flag > env > credential store > IAM login), so purpose-minted
|
||||
// machine tokens keep their precedence and internal automation is unchanged.
|
||||
// Never hardcoded.
|
||||
func (e *Env) platformToken(flagVal string) string {
|
||||
return firstNonEmpty(
|
||||
flagVal,
|
||||
@@ -486,17 +489,22 @@ func (e *Env) platformToken(flagVal string) string {
|
||||
os.Getenv("PLATFORM_SERVICE_TOKEN"),
|
||||
os.Getenv("PAAS_SERVICE_TOKEN"),
|
||||
e.creds.PlatformToken,
|
||||
e.accessToken(), // IAM login is the one identity that authorizes control-plane ops
|
||||
)
|
||||
}
|
||||
|
||||
// buildToken resolves the platform build-enqueue token (a distinct credential
|
||||
// from the service token — see /v1/runner).
|
||||
// buildToken resolves the bearer `hanzo build` sends to the platform build
|
||||
// enqueue (/v1/runner). Same unify-infra contract as platformToken: a dedicated
|
||||
// build token wins when present, but a plain IAM login is the FINAL fallback, so
|
||||
// `hanzo build` works off the one identity with no separate --build-token — the
|
||||
// platform verifies the IAM JWT and authorizes the build by org + role.
|
||||
func (e *Env) buildToken(flagVal string) string {
|
||||
return firstNonEmpty(
|
||||
flagVal,
|
||||
os.Getenv("HANZO_BUILD_TOKEN"),
|
||||
os.Getenv("PLATFORM_BUILD_CALLBACK_TOKEN"),
|
||||
e.creds.BuildToken,
|
||||
e.accessToken(), // IAM login is the one identity that authorizes builds
|
||||
)
|
||||
}
|
||||
|
||||
@@ -588,7 +596,6 @@ func newRootCmd() *cobra.Command {
|
||||
newDeployCmd(envOf, &f),
|
||||
newClustersCmd(envOf, &f),
|
||||
newBuildCmd(envOf, &f),
|
||||
newK8sCmd(envOf, &f),
|
||||
newConfigCmd(),
|
||||
newSecurityCmd(envOf),
|
||||
newGPUCmd(envOf, &f),
|
||||
|
||||
@@ -141,6 +141,29 @@ func TestPlatformTokenPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformTokenFallsBackToIAM is the UNIFY-INFRA contract for the control
|
||||
// plane: after a plain `hanzo login`, the IAM access token is the FINAL fallback
|
||||
// so `hanzo apps`/`hanzo deploy` authorize off the one identity. An explicit
|
||||
// platform service token (creds/env/flag) still wins.
|
||||
func TestPlatformTokenFallsBackToIAM(t *testing.T) {
|
||||
sandbox(t)
|
||||
// Only an IAM login: no platform token anywhere ⇒ the IAM access token is sent.
|
||||
e := resolve(&Config{}, &Credentials{AccessToken: "iam-jwt"}, globalFlags{})
|
||||
if got := e.platformToken(""); got != "iam-jwt" {
|
||||
t.Fatalf("IAM access token should be the final platform-token fallback: %q", got)
|
||||
}
|
||||
// A dedicated platform service token still beats the IAM token.
|
||||
e = resolve(&Config{}, &Credentials{AccessToken: "iam-jwt", PlatformToken: "svc"}, globalFlags{})
|
||||
if got := e.platformToken(""); got != "svc" {
|
||||
t.Fatalf("dedicated platform token must beat the IAM fallback: %q", got)
|
||||
}
|
||||
// No login at all ⇒ empty (caller surfaces "run `hanzo login`").
|
||||
e = resolve(&Config{}, &Credentials{}, globalFlags{})
|
||||
if got := e.platformToken(""); got != "" {
|
||||
t.Fatalf("no token and no login should resolve empty: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTokenPrecedence(t *testing.T) {
|
||||
sandbox(t)
|
||||
e := resolve(&Config{}, &Credentials{BuildToken: "creds"}, globalFlags{})
|
||||
@@ -156,6 +179,37 @@ func TestBuildTokenPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTokenFallsBackToIAM is the UNIFY-INFRA contract: after a plain
|
||||
// `hanzo login` (no --build-token), the IAM access token is the FINAL fallback,
|
||||
// so `hanzo build` authorizes off the one identity. An explicit build token
|
||||
// (creds/env/flag) still wins — the IAM token is the LAST resort, never an
|
||||
// override of a purpose-minted machine token.
|
||||
func TestBuildTokenFallsBackToIAM(t *testing.T) {
|
||||
sandbox(t)
|
||||
// Only an IAM login: no build token anywhere ⇒ the IAM access token is sent.
|
||||
e := resolve(&Config{}, &Credentials{AccessToken: "iam-jwt"}, globalFlags{})
|
||||
if got := e.buildToken(""); got != "iam-jwt" {
|
||||
t.Fatalf("IAM access token should be the final build-token fallback: %q", got)
|
||||
}
|
||||
// A dedicated build token still beats the IAM token (precedence preserved).
|
||||
e = resolve(&Config{}, &Credentials{AccessToken: "iam-jwt", BuildToken: "creds"}, globalFlags{})
|
||||
if got := e.buildToken(""); got != "creds" {
|
||||
t.Fatalf("dedicated build token must beat the IAM fallback: %q", got)
|
||||
}
|
||||
// HANZO_TOKEN (the env form of the IAM token) is also honored via accessToken().
|
||||
e = resolve(&Config{}, &Credentials{}, globalFlags{})
|
||||
t.Setenv("HANZO_TOKEN", "iam-env")
|
||||
if got := e.buildToken(""); got != "iam-env" {
|
||||
t.Fatalf("HANZO_TOKEN should back the build-token fallback: %q", got)
|
||||
}
|
||||
// No login at all ⇒ empty, so the caller can surface "run `hanzo login`".
|
||||
t.Setenv("HANZO_TOKEN", "")
|
||||
e = resolve(&Config{}, &Credentials{}, globalFlags{})
|
||||
if got := e.buildToken(""); got != "" {
|
||||
t.Fatalf("no token and no login should resolve empty: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTokenFromEnvOverCreds(t *testing.T) {
|
||||
sandbox(t)
|
||||
e := resolve(&Config{}, &Credentials{AccessToken: "creds"}, globalFlags{})
|
||||
|
||||
+64
-22
@@ -147,6 +147,7 @@ type codeAgent struct {
|
||||
bin string // executable to exec
|
||||
wire wire // how it finds the cloud
|
||||
fullAuto []string // flags that bypass approval prompts
|
||||
continueArgs []string // harness-native form of Hanzo -c/--continue
|
||||
modelArg []string // how the model is passed on argv (empty: via env)
|
||||
carrier func(model string) string // maps the resolved model to a client-recognized id (claude: zen→carrier); nil = pass through
|
||||
provider func(base string) []string // agents that need the endpoint declared, not just env'd
|
||||
@@ -163,10 +164,11 @@ type codeAgent struct {
|
||||
// declared, so declare Hanzo as the provider and select it.
|
||||
func codexLike(bin, install string) codeAgent {
|
||||
return codeAgent{
|
||||
bin: bin,
|
||||
wire: openaiWire,
|
||||
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
|
||||
modelArg: []string{"-m"},
|
||||
bin: bin,
|
||||
wire: openaiWire,
|
||||
fullAuto: []string{"--dangerously-bypass-approvals-and-sandbox"},
|
||||
continueArgs: []string{"resume", "--last"},
|
||||
modelArg: []string{"-m"},
|
||||
provider: func(base string) []string {
|
||||
return []string{
|
||||
"-c", "model_provider=hanzo",
|
||||
@@ -174,6 +176,12 @@ func codexLike(bin, install string) codeAgent {
|
||||
"-c", fmt.Sprintf(`model_providers.hanzo.base_url="%s/v1"`, strings.TrimSuffix(base, "/")),
|
||||
"-c", `model_providers.hanzo.env_key="OPENAI_API_KEY"`,
|
||||
"-c", `model_providers.hanzo.wire_api="responses"`,
|
||||
// api.hanzo.ai exposes the standard OpenAI /v1/models shape,
|
||||
// not Codex's private remote model-catalog schema. Skip that
|
||||
// optional refresh and supply the coding model's metadata here.
|
||||
"-c", `features.remote_models=false`,
|
||||
"-c", `model_context_window=262144`,
|
||||
"-c", `model_auto_compact_token_limit=235929`,
|
||||
}
|
||||
},
|
||||
install: install,
|
||||
@@ -190,9 +198,10 @@ const zenIdentityPrompt = "You are running through the Hanzo AI cloud as a Hanzo
|
||||
|
||||
var codeAgents = map[string]codeAgent{
|
||||
"claude": {
|
||||
bin: "claude",
|
||||
wire: anthropicWire,
|
||||
fullAuto: []string{"--dangerously-skip-permissions"},
|
||||
bin: "claude",
|
||||
wire: anthropicWire,
|
||||
fullAuto: []string{"--dangerously-skip-permissions"},
|
||||
continueArgs: []string{"--continue"},
|
||||
// --model forces the session model on argv. Claude Code persists the
|
||||
// user's last /model selection (e.g. the reserved word "best"), and that
|
||||
// persisted choice OVERRIDES ANTHROPIC_MODEL — so the env var alone cannot
|
||||
@@ -246,9 +255,12 @@ func newCodeCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
Long: "Run @hanzo/dev, Claude Code, or Codex against api.hanzo.ai with the endpoint,\n" +
|
||||
"credential and model injected — no env vars to remember. `hanzo code` alone runs\n" +
|
||||
"dev (the Hanzo agent); name an agent to pick another. Model ids resolve fuzzily\n" +
|
||||
"(glm5.2 -> glm-5.2) and agents run full-auto unless you pass --safe.",
|
||||
"(glm5.2 -> glm-5.2), -c resumes either harness, and agents run full-auto unless\n" +
|
||||
"you pass --safe. Unknown options pass through; -- forces verbatim passthrough.",
|
||||
Example: " hanzo code # dev, the default agent\n" +
|
||||
" hanzo code claude\n" +
|
||||
" hanzo code claude -c\n" +
|
||||
" hanzo code codex -c\n" +
|
||||
" hanzo code codex deepseek-v4-pro\n" +
|
||||
" hanzo code dev glm5.2 -- --resume\n" +
|
||||
" hanzo code ls",
|
||||
@@ -320,19 +332,10 @@ func runCode(env *Env, agent codeAgent, args []string) error {
|
||||
}
|
||||
base := strings.TrimSuffix(firstNonEmpty(env.CloudURL, "https://api.hanzo.ai"), "/")
|
||||
|
||||
// First non-flag arg is the model; --safe is ours; the rest is the agent's.
|
||||
model, safe, rest := "", false, make([]string, 0, len(args))
|
||||
for _, a := range args {
|
||||
switch {
|
||||
case a == "--": // the separator is ours; the agent must not see it
|
||||
case a == "--safe" || a == "--ask":
|
||||
safe = true
|
||||
case model == "" && !strings.HasPrefix(a, "-") && len(rest) == 0:
|
||||
model = a
|
||||
default:
|
||||
rest = append(rest, a)
|
||||
}
|
||||
}
|
||||
// First non-flag arg before -- is the model; --safe and --continue are ours.
|
||||
// Unknown options pass through unchanged. Everything after -- belongs to the
|
||||
// agent, including positional subcommands and raw Codex -c config overrides.
|
||||
model, safe, continueLast, rest := splitCodeArgs(args)
|
||||
if model == "" {
|
||||
model = defaultCodeModel
|
||||
}
|
||||
@@ -382,7 +385,7 @@ func runCode(env *Env, agent codeAgent, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
argv := codeArgv(agent, base, model, safe, rest)
|
||||
argv := codeArgv(agent, base, model, safe, codeAgentRest(agent, continueLast, rest))
|
||||
|
||||
for k, v := range agent.wire(base, token, model) {
|
||||
if err := os.Setenv(k, v); err != nil {
|
||||
@@ -397,6 +400,45 @@ func runCode(env *Env, agent codeAgent, args []string) error {
|
||||
return execEngine(bin, argv) // exec: signals + exit code flow straight through
|
||||
}
|
||||
|
||||
// splitCodeArgs pulls the launcher-owned tokens (the model, --safe, -c/--continue)
|
||||
// out of the raw args; everything else is the agent's. The `--` separator is ours
|
||||
// and switches on verbatim passthrough — every token after it goes to the agent
|
||||
// untouched, including positional subcommands (`codex exec`) and raw Codex -c
|
||||
// config overrides that would otherwise look like our --continue.
|
||||
func splitCodeArgs(args []string) (model string, safe, continueLast bool, rest []string) {
|
||||
rest = make([]string, 0, len(args))
|
||||
passthrough := false
|
||||
for _, a := range args {
|
||||
switch {
|
||||
case passthrough:
|
||||
rest = append(rest, a)
|
||||
case a == "--": // the separator is ours; the agent must not see it
|
||||
passthrough = true
|
||||
case a == "--safe" || a == "--ask":
|
||||
safe = true
|
||||
case a == "-c" || a == "--continue":
|
||||
continueLast = true
|
||||
case model == "" && !strings.HasPrefix(a, "-") && len(rest) == 0:
|
||||
model = a
|
||||
default:
|
||||
rest = append(rest, a)
|
||||
}
|
||||
}
|
||||
return model, safe, continueLast, rest
|
||||
}
|
||||
|
||||
// codeAgentRest prepends the agent's harness-native resume tokens when -c/--continue
|
||||
// was given, so one Hanzo flag resumes the last session on either harness (`--continue`
|
||||
// for Claude Code, `resume --last` for Codex/dev).
|
||||
func codeAgentRest(agent codeAgent, continueLast bool, rest []string) []string {
|
||||
if !continueLast {
|
||||
return rest
|
||||
}
|
||||
args := make([]string, 0, len(agent.continueArgs)+len(rest))
|
||||
args = append(args, agent.continueArgs...)
|
||||
return append(args, rest...)
|
||||
}
|
||||
|
||||
// codeArgv builds the final agent command line. Permission bypass is the
|
||||
// launcher default for every agent; --safe is the single explicit opt-out.
|
||||
func codeArgv(agent codeAgent, base, model string, safe bool, rest []string) []string {
|
||||
|
||||
+77
-10
@@ -18,6 +18,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -48,6 +49,72 @@ func TestCodeAgentsBypassPermissionsByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeArgsSeparatorPreservesAgentSubcommand(t *testing.T) {
|
||||
model, safe, continueLast, rest := splitCodeArgs([]string{"--safe", "--", "exec", "--ephemeral", "do it"})
|
||||
if model != "" || !safe || continueLast {
|
||||
t.Fatalf("model=%q safe=%v continue=%v, want default model and safe mode", model, safe, continueLast)
|
||||
}
|
||||
if want := []string{"exec", "--ephemeral", "do it"}; !reflect.DeepEqual(rest, want) {
|
||||
t.Fatalf("agent args = %q, want %q", rest, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeArgsExplicitModelBeforeSeparator(t *testing.T) {
|
||||
model, safe, continueLast, rest := splitCodeArgs([]string{"zen5-max", "--", "exec"})
|
||||
if model != "zen5-max" || safe || continueLast || !reflect.DeepEqual(rest, []string{"exec"}) {
|
||||
t.Fatalf("model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeContinueIsNormalizedForBothHarnesses(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
want []string
|
||||
}{
|
||||
{name: "claude", want: []string{"--continue"}},
|
||||
{name: "codex", want: []string{"resume", "--last"}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
model, safe, continueLast, rest := splitCodeArgs([]string{"-c"})
|
||||
if model != "" || safe || !continueLast || len(rest) != 0 {
|
||||
t.Fatalf("model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
|
||||
}
|
||||
if got := codeAgentRest(codeAgents[tt.name], continueLast, rest); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("normalized continue args = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeUnknownOptionsAndPostSeparatorArgsPassThrough(t *testing.T) {
|
||||
unknown := []string{"--mystery", "value", "--other=1"}
|
||||
model, safe, continueLast, rest := splitCodeArgs(unknown)
|
||||
if model != "" || safe || continueLast || !reflect.DeepEqual(rest, unknown) {
|
||||
t.Fatalf("unknown options changed: model=%q safe=%v continue=%v rest=%q", model, safe, continueLast, rest)
|
||||
}
|
||||
|
||||
_, _, continueLast, rest = splitCodeArgs([]string{"--", "-c", "model=x"})
|
||||
if continueLast || !reflect.DeepEqual(rest, []string{"-c", "model=x"}) {
|
||||
t.Fatalf("post-separator Codex config must pass verbatim: continue=%v rest=%q", continueLast, rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderUsesNativeResponsesMetadata(t *testing.T) {
|
||||
argv := codeArgv(codeAgents["codex"], "https://api.hanzo.ai", defaultCodeModel, false, nil)
|
||||
for _, want := range []string{
|
||||
`model_provider=hanzo`,
|
||||
`model_providers.hanzo.base_url="https://api.hanzo.ai/v1"`,
|
||||
`model_providers.hanzo.wire_api="responses"`,
|
||||
`features.remote_models=false`,
|
||||
`model_context_window=262144`,
|
||||
`model_auto_compact_token_limit=235929`,
|
||||
} {
|
||||
if !slices.Contains(argv, want) {
|
||||
t.Errorf("Codex argv %q does not contain %q", argv, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeTokenPrecedence locks in the 402 unblock: a fresh `hanzo login` JWT
|
||||
// (which carries owner/project/sub on EVERY deployment) beats the hk- API key
|
||||
// (which only mints a billing principal where the server has IAM_MINT_CLIENT_*).
|
||||
@@ -60,10 +127,10 @@ func TestCodeTokenPrecedence(t *testing.T) {
|
||||
freshExpiry := time.Now().Add(1 * time.Hour).Unix()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
envKey string // HANZO_API_KEY override
|
||||
creds Credentials
|
||||
want string
|
||||
name string
|
||||
envKey string // HANZO_API_KEY override
|
||||
creds Credentials
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "fresh JWT beats hk- key",
|
||||
@@ -81,16 +148,16 @@ func TestCodeTokenPrecedence(t *testing.T) {
|
||||
want: "hk-stored",
|
||||
},
|
||||
{
|
||||
name: "HANZO_API_KEY overrides everything (deliberate operator override)",
|
||||
name: "HANZO_API_KEY overrides everything (deliberate operator override)",
|
||||
envKey: "hk-explicit",
|
||||
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
|
||||
want: "hk-explicit",
|
||||
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
|
||||
want: "hk-explicit",
|
||||
},
|
||||
{
|
||||
name: "HANZO_API_KEY overrides even an expired JWT",
|
||||
name: "HANZO_API_KEY overrides even an expired JWT",
|
||||
envKey: "hk-explicit",
|
||||
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
|
||||
want: "hk-explicit",
|
||||
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
|
||||
want: "hk-explicit",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+103
-222
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -15,12 +16,12 @@ func (e *Env) platform(gf *globalFlags) *Platform {
|
||||
return newPlatform(e.PlatformURL, e.platformToken(gf.platformToken))
|
||||
}
|
||||
|
||||
// deref renders a *string for a table cell, "-" when nil/empty.
|
||||
func deref(p *string) string {
|
||||
if p == nil || *p == "" {
|
||||
// dashIfEmpty renders a string cell, "-" when empty.
|
||||
func dashIfEmpty(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return *p
|
||||
return s
|
||||
}
|
||||
|
||||
// yesno renders a bool for a table cell.
|
||||
@@ -37,7 +38,8 @@ func newTab(w io.Writer) *tabwriter.Writer {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apps — the observe surface.
|
||||
// apps — the fleet drift board (GET /v1/paas/apps). Org-confined server-side by
|
||||
// the IAM identity: a superadmin sees the whole fleet, an org-admin only its own.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
@@ -56,7 +58,6 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
e := envOf()
|
||||
res, err := e.platform(gf).Apps(cmd.Context(), AppsQuery{
|
||||
Org: e.Org, // empty == all (single-tenant default)
|
||||
Env: envFilter,
|
||||
Health: healthFilter,
|
||||
Drift: driftOnly,
|
||||
@@ -69,8 +70,8 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
fmt.Fprintln(tw, "ORG\tAPP\tENV\tDECLARED\tRUNNING\tHEALTH\tDRIFT")
|
||||
for _, a := range res.Apps {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
a.Org, a.App, a.Env, deref(a.DeclaredTag), deref(a.RunningTag),
|
||||
deref(a.Health), driftSeverity(a.Drift))
|
||||
a.Org, a.App, a.Env, dashIfEmpty(a.DeclaredTag), dashIfEmpty(a.RunningTag),
|
||||
dashIfEmpty(a.Health), driftSeverity(a.Drift))
|
||||
}
|
||||
tw.Flush()
|
||||
fmt.Fprintf(w, "\n%d apps (ok=%d yellow=%d red=%d)\n",
|
||||
@@ -79,17 +80,17 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().StringVar(&envFilter, "env", "", "filter by env: dev|test|main")
|
||||
list.Flags().StringVar(&envFilter, "env", "", "filter by env: main|test|dev")
|
||||
list.Flags().StringVar(&healthFilter, "health", "", "filter by health: green|yellow|red")
|
||||
list.Flags().BoolVar(&driftOnly, "drift", false, "only rows that are drifting")
|
||||
|
||||
get := &cobra.Command{
|
||||
Use: "get <org/app/env>",
|
||||
Short: "Get one app row by its <org>/<app>/<env> id",
|
||||
Use: "get <app>",
|
||||
Short: "Get one app row by its CR name (production by default)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
e := envOf()
|
||||
a, err := e.platform(gf).App(cmd.Context(), args[0], e.Org)
|
||||
a, err := e.platform(gf).App(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -101,109 +102,93 @@ func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
fmt.Fprintf(tw, "env:\t%s\n", a.Env)
|
||||
fmt.Fprintf(tw, "repo:\t%s\n", a.Repo)
|
||||
fmt.Fprintf(tw, "registry:\t%s\n", a.Registry)
|
||||
fmt.Fprintf(tw, "declared:\t%s\n", deref(a.DeclaredTag))
|
||||
fmt.Fprintf(tw, "running:\t%s\n", deref(a.RunningTag))
|
||||
fmt.Fprintf(tw, "latest:\t%s\n", deref(a.LatestTag))
|
||||
fmt.Fprintf(tw, "health:\t%s\n", deref(a.Health))
|
||||
fmt.Fprintf(tw, "declared:\t%s\n", dashIfEmpty(a.DeclaredTag))
|
||||
fmt.Fprintf(tw, "running:\t%s\n", dashIfEmpty(a.RunningTag))
|
||||
fmt.Fprintf(tw, "health:\t%s\n", dashIfEmpty(a.Health))
|
||||
fmt.Fprintf(tw, "phase:\t%s\n", dashIfEmpty(a.Phase))
|
||||
fmt.Fprintf(tw, "drift:\t%s\n", driftSeverity(a.Drift))
|
||||
fmt.Fprintf(tw, "cluster:\t%s\n", deref(a.Cluster))
|
||||
fmt.Fprintf(tw, "namespace:\t%s\n", deref(a.Namespace))
|
||||
fmt.Fprintf(tw, "updated:\t%s\n", a.UpdatedAt)
|
||||
fmt.Fprintf(tw, "cluster:\t%s\n", dashIfEmpty(a.Cluster))
|
||||
fmt.Fprintf(tw, "namespace:\t%s\n", dashIfEmpty(a.Namespace))
|
||||
if len(a.Endpoints) > 0 {
|
||||
fmt.Fprintf(tw, "endpoints:\t%s\n", strings.Join(a.Endpoints, ", "))
|
||||
}
|
||||
tw.Flush()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
sync := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Trigger an inventory refresh of the apps board",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
e := envOf()
|
||||
if err := e.platform(gf).SyncApps(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "apps sync triggered")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(list, get, sync)
|
||||
cmd.AddCommand(list, get)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deploy — the drive surface (rolling restart, zero-downtime).
|
||||
// deploy — POST /v1/paas/apps/{app}/deploy: a zero-downtime rolling restart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newDeployCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
var project, environment string
|
||||
var environment string
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy <container>",
|
||||
Short: "Redeploy a container (rolling restart, zero-downtime)",
|
||||
Long: "Drive a platform redeploy: a rolling restart of the container's k8s\n" +
|
||||
"Deployment (re-pulls the image, recreates pods, zero downtime). Coordinates\n" +
|
||||
"are exact — org (--org/config), project (--project), env (--env) and the\n" +
|
||||
"container id (positional). This is the canonical PaaS-driven deploy.",
|
||||
Use: "deploy <app>",
|
||||
Short: "Redeploy an app (rolling restart, zero-downtime) — requires --env",
|
||||
Long: "Drive a platform redeploy: a rolling restart of the app's k8s Deployment\n" +
|
||||
"(re-pulls the declared image, recreates pods, zero downtime). The app is the\n" +
|
||||
"operator App CR name; the org comes from your IAM identity. --env is REQUIRED\n" +
|
||||
"(main|test|dev) — deploy never silently targets production. Restarting a shared\n" +
|
||||
"platform service is a platform-operator action, so this needs a superadmin\n" +
|
||||
"identity. A TAG change is still a git commit — this restarts what is declared.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(environment) == "" {
|
||||
return fmt.Errorf("--env is required (main|test|dev) — deploy will not default to production")
|
||||
}
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
res, err := e.platform(gf).Redeploy(cmd.Context(), args[0], environment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if project == "" || environment == "" {
|
||||
return fmt.Errorf("--project and --env are required (the container's project/environment ids)")
|
||||
}
|
||||
container := args[0]
|
||||
if err := e.platform(gf).Redeploy(cmd.Context(), org, project, environment, container); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "redeployed %s (org=%s project=%s env=%s)\n", container, org, project, environment)
|
||||
return nil
|
||||
return e.emit(res, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "restarted %s (namespace=%s env=%s at %s)\n",
|
||||
res.App, res.Namespace, dashIfEmpty(res.Env), res.RestartedAt)
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "", "project id")
|
||||
cmd.Flags().StringVar(&environment, "env", "", "environment id")
|
||||
cmd.Flags().StringVar(&environment, "env", "", "lifecycle env: main|test|dev (REQUIRED)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clusters — dedicated DOKS cluster lifecycle.
|
||||
// clusters — GET /v1/clusters: the org's compute fleet (Visor-managed + BYO),
|
||||
// tenant-scoped server-side by the IAM identity.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newClustersCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "clusters",
|
||||
Aliases: []string{"cluster"},
|
||||
Short: "Provision/list/select dedicated DOKS clusters",
|
||||
Short: "List the org's clusters (managed + BYO)",
|
||||
}
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the org's dedicated clusters",
|
||||
Short: "List the org's clusters",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs, err := e.platform(gf).Clusters(cmd.Context(), org)
|
||||
cs, err := e.platform(gf).Clusters(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emit(cs, func(w io.Writer) {
|
||||
tw := newTab(w)
|
||||
fmt.Fprintln(tw, "NAME\tID\tREGION\tSTATUS\tPHASE\tACTIVE\tOPERATOR\tBASELINE")
|
||||
fmt.Fprintln(tw, "NAME\tID\tREGION\tSTATUS\tKIND\tNODES\tSIZE\tGPUS")
|
||||
for _, c := range cs {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
c.Name, c.DoksClusterID, c.Region, c.Status, c.Phase,
|
||||
yesno(c.Active), yesno(c.OperatorInstalled), yesno(c.BaselineInstalled))
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
|
||||
c.Name, dashIfEmpty(c.ID()), dashIfEmpty(c.Region), dashIfEmpty(c.Status),
|
||||
dashIfEmpty(c.Kind), c.NodeCount, dashIfEmpty(c.NodeSize), gpuCell(c))
|
||||
}
|
||||
tw.Flush()
|
||||
if len(cs) == 0 {
|
||||
fmt.Fprintln(w, "(no dedicated clusters)")
|
||||
fmt.Fprintln(w, "(no clusters)")
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -215,151 +200,51 @@ func newClustersCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs, err := e.platform(gf).Clusters(cmd.Context(), org)
|
||||
cs, err := e.platform(gf).Clusters(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cs {
|
||||
if c.DoksClusterID == args[0] || c.Name == args[0] {
|
||||
if c.ID() == args[0] || c.Name == args[0] {
|
||||
return e.emit(c, func(w io.Writer) { printCluster(w, c) })
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("cluster %q not found in org %s", args[0], org)
|
||||
return fmt.Errorf("cluster %q not found", args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var region, nodeSize string
|
||||
var ha bool
|
||||
var nodeCount int
|
||||
create := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Provision a new dedicated DOKS cluster for the org",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := e.platform(gf).ProvisionCluster(cmd.Context(), org, ProvisionReq{
|
||||
Region: region, HA: ha, NodeSize: nodeSize, NodeCount: nodeCount,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emit(c, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "provisioning cluster %s (%s)\n", c.Name, c.DoksClusterID)
|
||||
printCluster(w, *c)
|
||||
})
|
||||
},
|
||||
}
|
||||
create.Flags().StringVar(®ion, "region", "", "DO region (default sfo3)")
|
||||
create.Flags().BoolVar(&ha, "ha", false, "highly-available control plane")
|
||||
create.Flags().StringVar(&nodeSize, "node-size", "", "node size slug (e.g. s-2vcpu-4gb)")
|
||||
create.Flags().IntVar(&nodeCount, "node-count", 0, "node count")
|
||||
|
||||
var shared bool
|
||||
selectCmd := &cobra.Command{
|
||||
Use: "select <cluster-id>",
|
||||
Short: "Set the org's active deploy target (or --shared to revert)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var clusterID *string
|
||||
switch {
|
||||
case shared:
|
||||
clusterID = nil
|
||||
case len(args) == 1:
|
||||
clusterID = &args[0]
|
||||
default:
|
||||
return fmt.Errorf("give a cluster id, or --shared to revert to the shared cluster")
|
||||
}
|
||||
t, err := e.platform(gf).SelectTarget(cmd.Context(), org, clusterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
|
||||
},
|
||||
}
|
||||
selectCmd.Flags().BoolVar(&shared, "shared", false, "revert to the shared cluster")
|
||||
|
||||
installBaseline := &cobra.Command{
|
||||
Use: "install-baseline <cluster-id>",
|
||||
Short: "Install the hanzo-operator + per-tenant baseline on a cluster",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.platform(gf).InstallBaseline(cmd.Context(), org, args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "baseline install requested for %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
target := &cobra.Command{
|
||||
Use: "target",
|
||||
Short: "Show the org's current resolved deploy target",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := e.platform(gf).Target(cmd.Context(), org)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(list, get, create, selectCmd, installBaseline, target)
|
||||
cmd.AddCommand(list, get)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// gpuCell renders the live GPU inventory of a cluster ("-" when none).
|
||||
func gpuCell(c Cluster) string {
|
||||
var parts []string
|
||||
if c.NvidiaGPU > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d nvidia", c.NvidiaGPU))
|
||||
}
|
||||
if c.AmdGPU > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d amd", c.AmdGPU))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(parts, "+")
|
||||
}
|
||||
|
||||
func printCluster(w io.Writer, c Cluster) {
|
||||
tw := newTab(w)
|
||||
fmt.Fprintf(tw, "id:\t%s\n", c.DoksClusterID)
|
||||
fmt.Fprintf(tw, "id:\t%s\n", dashIfEmpty(c.ID()))
|
||||
fmt.Fprintf(tw, "name:\t%s\n", c.Name)
|
||||
fmt.Fprintf(tw, "region:\t%s\n", c.Region)
|
||||
fmt.Fprintf(tw, "status:\t%s\n", c.Status)
|
||||
fmt.Fprintf(tw, "phase:\t%s\n", c.Phase)
|
||||
fmt.Fprintf(tw, "active:\t%s\n", yesno(c.Active))
|
||||
fmt.Fprintf(tw, "operatorInstalled:\t%s\n", yesno(c.OperatorInstalled))
|
||||
fmt.Fprintf(tw, "baselineInstalled:\t%s\n", yesno(c.BaselineInstalled))
|
||||
fmt.Fprintf(tw, "endpoint:\t%s\n", deref(c.Endpoint))
|
||||
fmt.Fprintf(tw, "k8sVersion:\t%s\n", deref(c.K8sVersion))
|
||||
fmt.Fprintf(tw, "created:\t%s\n", c.CreatedAt)
|
||||
if c.BaselineError != nil && *c.BaselineError != "" {
|
||||
fmt.Fprintf(tw, "baselineError:\t%s\n", *c.BaselineError)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func printTarget(w io.Writer, t *Target) {
|
||||
tw := newTab(w)
|
||||
kind := "shared"
|
||||
if t.Dedicated {
|
||||
kind = "dedicated"
|
||||
}
|
||||
fmt.Fprintf(tw, "cluster:\t%s\n", t.Cluster)
|
||||
fmt.Fprintf(tw, "kind:\t%s\n", kind)
|
||||
for ns, env := range t.Namespaces {
|
||||
fmt.Fprintf(tw, "namespace:\t%s -> %s\n", ns, env)
|
||||
fmt.Fprintf(tw, "region:\t%s\n", dashIfEmpty(c.Region))
|
||||
fmt.Fprintf(tw, "status:\t%s\n", dashIfEmpty(c.Status))
|
||||
fmt.Fprintf(tw, "kind:\t%s\n", dashIfEmpty(c.Kind))
|
||||
fmt.Fprintf(tw, "nodeCount:\t%d\n", c.NodeCount)
|
||||
fmt.Fprintf(tw, "nodeSize:\t%s\n", dashIfEmpty(c.NodeSize))
|
||||
fmt.Fprintf(tw, "gpus:\t%s\n", gpuCell(c))
|
||||
fmt.Fprintf(tw, "created:\t%s\n", dashIfEmpty(c.CreatedAt))
|
||||
for _, np := range c.NodePools {
|
||||
fmt.Fprintf(tw, "pool:\t%s (%s x%d, autoscale=%s)\n", np.Name, np.Size, np.Count, yesno(np.AutoScale))
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
@@ -387,6 +272,10 @@ func newBuildCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
if br.Repo == "" || br.SHA == "" || br.Image == "" {
|
||||
return fmt.Errorf("--repo (or positional), --sha and --image are required")
|
||||
}
|
||||
// The platform build muscle clones an https git URL; accept the
|
||||
// idiomatic `owner/name` shorthand and expand it to GitHub (the host
|
||||
// for every hanzoai/luxfi/zooai repo). A full URL passes through.
|
||||
br.Repo = normalizeRepoURL(br.Repo)
|
||||
if br.OrganizationID == "" {
|
||||
br.OrganizationID = e.Org // optional; server defaults to DEFAULT_BUILD_ORG_ID
|
||||
}
|
||||
@@ -419,32 +308,24 @@ func newBuildCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// k8s — deploy-target helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newK8sCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "k8s",
|
||||
Short: "Kubernetes deploy-target helpers",
|
||||
// normalizeRepoURL expands the idiomatic `owner/name` shorthand to a full GitHub
|
||||
// https URL (the platform build muscle clones https), and leaves an explicit URL
|
||||
// (http/https/git/ssh scheme, or a scp-style git@host:owner/name) untouched. Only
|
||||
// a bare single-segment `owner/name` — two path parts, no scheme, no host — is
|
||||
// expanded; anything else is the caller's explicit choice and passes through.
|
||||
func normalizeRepoURL(repo string) string {
|
||||
r := strings.TrimSpace(repo)
|
||||
if r == "" {
|
||||
return r
|
||||
}
|
||||
target := &cobra.Command{
|
||||
Use: "target",
|
||||
Short: "Show the org's current resolved deploy target (cluster + namespaces)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := e.platform(gf).Target(cmd.Context(), org)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emit(t, func(w io.Writer) { printTarget(w, t) })
|
||||
},
|
||||
// Already a URL or scp-style remote → leave as-is.
|
||||
if strings.Contains(r, "://") || strings.Contains(r, "@") {
|
||||
return r
|
||||
}
|
||||
cmd.AddCommand(target)
|
||||
return cmd
|
||||
// Bare owner/name (exactly two non-empty segments, no host dot in the first).
|
||||
parts := strings.Split(strings.Trim(r, "/"), "/")
|
||||
if len(parts) == 2 && parts[0] != "" && parts[1] != "" && !strings.Contains(parts[0], ".") {
|
||||
return "https://github.com/" + parts[0] + "/" + parts[1]
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
+109
-32
@@ -8,6 +8,24 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRepoURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"luxfi/wallet": "https://github.com/luxfi/wallet",
|
||||
"hanzoai/cloud": "https://github.com/hanzoai/cloud",
|
||||
"https://github.com/luxfi/wallet": "https://github.com/luxfi/wallet", // full URL untouched
|
||||
"git@github.com:luxfi/wallet.git": "git@github.com:luxfi/wallet.git", // scp-style untouched
|
||||
"https://gitlab.com/org/repo": "https://gitlab.com/org/repo", // non-github URL untouched
|
||||
"owner/name/extra": "owner/name/extra", // not a bare owner/name
|
||||
"single": "single", // not two segments
|
||||
"": "", // empty
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeRepoURL(in); got != want {
|
||||
t.Errorf("normalizeRepoURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withPlatform points the CLI at an httptest platform via env (HANZO_PLATFORM_URL
|
||||
// + HANZO_PLATFORM_TOKEN), the same resolution path the real binary uses.
|
||||
func withPlatform(t *testing.T, h http.HandlerFunc) string {
|
||||
@@ -20,11 +38,15 @@ func withPlatform(t *testing.T, h http.HandlerFunc) string {
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// apps list hits the LIVE board path /v1/paas/apps and renders the fleet table.
|
||||
func TestAppsListCommandTable(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/paas/apps" {
|
||||
t.Errorf("apps path = %s, want /v1/paas/apps", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(AppsList{
|
||||
Apps: []AppView{
|
||||
{Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: strptr("v1.2.3"), RunningTag: strptr("v1.2.3"), Health: strptr("green"), Drift: json.RawMessage(`{"severity":"ok"}`)},
|
||||
{Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: "v1.2.3", RunningTag: "v1.2.3", Health: "green", Drift: json.RawMessage(`{"severity":"ok"}`)},
|
||||
},
|
||||
Summary: struct {
|
||||
Total int `json:"total"`
|
||||
@@ -43,6 +65,20 @@ func TestAppsListCommandTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// apps list honors --env/--health/--drift as server query params (the board filters).
|
||||
func TestAppsListCommandFilters(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("env") != "main" || q.Get("health") != "red" || q.Get("drift") != "1" {
|
||||
t.Errorf("filters not forwarded: %s", r.URL.RawQuery)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(AppsList{})
|
||||
})
|
||||
if _, err := runRoot(t, "", "apps", "list", "--env", "main", "--health", "red", "--drift"); err != nil {
|
||||
t.Fatalf("apps list filters: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsListCommandJSON(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(AppsList{Apps: []AppView{{Org: "hanzoai", App: "iam", Env: "main"}}})
|
||||
@@ -60,69 +96,112 @@ func TestAppsListCommandJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// apps get hits /v1/paas/apps/{app}.
|
||||
func TestAppsGetCommand(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/paas/apps/iam" {
|
||||
t.Errorf("path = %s, want /v1/paas/apps/iam", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", Org: "hanzoai", App: "iam", Env: "main", DeclaredTag: "v1.2.3", Health: "green", Phase: "Running"})
|
||||
})
|
||||
out, err := runRoot(t, "", "apps", "get", "iam")
|
||||
if err != nil {
|
||||
t.Fatalf("apps get: %v", err)
|
||||
}
|
||||
for _, want := range []string{"hanzoai/iam/main", "Running", "v1.2.3"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("apps get missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deploy hits /v1/paas/apps/{app}/deploy — a rolling restart, org from identity.
|
||||
func TestDeployCommand(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/org/acme/project/p1/env/e1/container/app-x/redeploy" {
|
||||
t.Errorf("redeploy path = %s", r.URL.Path)
|
||||
if r.URL.Path != "/v1/paas/apps/app-x/deploy" || r.URL.Query().Get("env") != "main" {
|
||||
t.Errorf("redeploy request = %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
w.WriteHeader(202)
|
||||
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "app-x", Namespace: "hanzo", Env: "main", RestartedAt: "2026-07-18T12:00:00Z"})
|
||||
})
|
||||
out, err := runRoot(t, "", "deploy", "app-x", "--org", "acme", "--project", "p1", "--env", "e1")
|
||||
out, err := runRoot(t, "", "deploy", "app-x", "--env", "main")
|
||||
if err != nil {
|
||||
t.Fatalf("deploy: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "redeployed app-x") {
|
||||
if !strings.Contains(out, "restarted app-x") || !strings.Contains(out, "namespace=hanzo") {
|
||||
t.Fatalf("deploy output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployRequiresProjectEnv(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
if _, err := runRoot(t, "", "deploy", "app-x", "--org", "acme"); err == nil {
|
||||
t.Fatalf("deploy must require --project/--env")
|
||||
// deploy REQUIRES --env — a bare deploy errors CLI-side, never silently prod.
|
||||
func TestDeployRequiresEnv(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
t.Error("deploy without --env must not reach the server")
|
||||
w.WriteHeader(202)
|
||||
})
|
||||
if _, err := runRoot(t, "", "deploy", "app-x"); err == nil {
|
||||
t.Fatalf("deploy must require --env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployRequiresOrg(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
if _, err := runRoot(t, "", "deploy", "app-x", "--project", "p1", "--env", "e1"); err == nil {
|
||||
t.Fatalf("deploy must require an org")
|
||||
// deploy --env selects the lifecycle namespace via the ?env query param.
|
||||
func TestDeployCommandEnv(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/paas/apps/chat/deploy" || r.URL.Query().Get("env") != "test" {
|
||||
t.Errorf("deploy env request = %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
w.WriteHeader(202)
|
||||
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "chat", Namespace: "hanzo-testnet", Env: "test", RestartedAt: "2026-07-18T12:00:00Z"})
|
||||
})
|
||||
if _, err := runRoot(t, "", "deploy", "chat", "--env", "test"); err != nil {
|
||||
t.Fatalf("deploy --env: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-ok deploy response is surfaced as an error.
|
||||
func TestDeployNotOK(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(DeployResult{OK: false})
|
||||
})
|
||||
if _, err := runRoot(t, "", "deploy", "app-x", "--env", "main"); err == nil {
|
||||
t.Fatalf("deploy must error when the server does not report ok")
|
||||
}
|
||||
}
|
||||
|
||||
// clusters list hits the LIVE /v1/clusters (org from identity, not the path).
|
||||
func TestClustersListCommand(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/org/acme/cluster" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
if r.URL.Path != "/v1/clusters" {
|
||||
t.Errorf("path = %s, want /v1/clusters", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
|
||||
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Phase: "ready", Active: true, OperatorInstalled: true, BaselineInstalled: true},
|
||||
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "managed", NodeCount: 3, NodeSize: "s-2vcpu-4gb", NvidiaGPU: 2},
|
||||
}})
|
||||
})
|
||||
out, err := runRoot(t, "", "clusters", "list", "--org", "acme")
|
||||
out, err := runRoot(t, "", "clusters", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("clusters list: %v", err)
|
||||
}
|
||||
for _, want := range []string{"NAME", "hanzo-acme", "c1", "ready", "yes"} {
|
||||
for _, want := range []string{"NAME", "hanzo-acme", "c1", "managed", "2 nvidia"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("clusters list missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8sTargetCommand(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/org/acme/cluster/select" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"target": Target{Cluster: "hanzo-k8s", Dedicated: false, Namespaces: map[string]string{"hanzo": "main"}}})
|
||||
// clusters get filters the live list client-side by id or name.
|
||||
func TestClustersGetCommand(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
|
||||
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "byo", NodeCount: 1},
|
||||
}})
|
||||
})
|
||||
out, err := runRoot(t, "", "k8s", "target", "--org", "acme")
|
||||
out, err := runRoot(t, "", "clusters", "get", "c1")
|
||||
if err != nil {
|
||||
t.Fatalf("k8s target: %v", err)
|
||||
t.Fatalf("clusters get: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "hanzo-k8s") || !strings.Contains(out, "shared") {
|
||||
t.Fatalf("k8s target output: %q", out)
|
||||
if !strings.Contains(out, "hanzo-acme") || !strings.Contains(out, "byo") {
|
||||
t.Fatalf("clusters get output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,5 +246,3 @@ func TestConfigSetGetCommand(t *testing.T) {
|
||||
t.Fatalf("config get = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
||||
+64
-8
@@ -54,6 +54,10 @@ const (
|
||||
heartbeatEvery = 30 * time.Second
|
||||
claimPoll = 2 * time.Second
|
||||
claimLeaseSecs = 120
|
||||
// renderWindow matches the dispatch cap (studio gpu_dispatch sets
|
||||
// startToCloseTimeout 14400s). The old 10m local poll undercut it and
|
||||
// marked live renders failed while they kept sampling (observed 8-70m).
|
||||
renderWindow = 4 * time.Hour
|
||||
// localComfyUI is the studio render backend the studio.render handler drives.
|
||||
localComfyUI = "http://127.0.0.1:8188"
|
||||
// defaultStudioUploadURL is where finished render outputs are POSTed so they
|
||||
@@ -820,6 +824,14 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
|
||||
return nil, fmt.Errorf("studio.render: input needs a `prompt` graph")
|
||||
}
|
||||
cl := &http.Client{Timeout: 60 * time.Second}
|
||||
// The claim-to-submit window: hold off the supervisor's recycle, and wait out
|
||||
// one if it is already mid-flight — a claimed job must never die on staging
|
||||
// because the engine happened to be restarting.
|
||||
staging.Add(1)
|
||||
defer staging.Add(-1)
|
||||
if err := waitEngine(ctx, cl); err != nil {
|
||||
return nil, fmt.Errorf("studio.render: %w", err)
|
||||
}
|
||||
// Materialize any uploaded inputs (they live in orgs/{org}/input on the cloud
|
||||
// pod, which this worker cannot read) into the LOCAL studio input dir via its
|
||||
// own /upload/image, so LoadImage resolves them before we render.
|
||||
@@ -848,7 +860,7 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
|
||||
return nil, fmt.Errorf("studio.render: no prompt_id in /prompt response")
|
||||
}
|
||||
// Poll history until the prompt shows up (completed).
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
deadline := time.Now().Add(renderWindow)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -872,6 +884,11 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
|
||||
if uerr != nil {
|
||||
return nil, fmt.Errorf("studio.render: prompt %s rendered but gallery upload failed: %w", pr.PromptID, uerr)
|
||||
}
|
||||
// The engine leaks ~58GB per render; recycling after each completed
|
||||
// render caps it at one render's worth. Boot (~20s) is noise next to
|
||||
// 8-70m renders. Never recycle on the timeout path — the engine may
|
||||
// still be sampling and the mirror rescues late finishes.
|
||||
requestStudioRecycle()
|
||||
return map[string]any{"promptId": pr.PromptID, "outputs": outputs, "gallery": gallery}, nil
|
||||
}
|
||||
}
|
||||
@@ -1014,6 +1031,12 @@ func (w *worker) mirrorRenders(ctx context.Context, out io.Writer, dir, base str
|
||||
if werr != nil || info == nil || info.IsDir() || !isImageFile(p) {
|
||||
return nil
|
||||
}
|
||||
// Hidden files and AppleDouble forks (`._*`, `.DS_Store`) ride along with
|
||||
// mac scp and are not renders — `._foo.png` passes the extension check
|
||||
// but is a 4KB resource fork that poisons the library.
|
||||
if strings.HasPrefix(filepath.Base(p), ".") {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(dir, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
@@ -1148,14 +1171,47 @@ func (w *worker) materializeInputs(ctx context.Context, cl *http.Client, inputs
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectOutputs pulls the output image/file names out of a ComfyUI history entry.
|
||||
// waitEngine blocks until the local engine answers its /queue — up to 90s, which
|
||||
// outlasts any supervisor recycle (engine restart is seconds, model reload longer).
|
||||
func waitEngine(ctx context.Context, cl *http.Client) error {
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localComfyUI+"/queue", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := cl.Do(req)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<10))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode/100 == 2 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("engine not up: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectOutputs pulls the output file names out of a ComfyUI history entry. Savers
|
||||
// publish under different keys — SaveImage/SaveVideo under "images", SaveGLB under
|
||||
// "3d" — so every saver's outputs are gathered, not just images (a 3D mesh would
|
||||
// otherwise never travel back to the library).
|
||||
func collectOutputs(entry json.RawMessage) []string {
|
||||
type namedFile struct {
|
||||
Filename string `json:"filename"`
|
||||
Subfolder string `json:"subfolder"`
|
||||
}
|
||||
var e struct {
|
||||
Outputs map[string]struct {
|
||||
Images []struct {
|
||||
Filename string `json:"filename"`
|
||||
Subfolder string `json:"subfolder"`
|
||||
} `json:"images"`
|
||||
Images []namedFile `json:"images"`
|
||||
ThreeD []namedFile `json:"3d"`
|
||||
} `json:"outputs"`
|
||||
}
|
||||
if err := json.Unmarshal(entry, &e); err != nil {
|
||||
@@ -1163,8 +1219,8 @@ func collectOutputs(entry json.RawMessage) []string {
|
||||
}
|
||||
var files []string
|
||||
for _, node := range e.Outputs {
|
||||
for _, img := range node.Images {
|
||||
files = append(files, filepath.Join(img.Subfolder, img.Filename))
|
||||
for _, f := range append(append([]namedFile{}, node.Images...), node.ThreeD...) {
|
||||
files = append(files, filepath.Join(f.Subfolder, f.Filename))
|
||||
}
|
||||
}
|
||||
return files
|
||||
|
||||
+110
-132
@@ -12,11 +12,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Platform is a thin client over the platform.hanzo.ai /v1 control plane. That
|
||||
// surface is machine-to-machine (service-token, "No OIDC" — it cannot validate
|
||||
// IAM user tokens), so the token here is the platform service token, resolved
|
||||
// from flag/env/credential store by the caller; the build endpoint takes its
|
||||
// own token per call.
|
||||
// Platform is a thin client over the LIVE Hanzo Cloud control plane
|
||||
// (platform.hanzo.ai / api.hanzo.ai → svc `cloud`, the Go binary). Every route it
|
||||
// calls is served by that one binary and authorized off ONE IAM identity: after
|
||||
// `hanzo login` the CLI sends the IAM access token as the bearer, and the cloud's
|
||||
// identity middleware (SanitizeIdentity) validates the JWT and org-scopes the
|
||||
// caller — no separate platform/service token. A purpose-minted machine token
|
||||
// still works (flag > env > credential store > IAM login) for automation.
|
||||
type Platform struct {
|
||||
baseURL string
|
||||
token string
|
||||
@@ -45,8 +47,8 @@ func (e *apiError) Error() string {
|
||||
msg = http.StatusText(e.status)
|
||||
}
|
||||
hint := ""
|
||||
if e.status == http.StatusUnauthorized {
|
||||
hint = " (set the platform service token: --platform-token, HANZO_PLATFORM_TOKEN, or `hanzo login --platform-token`)"
|
||||
if e.status == http.StatusUnauthorized || e.status == http.StatusForbidden {
|
||||
hint = " (run `hanzo login` — your IAM identity authorizes the platform; admin ops need an org-admin or superadmin identity)"
|
||||
}
|
||||
return fmt.Sprintf("platform %s: HTTP %d: %s%s", e.path, e.status, msg, hint)
|
||||
}
|
||||
@@ -55,7 +57,7 @@ func (e *apiError) Error() string {
|
||||
// into out (when non-nil) and mapping a non-2xx into an *apiError.
|
||||
func (p *Platform) do(ctx context.Context, method, path, token string, body, out any) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("no platform token: pass --platform-token, set HANZO_PLATFORM_TOKEN, or run `hanzo login --platform-token <tok>`")
|
||||
return fmt.Errorf("not authenticated: run `hanzo login` (an IAM login now authorizes the platform; a --platform-token / HANZO_PLATFORM_TOKEN still works for machine automation)")
|
||||
}
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
@@ -94,8 +96,8 @@ func (p *Platform) do(ctx context.Context, method, path, token string, body, out
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverMessage pulls the `{ "message": … }` field platform errors use, falling
|
||||
// back to the raw (truncated) body.
|
||||
// serverMessage pulls the `{ "message": … }` / `{ "error": … }` field the cloud's
|
||||
// errors use, falling back to the raw (truncated) body.
|
||||
func serverMessage(raw []byte) string {
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
@@ -117,33 +119,35 @@ func serverMessage(raw []byte) string {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apps board — GET /v1/apps, GET /v1/apps/{id}, POST /v1/apps/sync.
|
||||
// Apps board — GET /v1/paas/apps, GET /v1/paas/apps/{app}. The live Go cloud's
|
||||
// fleet drift board (clients/paas): the operator App CRs across the platform
|
||||
// namespaces, declared/running/latest tags + health + the drift verdict. It is
|
||||
// org-confined server-side (a SuperAdmin sees the fleet; an OrgAdmin only its own
|
||||
// org), so the CLI sends NO org filter — identity scopes the view.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AppView mirrors the platform apps-lifecycle DTO. Nullable columns are *string
|
||||
// so JSON null round-trips; Drift is kept raw so --json is byte-faithful and
|
||||
// the drift schema can evolve without a client bump.
|
||||
// AppView mirrors clients/paas.AppView (the LIVE board DTO). Tags/health are plain
|
||||
// strings ("" == unknown, rendered "-"); Drift is kept raw so --json is
|
||||
// byte-faithful and the drift schema can evolve without a client bump.
|
||||
type AppView struct {
|
||||
ID string `json:"id"`
|
||||
Org string `json:"org"`
|
||||
App string `json:"app"`
|
||||
Env string `json:"env"`
|
||||
Repo string `json:"repo"`
|
||||
Registry string `json:"registry"`
|
||||
DeclaredTag *string `json:"declaredTag"`
|
||||
RunningTag *string `json:"runningTag"`
|
||||
LatestTag *string `json:"latestTag"`
|
||||
ReleaseURL *string `json:"releaseUrl"`
|
||||
ReleaseAssets int `json:"releaseAssets"`
|
||||
Health *string `json:"health"`
|
||||
Cluster *string `json:"cluster"`
|
||||
Namespace *string `json:"namespace"`
|
||||
LastObserved *string `json:"lastObserved"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Drift json.RawMessage `json:"drift"`
|
||||
ID string `json:"id"` // <org>/<app>/<env>, e.g. hanzoai/iam/main
|
||||
Org string `json:"org"` // image namespace, e.g. hanzoai
|
||||
App string `json:"app"`
|
||||
Env string `json:"env"`
|
||||
Repo string `json:"repo"`
|
||||
Registry string `json:"registry"`
|
||||
DeclaredTag string `json:"declaredTag"`
|
||||
RunningTag string `json:"runningTag"`
|
||||
LatestTag string `json:"latestTag"`
|
||||
Health string `json:"health"`
|
||||
Phase string `json:"phase"`
|
||||
Cluster string `json:"cluster"`
|
||||
Namespace string `json:"namespace"`
|
||||
Endpoints []string `json:"endpoints"`
|
||||
Drift json.RawMessage `json:"drift"`
|
||||
}
|
||||
|
||||
// AppsList is the /v1/apps envelope: ordered rows + a drift summary.
|
||||
// AppsList is the /v1/paas/apps envelope: ordered rows + a drift summary.
|
||||
type AppsList struct {
|
||||
Apps []AppView `json:"apps"`
|
||||
Summary struct {
|
||||
@@ -152,9 +156,10 @@ type AppsList struct {
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
// AppsQuery are the optional /v1/apps filters.
|
||||
// AppsQuery are the optional /v1/paas/apps filters (server-honored). Env/Health/
|
||||
// Drift narrow the board; there is deliberately no org filter — the board is
|
||||
// confined to the caller's org by the validated identity, never a client value.
|
||||
type AppsQuery struct {
|
||||
Org string
|
||||
Env string
|
||||
Health string
|
||||
Drift bool
|
||||
@@ -162,9 +167,6 @@ type AppsQuery struct {
|
||||
|
||||
func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
|
||||
v := url.Values{}
|
||||
if q.Org != "" {
|
||||
v.Set("org", q.Org)
|
||||
}
|
||||
if q.Env != "" {
|
||||
v.Set("env", q.Env)
|
||||
}
|
||||
@@ -174,7 +176,7 @@ func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
|
||||
if q.Drift {
|
||||
v.Set("drift", "1")
|
||||
}
|
||||
path := "/v1/apps"
|
||||
path := "/v1/paas/apps"
|
||||
if len(v) > 0 {
|
||||
path += "?" + v.Encode()
|
||||
}
|
||||
@@ -182,17 +184,11 @@ func (p *Platform) Apps(ctx context.Context, q AppsQuery) (*AppsList, error) {
|
||||
return out, p.do(ctx, http.MethodGet, path, p.token, nil, out)
|
||||
}
|
||||
|
||||
func (p *Platform) App(ctx context.Context, id, org string) (*AppView, error) {
|
||||
path := "/v1/apps/" + id
|
||||
if org != "" {
|
||||
path += "?org=" + url.QueryEscape(org)
|
||||
}
|
||||
// App gets one app row by its <app> CR name (production by default; the server
|
||||
// scans the caller's authorized namespaces main→test→dev).
|
||||
func (p *Platform) App(ctx context.Context, app string) (*AppView, error) {
|
||||
out := &AppView{}
|
||||
return out, p.do(ctx, http.MethodGet, path, p.token, nil, out)
|
||||
}
|
||||
|
||||
func (p *Platform) SyncApps(ctx context.Context) error {
|
||||
return p.do(ctx, http.MethodPost, "/v1/apps/sync", p.token, nil, nil)
|
||||
return out, p.do(ctx, http.MethodGet, "/v1/paas/apps/"+url.PathEscape(app), p.token, nil, out)
|
||||
}
|
||||
|
||||
// driftSeverity extracts the severity string from the raw drift object.
|
||||
@@ -207,110 +203,92 @@ func driftSeverity(raw json.RawMessage) string {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedicated clusters — /v1/org/{org}/cluster[ /select | /{id}/install-baseline ].
|
||||
// Clusters — GET /v1/clusters. The live Go cloud's compute fleet (clients/visor):
|
||||
// Visor-managed node pools + the org's BYO clusters, tenant-scoped server-side by
|
||||
// the validated org (?owner is the caller's IAM org). No org in the path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Cluster mirrors a doks_cluster record. `status` is DigitalOcean state; `phase`
|
||||
// is the platform provisioning lifecycle — orthogonal (a DO-running cluster is
|
||||
// not a usable target until phase=ready).
|
||||
// NodePool mirrors clients/visor.nodePoolView.
|
||||
type NodePool struct {
|
||||
PoolID string `json:"poolId"`
|
||||
Name string `json:"name"`
|
||||
Size string `json:"size"`
|
||||
Count int `json:"count"`
|
||||
MinNodes int `json:"minNodes"`
|
||||
MaxNodes int `json:"maxNodes"`
|
||||
AutoScale bool `json:"autoScale"`
|
||||
}
|
||||
|
||||
// Cluster mirrors clients/visor.clusterView — the LIVE cluster DTO. `kind` is
|
||||
// "managed" (Visor-provisioned) or "byo" (attached kubeconfig).
|
||||
type Cluster struct {
|
||||
DoksClusterID string `json:"doksClusterId"`
|
||||
Name string `json:"name"`
|
||||
DoClusterID *string `json:"doClusterId"`
|
||||
Region string `json:"region"`
|
||||
Status string `json:"status"`
|
||||
Endpoint *string `json:"endpoint"`
|
||||
K8sVersion *string `json:"k8sVersion"`
|
||||
HA bool `json:"ha"`
|
||||
Phase string `json:"phase"`
|
||||
OperatorInstalled bool `json:"operatorInstalled"`
|
||||
BaselineInstalled bool `json:"baselineInstalled"`
|
||||
Active bool `json:"active"`
|
||||
BaselineError *string `json:"baselineError"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Tags []string `json:"tags"`
|
||||
MaintenancePolicy json.RawMessage `json:"maintenancePolicy,omitempty"`
|
||||
DoksClusterID string `json:"doksClusterId"`
|
||||
DoClusterID string `json:"doClusterId"`
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
Status string `json:"status"`
|
||||
NodePools []NodePool `json:"nodePools"`
|
||||
NodeSize string `json:"nodeSize"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Kind string `json:"kind"`
|
||||
NvidiaGPU int `json:"nvidiaGpu"`
|
||||
AmdGPU int `json:"amdGpu"`
|
||||
}
|
||||
|
||||
// ProvisionReq is the dedicated-cluster provisioning body (org forced by path).
|
||||
type ProvisionReq struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
HA bool `json:"ha,omitempty"`
|
||||
NodeSize string `json:"nodeSize,omitempty"`
|
||||
NodeCount int `json:"nodeCount,omitempty"`
|
||||
// ID is the stable cluster identifier for display/lookup: the DOKS id when managed,
|
||||
// else the name (a BYO cluster keys on its attached name).
|
||||
func (c Cluster) ID() string {
|
||||
if c.DoksClusterID != "" {
|
||||
return c.DoksClusterID
|
||||
}
|
||||
return c.Name
|
||||
}
|
||||
|
||||
// Target is the redacted ClusterTargetView — the kubeconfig is never present.
|
||||
type Target struct {
|
||||
Cluster string `json:"cluster"`
|
||||
Namespaces map[string]string `json:"namespaces"`
|
||||
Dedicated bool `json:"dedicated"`
|
||||
}
|
||||
|
||||
func (p *Platform) Clusters(ctx context.Context, org string) ([]Cluster, error) {
|
||||
func (p *Platform) Clusters(ctx context.Context) ([]Cluster, error) {
|
||||
var out struct {
|
||||
Clusters []Cluster `json:"clusters"`
|
||||
}
|
||||
err := p.do(ctx, http.MethodGet, "/v1/org/"+url.PathEscape(org)+"/cluster", p.token, nil, &out)
|
||||
err := p.do(ctx, http.MethodGet, "/v1/clusters", p.token, nil, &out)
|
||||
return out.Clusters, err
|
||||
}
|
||||
|
||||
func (p *Platform) ProvisionCluster(ctx context.Context, org string, req ProvisionReq) (*Cluster, error) {
|
||||
var out struct {
|
||||
Cluster Cluster `json:"cluster"`
|
||||
}
|
||||
err := p.do(ctx, http.MethodPost, "/v1/org/"+url.PathEscape(org)+"/cluster", p.token, req, &out)
|
||||
return &out.Cluster, err
|
||||
}
|
||||
|
||||
func (p *Platform) Target(ctx context.Context, org string) (*Target, error) {
|
||||
var out struct {
|
||||
Target Target `json:"target"`
|
||||
}
|
||||
err := p.do(ctx, http.MethodGet, "/v1/org/"+url.PathEscape(org)+"/cluster/select", p.token, nil, &out)
|
||||
return &out.Target, err
|
||||
}
|
||||
|
||||
// SelectTarget activates a dedicated cluster as the org's deploy target, or
|
||||
// reverts to the shared cluster when clusterID is nil.
|
||||
func (p *Platform) SelectTarget(ctx context.Context, org string, clusterID *string) (*Target, error) {
|
||||
var out struct {
|
||||
Target Target `json:"target"`
|
||||
}
|
||||
body := map[string]any{"doksClusterId": clusterID}
|
||||
err := p.do(ctx, http.MethodPost, "/v1/org/"+url.PathEscape(org)+"/cluster/select", p.token, body, &out)
|
||||
return &out.Target, err
|
||||
}
|
||||
|
||||
func (p *Platform) InstallBaseline(ctx context.Context, org, clusterID string) error {
|
||||
path := "/v1/org/" + url.PathEscape(org) + "/cluster/" + url.PathEscape(clusterID) + "/install-baseline"
|
||||
return p.do(ctx, http.MethodPost, path, p.token, nil, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deploy — POST …/container/{id}/redeploy (rolling restart, zero-downtime).
|
||||
// Deploy — POST /v1/paas/apps/{app}/deploy: a zero-downtime ROLLING RESTART of the
|
||||
// app's Deployment (re-pulls the declared image, recreates pods). Org-confined
|
||||
// server-side; an optional env selects the lifecycle namespace (main|test|dev).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Redeploy triggers a rolling restart of the container's k8s Deployment. The
|
||||
// coordinates are exact (the platform validates org+project+env+container scope).
|
||||
func (p *Platform) Redeploy(ctx context.Context, org, project, env, container string) error {
|
||||
path := fmt.Sprintf("/v1/org/%s/project/%s/env/%s/container/%s/redeploy",
|
||||
url.PathEscape(org), url.PathEscape(project), url.PathEscape(env), url.PathEscape(container))
|
||||
var out struct {
|
||||
OK bool `json:"ok"`
|
||||
// Redeploy triggers a rolling restart of the named app. env is optional
|
||||
// (main|test|dev); empty targets production (the first match, main→test→dev).
|
||||
func (p *Platform) Redeploy(ctx context.Context, app, env string) (*DeployResult, error) {
|
||||
path := "/v1/paas/apps/" + url.PathEscape(app) + "/deploy"
|
||||
if env != "" {
|
||||
path += "?env=" + url.QueryEscape(env)
|
||||
}
|
||||
if err := p.do(ctx, http.MethodPost, path, p.token, nil, &out); err != nil {
|
||||
return err
|
||||
out := &DeployResult{}
|
||||
if err := p.do(ctx, http.MethodPost, path, p.token, nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !out.OK {
|
||||
return fmt.Errorf("redeploy did not report ok")
|
||||
return nil, fmt.Errorf("redeploy did not report ok")
|
||||
}
|
||||
return nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeployResult is the /deploy acceptance (202): the restarted app + its namespace.
|
||||
type DeployResult struct {
|
||||
OK bool `json:"ok"`
|
||||
App string `json:"app"`
|
||||
Namespace string `json:"namespace"`
|
||||
Env string `json:"env"`
|
||||
RestartedAt string `json:"restartedAt"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build — POST /v1/runner (platform-native CI, no GitHub builders).
|
||||
// Build — POST /v1/runner (platform-native CI, no GitHub builders). Authorized off
|
||||
// the IAM login exactly like the surfaces above (or a dedicated build token for
|
||||
// machine automation). Unchanged wire contract.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BuildReq is the direct-enqueue body. Repo/SHA/Image are required.
|
||||
@@ -337,11 +315,11 @@ type BuildJob struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
// EnqueueBuild enqueues a native build. It authenticates with the dedicated
|
||||
// build-callback token, not the service token.
|
||||
// EnqueueBuild enqueues a native build. buildToken is resolved by the caller (IAM
|
||||
// login is the final fallback; a dedicated build token wins when present).
|
||||
func (p *Platform) EnqueueBuild(ctx context.Context, req BuildReq, buildToken string) (*BuildJob, error) {
|
||||
if buildToken == "" {
|
||||
return nil, fmt.Errorf("no build token: set HANZO_BUILD_TOKEN / PLATFORM_BUILD_CALLBACK_TOKEN or `hanzo login --build-token <tok>`")
|
||||
return nil, fmt.Errorf("not authenticated: run `hanzo login` (an IAM login now authorizes builds; HANZO_BUILD_TOKEN / --build-token still works for machine automation)")
|
||||
}
|
||||
out := &BuildJob{}
|
||||
return out, p.do(ctx, http.MethodPost, "/v1/runner", buildToken, req, out)
|
||||
|
||||
+47
-91
@@ -18,17 +18,22 @@ func platformStub(t *testing.T, token string, h http.HandlerFunc) (*Platform, fu
|
||||
return newPlatform(srv.URL, token), srv.Close
|
||||
}
|
||||
|
||||
// Apps hits the LIVE board /v1/paas/apps with the IAM bearer; it sends NO org
|
||||
// filter (the board is org-confined server-side by the validated identity).
|
||||
func TestPlatformAuthHeaderAndApps(t *testing.T) {
|
||||
p, done := platformStub(t, "svc-tok", func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer svc-tok" {
|
||||
t.Errorf("auth header = %q", got)
|
||||
}
|
||||
if r.URL.Path != "/v1/apps" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
if r.URL.Path != "/v1/paas/apps" {
|
||||
t.Errorf("path = %s, want /v1/paas/apps", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("env") != "main" || r.URL.Query().Get("drift") != "1" {
|
||||
t.Errorf("query = %s", r.URL.RawQuery)
|
||||
}
|
||||
if r.URL.Query().Has("org") {
|
||||
t.Errorf("client must NOT send an org filter (identity confines the board): %s", r.URL.RawQuery)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(AppsList{
|
||||
Apps: []AppView{{ID: "hanzoai/iam/main", Org: "hanzoai", App: "iam", Env: "main", Drift: json.RawMessage(`{"severity":"red"}`)}},
|
||||
})
|
||||
@@ -47,126 +52,75 @@ func TestPlatformAuthHeaderAndApps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// App hits /v1/paas/apps/{app}; no org query (identity scopes it).
|
||||
func TestPlatformApp(t *testing.T) {
|
||||
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/apps/hanzoai/iam/main" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
if r.URL.Path != "/v1/paas/apps/iam" {
|
||||
t.Errorf("path = %s, want /v1/paas/apps/iam", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("org") != "hanzoai" {
|
||||
t.Errorf("org query = %s", r.URL.RawQuery)
|
||||
if r.URL.RawQuery != "" {
|
||||
t.Errorf("app get must carry no query, got %s", r.URL.RawQuery)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", App: "iam"})
|
||||
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", App: "iam", Phase: "Running"})
|
||||
})
|
||||
defer done()
|
||||
a, err := p.App(context.Background(), "hanzoai/iam/main", "hanzoai")
|
||||
a, err := p.App(context.Background(), "iam")
|
||||
if err != nil || a.App != "iam" {
|
||||
t.Fatalf("App: %v %+v", err, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformSyncApps(t *testing.T) {
|
||||
// Clusters hits the LIVE /v1/clusters (org from identity, not the path).
|
||||
func TestPlatformClusters(t *testing.T) {
|
||||
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/apps/sync" {
|
||||
t.Errorf("sync = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
})
|
||||
defer done()
|
||||
if err := p.SyncApps(context.Background()); err != nil {
|
||||
t.Fatalf("SyncApps: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformClustersAndProvision(t *testing.T) {
|
||||
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/org/acme/cluster":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Phase: "ready", Active: true}}})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/org/acme/cluster":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req ProvisionReq
|
||||
_ = json.Unmarshal(body, &req)
|
||||
if req.Region != "sfo3" || !req.HA {
|
||||
t.Errorf("provision body = %+v", req)
|
||||
}
|
||||
w.WriteHeader(201)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"cluster": Cluster{DoksClusterID: "c2", Name: "new", Phase: "requested"}})
|
||||
default:
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/clusters" {
|
||||
t.Errorf("clusters = %s %s, want GET /v1/clusters", r.Method, r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"clusters": []Cluster{
|
||||
{DoksClusterID: "c1", Name: "hanzo-acme", Region: "sfo3", Status: "running", Kind: "managed", NodeCount: 3},
|
||||
}})
|
||||
})
|
||||
defer done()
|
||||
|
||||
cs, err := p.Clusters(context.Background(), "acme")
|
||||
if err != nil || len(cs) != 1 || cs[0].DoksClusterID != "c1" {
|
||||
cs, err := p.Clusters(context.Background())
|
||||
if err != nil || len(cs) != 1 || cs[0].ID() != "c1" || cs[0].Kind != "managed" {
|
||||
t.Fatalf("Clusters: %v %+v", err, cs)
|
||||
}
|
||||
c, err := p.ProvisionCluster(context.Background(), "acme", ProvisionReq{Region: "sfo3", HA: true})
|
||||
if err != nil || c.DoksClusterID != "c2" {
|
||||
t.Fatalf("ProvisionCluster: %v %+v", err, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformTargetAndSelect(t *testing.T) {
|
||||
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/org/acme/cluster/select" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
if m["doksClusterId"] != "c1" {
|
||||
t.Errorf("select body = %v", m)
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"target": Target{Cluster: "hanzo-acme", Dedicated: true, Namespaces: map[string]string{"acme": "main"}}})
|
||||
})
|
||||
defer done()
|
||||
|
||||
tg, err := p.Target(context.Background(), "acme")
|
||||
if err != nil || tg.Cluster != "hanzo-acme" || !tg.Dedicated {
|
||||
t.Fatalf("Target: %v %+v", err, tg)
|
||||
}
|
||||
id := "c1"
|
||||
if _, err := p.SelectTarget(context.Background(), "acme", &id); err != nil {
|
||||
t.Fatalf("SelectTarget: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformInstallBaseline(t *testing.T) {
|
||||
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/org/acme/cluster/c1/install-baseline" {
|
||||
t.Errorf("install-baseline = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
})
|
||||
defer done()
|
||||
if err := p.InstallBaseline(context.Background(), "acme", "c1"); err != nil {
|
||||
t.Fatalf("InstallBaseline: %v", err)
|
||||
// A BYO cluster with no DOKS id keys on its name via ID().
|
||||
func TestClusterIDFallsBackToName(t *testing.T) {
|
||||
c := Cluster{Name: "byo-1", Kind: "byo"}
|
||||
if c.ID() != "byo-1" {
|
||||
t.Fatalf("ID() = %q, want byo-1", c.ID())
|
||||
}
|
||||
}
|
||||
|
||||
// Redeploy hits /v1/paas/apps/{app}/deploy (rolling restart), org from identity.
|
||||
func TestPlatformRedeploy(t *testing.T) {
|
||||
p, done := platformStub(t, "t", func(w http.ResponseWriter, r *http.Request) {
|
||||
want := "/v1/org/acme/project/p1/env/e1/container/app-x/redeploy"
|
||||
if r.Method != http.MethodPost || r.URL.Path != want {
|
||||
t.Errorf("redeploy path = %s %s", r.Method, r.URL.Path)
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/paas/apps/app-x/deploy" {
|
||||
t.Errorf("redeploy = %s %s, want POST /v1/paas/apps/app-x/deploy", r.Method, r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
if r.URL.Query().Get("env") != "test" {
|
||||
t.Errorf("env query = %s", r.URL.RawQuery)
|
||||
}
|
||||
w.WriteHeader(202)
|
||||
_ = json.NewEncoder(w).Encode(DeployResult{OK: true, App: "app-x", Namespace: "hanzo-testnet", Env: "test", RestartedAt: "2026-07-18T00:00:00Z"})
|
||||
})
|
||||
defer done()
|
||||
if err := p.Redeploy(context.Background(), "acme", "p1", "e1", "app-x"); err != nil {
|
||||
t.Fatalf("Redeploy: %v", err)
|
||||
res, err := p.Redeploy(context.Background(), "app-x", "test")
|
||||
if err != nil || res.Namespace != "hanzo-testnet" {
|
||||
t.Fatalf("Redeploy: %v %+v", err, res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformRedeployNotOK(t *testing.T) {
|
||||
p, done := platformStub(t, "t", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": false})
|
||||
_ = json.NewEncoder(w).Encode(DeployResult{OK: false})
|
||||
})
|
||||
defer done()
|
||||
if err := p.Redeploy(context.Background(), "o", "p", "e", "c"); err == nil {
|
||||
if _, err := p.Redeploy(context.Background(), "c", ""); err == nil {
|
||||
t.Fatalf("expected error when ok=false")
|
||||
}
|
||||
}
|
||||
@@ -207,14 +161,16 @@ func TestPlatformError401Hint(t *testing.T) {
|
||||
})
|
||||
defer done()
|
||||
_, err := p.Apps(context.Background(), AppsQuery{})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 401") || !strings.Contains(err.Error(), "platform service token") {
|
||||
t.Fatalf("401 error should carry a token hint, got %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 401") || !strings.Contains(err.Error(), "hanzo login") {
|
||||
t.Fatalf("401 error should point at `hanzo login`, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformNoTokenError(t *testing.T) {
|
||||
p := newPlatform("https://platform.hanzo.ai", "")
|
||||
if _, err := p.Apps(context.Background(), AppsQuery{}); err == nil || !strings.Contains(err.Error(), "no platform token") {
|
||||
t.Fatalf("expected no-token error, got %v", err)
|
||||
// After unify-infra, the "no credential" error points at `hanzo login` — the one
|
||||
// identity that authorizes the platform — not a separate platform token.
|
||||
if _, err := p.Apps(context.Background(), AppsQuery{}); err == nil || !strings.Contains(err.Error(), "hanzo login") {
|
||||
t.Fatalf("expected a `hanzo login` hint, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+69
-6
@@ -12,6 +12,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@@ -107,6 +109,48 @@ func stopStudio(cmd *exec.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// studioRecycle carries at most one pending recycle request; the render
|
||||
// handler signals it after each completed render (see gpu.go).
|
||||
var studioRecycle = make(chan struct{}, 1)
|
||||
|
||||
// staging guards the claim-to-submit window: a claimed job is real work the
|
||||
// engine queue cannot see yet, so the supervisor must never recycle over it
|
||||
// (observed: jobs claimed during a recycle failed staging on a dead engine
|
||||
// and were consumed).
|
||||
var staging atomic.Int32
|
||||
|
||||
func requestStudioRecycle() {
|
||||
select {
|
||||
case studioRecycle <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// studioBusy reports whether the engine holds queued or running prompts.
|
||||
// A generous timeout: a saturated GB10 answers slowly mid-render — slow is
|
||||
// alive, and killing a live render costs 8-70 minutes of GPU work.
|
||||
func studioBusy(ctx context.Context) (busy, ok bool) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+studioAddr+"/queue", nil)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var q struct {
|
||||
Running []json.RawMessage `json:"queue_running"`
|
||||
Pending []json.RawMessage `json:"queue_pending"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&q); err != nil {
|
||||
return false, false
|
||||
}
|
||||
return len(q.Running)+len(q.Pending) > 0, true
|
||||
}
|
||||
|
||||
// superviseStudio keeps the local render backend on :8188 alive until ctx
|
||||
// ends. Quiet by design: one line per restart event, not a probe firehose.
|
||||
func superviseStudio(ctx context.Context, dir string, out io.Writer) {
|
||||
@@ -138,6 +182,12 @@ func superviseStudio(ctx context.Context, dir string, out io.Writer) {
|
||||
|
||||
tick := time.NewTicker(studioProbeEvery)
|
||||
defer tick.Stop()
|
||||
// recyclePending defers the post-render recycle until the queue is EMPTY:
|
||||
// short jobs complete while a long render is mid-sample, and recycling on
|
||||
// their completion killed the live render (observed: every direct render
|
||||
// died within ~6 minutes while probe jobs cycled).
|
||||
recyclePending := false
|
||||
unhealthy := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -145,19 +195,32 @@ func superviseStudio(ctx context.Context, dir string, out io.Writer) {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
}
|
||||
return
|
||||
case <-studioRecycle:
|
||||
recyclePending = true
|
||||
case <-tick.C:
|
||||
busy, ok := studioBusy(ctx)
|
||||
if recyclePending && ok && !busy && staging.Load() == 0 {
|
||||
recyclePending = false
|
||||
unhealthy = 0
|
||||
restart("recycle")
|
||||
continue
|
||||
}
|
||||
if studioHealthy(ctx) {
|
||||
unhealthy = 0
|
||||
continue
|
||||
}
|
||||
// Grace re-check: it may be momentarily busy mid-render.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ok && busy {
|
||||
// Alive-busy: slow health under render load is not death.
|
||||
unhealthy = 0
|
||||
continue
|
||||
case <-time.After(studioGraceWait):
|
||||
}
|
||||
if !studioHealthy(ctx) {
|
||||
restart("unresponsive")
|
||||
// Sustained silence with an idle or unreadable queue = actually dead.
|
||||
unhealthy++
|
||||
if unhealthy < 3 {
|
||||
continue
|
||||
}
|
||||
unhealthy = 0
|
||||
restart("unresponsive")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
-11
@@ -30,6 +30,7 @@ package account
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -39,6 +40,7 @@ import (
|
||||
"github.com/hanzoai/account"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -107,6 +109,7 @@ var billingForwardable = map[string][]string{
|
||||
"subscriptions",
|
||||
"payment-methods",
|
||||
"spend-alerts",
|
||||
"spend-alerts/authorize", // the S2S cap-verdict read (metering gate); 2 segments need their own entry
|
||||
"payment-config",
|
||||
"plans",
|
||||
"payouts",
|
||||
@@ -241,9 +244,26 @@ func commerceCreds() (base, token string) {
|
||||
func billingData(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// IDOR boundary: the subject is the VALIDATED caller's own org/user, never a client
|
||||
// value. requireOwner=true — billing is always org-scoped (a zero-org user has none).
|
||||
// Auth. A browser caller is the VALIDATED principal (customer path — subject-pinned
|
||||
// below). An IN-PROC S2S caller carries the verified COMMERCE_SERVICE_TOKEN (the
|
||||
// metering cap-gate's authorize + the SuperAdmin cap-oversight Forward). The gateway
|
||||
// 401s a public Bearer that is not an IAM JWT / hk-|pk-|sk- key (the 64-hex service
|
||||
// token fails JWT parse at the edge), so an EXTERNAL client can NEVER present it here —
|
||||
// an unauthenticated caller still hits the 403 below. On the S2S path the caller
|
||||
// legitimately names its own subject, so its query is forwarded as-is (no pin), scoped
|
||||
// only by the EdgeAuth-controlled X-Org-Id.
|
||||
cr, ok := resolveCaller(c, true)
|
||||
s2s := false
|
||||
owner := cr.owner
|
||||
if !ok {
|
||||
return zip.ErrForbidden("sign in to view billing")
|
||||
if !s2sBillingCall(c) {
|
||||
return zip.ErrForbidden("sign in to view billing")
|
||||
}
|
||||
owner = strings.TrimSpace(c.Org()) // trusted X-Org-Id (never a client value on a public call)
|
||||
if owner == "" {
|
||||
return zip.ErrForbidden("sign in to view billing")
|
||||
}
|
||||
s2s = true
|
||||
}
|
||||
|
||||
method := c.Method()
|
||||
@@ -277,20 +297,32 @@ func billingData(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
|
||||
// Scope EVERY request to the caller's OWN subject — query AND write body — so
|
||||
// commerce's per-tenant isolation can never be crossed from the browser. The
|
||||
// subject comes from the ONE rule (ai/object.Payer), keyed on the IAM username
|
||||
// (cr.username = X-User-Name) the gate also keys on — so a top-up credits the
|
||||
// SAME account the gate debits. Keying on cr.name (X-User-Id, a UUID on the
|
||||
// direct-bearer path) would fund an account the gate never reads: the split.
|
||||
subject := account.Payer(account.Credential{Owner: cr.owner, Name: cr.username}).Subject()
|
||||
// subject comes from the ONE rule (ai/object.Payer), fed the account the
|
||||
// credential NAMES (the validated `billing_account` claim) — the same claim the
|
||||
// ai gate reads, so a top-up credits the SAME account the gate debits. Feeding
|
||||
// Payer a different credential here than the gate gets is the modern shape of
|
||||
// the old split: money landing in an account the gate never reads.
|
||||
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
|
||||
q := scopedBillingSearch(inQuery, subject)
|
||||
|
||||
var q url.Values
|
||||
var body []byte
|
||||
if method == http.MethodPost {
|
||||
body = scopedBillingBody(c.Body(), subject)
|
||||
if s2s {
|
||||
// Trusted S2S caller: forward its query/body VERBATIM — it legitimately names the
|
||||
// subject (e.g. the metering gate's ?user=<org>&amount=). Scoped by X-Org-Id.
|
||||
q = inQuery
|
||||
if method == http.MethodPost {
|
||||
body = c.Body()
|
||||
}
|
||||
} else {
|
||||
// Browser customer: pin EVERY subject key to the caller's OWN account so commerce's
|
||||
// per-tenant isolation can never be crossed from the client.
|
||||
subject := account.Payer(account.Credential{Owner: cr.owner, Name: cr.username, Account: principal.BillingAccount(c)}).Subject()
|
||||
q = scopedBillingSearch(inQuery, subject)
|
||||
if method == http.MethodPost {
|
||||
body = scopedBillingBody(c.Body(), subject)
|
||||
}
|
||||
}
|
||||
|
||||
raw, status, err := commerceDo(c.Context(), base, token, method, "/v1/billing/"+sub, q, cr.owner, body)
|
||||
raw, status, err := commerceDo(c.Context(), base, token, method, "/v1/billing/"+sub, q, owner, body)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable: %v", err)
|
||||
}
|
||||
@@ -300,3 +332,21 @@ func billingData(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
c.SetHeader("Cache-Control", "no-store, must-revalidate")
|
||||
return c.Bytes(status, raw)
|
||||
}
|
||||
|
||||
// s2sBillingCall reports whether the request carries the verified COMMERCE_SERVICE_TOKEN
|
||||
// as its Bearer — a trusted IN-PROC service-to-service caller (the metering cap-gate's
|
||||
// authorize, the SuperAdmin cap-oversight Forward). It is the SAME secret this bridge
|
||||
// already forwards WITH, so admitting a caller who already holds it grants no authority it
|
||||
// could not otherwise wield. Safety rests on the edge: the gateway 401s a public Bearer
|
||||
// that is not an IAM JWT / hk-|pk-|sk- API key (the 64-hex service token is a JWT
|
||||
// candidate that fails to parse), so an EXTERNAL client can never reach this handler
|
||||
// holding it — only in-proc commerceinproc dispatch does. Constant-time compare; the token
|
||||
// is never logged.
|
||||
func s2sBillingCall(c *zip.Ctx) bool {
|
||||
_, token := commerceCreds()
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
bearer := strings.TrimSpace(strings.TrimPrefix(c.Header("Authorization"), "Bearer "))
|
||||
return bearer != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(token)) == 1
|
||||
}
|
||||
|
||||
@@ -232,3 +232,65 @@ func TestBilling_RejectsTraversalSegment(t *testing.T) {
|
||||
t.Fatalf("a traversal must never reach commerce, but upstream saw %q", f.path)
|
||||
}
|
||||
}
|
||||
|
||||
// ── S2S service-token admission (the auth fix; 4 security invariants) ─────────
|
||||
|
||||
// Invariant #4 — THE SECURITY GATE: a public/unauthenticated caller (no validated
|
||||
// principal AND not the service token) STILL gets 403 on the spend-alert routes, incl.
|
||||
// a WRONG bearer. The fix must NEVER open billing to the world.
|
||||
func TestBilling_S2S_PublicStill403(t *testing.T) {
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
for _, path := range []string{
|
||||
"/v1/billing/spend-alerts",
|
||||
"/v1/billing/spend-alerts/authorize?user=acme&amount=1",
|
||||
} {
|
||||
// forged X-Org-Id, no validated principal, no service token
|
||||
if code, body := callH(t, app, http.MethodGet, path, map[string]string{"X-Org-Id": "victim"}, ""); code != http.StatusForbidden {
|
||||
t.Fatalf("public caller to %s: want 403, got %d (%s)", path, code, body)
|
||||
}
|
||||
}
|
||||
// a WRONG bearer is still just a public caller → 403
|
||||
if code, _ := callH(t, app, http.MethodGet, "/v1/billing/spend-alerts/authorize?user=acme&amount=1",
|
||||
map[string]string{"X-Org-Id": "acme", "Authorization": "Bearer not-the-token"}, ""); code != http.StatusForbidden {
|
||||
t.Fatalf("wrong bearer: want 403")
|
||||
}
|
||||
}
|
||||
|
||||
// The trusted in-proc S2S caller (verified COMMERCE_SERVICE_TOKEN + X-Org-Id) is admitted
|
||||
// and its authorize query is forwarded to commerce VERBATIM (a trusted caller names its
|
||||
// own subject), scoped by X-Org-Id — this is what lets the cap gate reach AuthorizeSpendCap.
|
||||
func TestBilling_S2S_ServiceTokenForwardsVerbatim(t *testing.T) {
|
||||
f := &fakeBilling{}
|
||||
t.Setenv("COMMERCE_URL", f.server(t).URL)
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
|
||||
code, body := callH(t, app, http.MethodGet,
|
||||
"/v1/billing/spend-alerts/authorize?user=acme&amount=100&project=P",
|
||||
map[string]string{"Authorization": "Bearer svc-tok", "X-Org-Id": "acme"}, "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("S2S authorize: want 200, got %d (%s)", code, body)
|
||||
}
|
||||
if f.path != "/v1/billing/spend-alerts/authorize" {
|
||||
t.Fatalf("forwarded path = %q", f.path)
|
||||
}
|
||||
// VERBATIM: the S2S caller's ?user/?amount/?project reach commerce un-pinned.
|
||||
if f.query.Get("user") != "acme" || f.query.Get("amount") != "100" || f.query.Get("project") != "P" {
|
||||
t.Fatalf("S2S query must forward verbatim, got %v", f.query)
|
||||
}
|
||||
if f.org != "acme" || f.auth != "Bearer svc-tok" {
|
||||
t.Fatalf("S2S must send X-Org-Id=acme + service token, got org=%q auth=%q", f.org, f.auth)
|
||||
}
|
||||
}
|
||||
|
||||
// S2S with the verified token but NO X-Org-Id → 403 (no org to scope the privileged
|
||||
// forward to; never fall back to a client value).
|
||||
func TestBilling_S2S_NoOrg403(t *testing.T) {
|
||||
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
|
||||
app := mountApp(t, "http://iam.invalid", "", "")
|
||||
if code, _ := callH(t, app, http.MethodGet, "/v1/billing/spend-alerts/authorize?user=acme&amount=1",
|
||||
map[string]string{"Authorization": "Bearer svc-tok"}, ""); code != http.StatusForbidden {
|
||||
t.Fatalf("S2S without X-Org-Id: want 403")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -38,7 +38,10 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/admin/finance"
|
||||
"github.com/hanzoai/cloud/clients/admin/health"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/hanzoai/cloud/clients/admin/invoices"
|
||||
"github.com/hanzoai/cloud/clients/admin/metrics"
|
||||
"github.com/hanzoai/cloud/clients/admin/revenue"
|
||||
"github.com/hanzoai/cloud/clients/admin/subscriptions"
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -111,18 +114,25 @@ func routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/flags", core.Guard(s, flagsBoard))
|
||||
app.Put("/v1/admin/flags/:key", core.Guard(s, setFlag))
|
||||
// Launch-control services board — the waitlist-mode lens on the flag engine (twin
|
||||
// of /v1/admin/flags), folded in from the former featuregate control plane.
|
||||
// of /v1/admin/flags), reading the registry + decide the admission gate owns.
|
||||
app.Get("/v1/admin/services", core.Guard(s, services))
|
||||
app.Post("/v1/admin/services", core.Guard(s, upsertService))
|
||||
app.Post("/v1/admin/services/:service/mode", core.Guard(s, setServiceMode))
|
||||
app.Get("/v1/admin/waitlist", core.Guard(s, waitlist))
|
||||
app.Post("/v1/admin/waitlist/boost", core.Guard(s, waitlistBoost))
|
||||
|
||||
// ── Carved-out domains own their routes (audit/customer/revenue/finance). ──
|
||||
// Usage-cap + promo control plane (promos platform-only; spend-caps org-scoped).
|
||||
limitRoutes(app, s)
|
||||
|
||||
// ── Carved-out domains own their routes (audit/customer/revenue/finance +
|
||||
// the billing fleet views metrics/invoices/subscriptions). ──
|
||||
audit.Routes(app, s)
|
||||
customer.Routes(app, s)
|
||||
revenue.Routes(app, s)
|
||||
finance.Routes(app, s)
|
||||
metrics.Routes(app, s)
|
||||
invoices.Routes(app, s)
|
||||
subscriptions.Routes(app, s)
|
||||
}
|
||||
|
||||
// ── /v1/admin/me — operator identity (AdminMe) ───────────────────────────────
|
||||
|
||||
@@ -295,6 +295,237 @@ func (c *Client) Deposit(ctx context.Context, subject string, amount money.Cents
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── SaaS-metrics god-view (fleet-wide, org-independent) ──────────────────────
|
||||
|
||||
// SaaSMetrics mirrors commerce's GET /v1/metrics/saas snapshot — the whole-business
|
||||
// SaaS-operations aggregate (MRR/ARR, new/churn, plan mix, top customers, recent
|
||||
// movements) computed IN commerce across every org namespace. It is org-INDEPENDENT
|
||||
// (like Costs) so the reader sends NO subject. Only the fields the admin god-view
|
||||
// renders are modeled; commerce fields we don't consume (upgrades/downgrades,
|
||||
// untagged-request counts) are simply ignored by the decoder.
|
||||
type SaaSMetrics struct {
|
||||
AsOf string `json:"asOf"`
|
||||
Currency string `json:"currency"`
|
||||
Window string `json:"window"`
|
||||
Revenue SaaSRevenue `json:"revenue"`
|
||||
Subs SaaSSubs `json:"subscriptions"`
|
||||
Usage SaaSUsage `json:"usage"`
|
||||
Customers []SaaSCustomer `json:"customers"`
|
||||
Orgs int `json:"orgs"`
|
||||
Gaps []string `json:"gaps"`
|
||||
}
|
||||
|
||||
// SaaSRevenue is the recurring-revenue headline (run-rate MRR/ARR + windowed movement).
|
||||
type SaaSRevenue struct {
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
ARRCents money.Cents `json:"arrCents"`
|
||||
ActiveSubscriptions int `json:"activeSubscriptions"`
|
||||
PayingCustomers int `json:"payingCustomers"`
|
||||
Trials int `json:"trials"`
|
||||
NewMRRCents money.Cents `json:"newMrrCents"`
|
||||
ChurnedMRRCents money.Cents `json:"churnedMrrCents"`
|
||||
NetNewMRRCents money.Cents `json:"netNewMrrCents"`
|
||||
ByCategory []SaaSCategory `json:"byCategory"`
|
||||
}
|
||||
|
||||
// SaaSCategory is one plan-category bucket of run-rate MRR (the plan mix).
|
||||
type SaaSCategory struct {
|
||||
Category string `json:"category"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
}
|
||||
|
||||
// SaaSSubs is the subscription-operations panel (per-plan mix, trials, new/canceled,
|
||||
// recent movements).
|
||||
type SaaSSubs struct {
|
||||
ByPlan []SaaSPlan `json:"byPlan"`
|
||||
TrialsActive int `json:"trialsActive"`
|
||||
New int `json:"new"`
|
||||
Canceled int `json:"canceled"`
|
||||
Recent []SaaSEvent `json:"recent"`
|
||||
}
|
||||
|
||||
// SaaSPlan is one plan's active/trialing counts, seats, and MRR contribution.
|
||||
type SaaSPlan struct {
|
||||
Plan string `json:"plan"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Active int `json:"active"`
|
||||
Trialing int `json:"trialing"`
|
||||
Seats int `json:"seats"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
}
|
||||
|
||||
// SaaSEvent is one recent subscription movement ("created" or "canceled").
|
||||
type SaaSEvent struct {
|
||||
At string `json:"at"`
|
||||
Org string `json:"org"`
|
||||
Type string `json:"type"`
|
||||
Plan string `json:"plan"`
|
||||
Category string `json:"category"`
|
||||
MRRDeltaCents money.Cents `json:"mrrDeltaCents"`
|
||||
}
|
||||
|
||||
// SaaSUsage is the metered / pay-as-you-go revenue headline for the window.
|
||||
type SaaSUsage struct {
|
||||
Instrumented bool `json:"instrumented"`
|
||||
WindowUsageCents money.Cents `json:"windowUsageCents"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
// SaaSCustomer is one top customer by MRR + windowed usage.
|
||||
type SaaSCustomer struct {
|
||||
Org string `json:"org"`
|
||||
Plan string `json:"plan"`
|
||||
Category string `json:"category"`
|
||||
Status string `json:"status"`
|
||||
MRRCents money.Cents `json:"mrrCents"`
|
||||
UsageCents money.Cents `json:"usageCents"`
|
||||
Seats int `json:"seats"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics reads the fleet SaaS-operations god-view (GET /v1/metrics/saas). Like Costs it
|
||||
// is org-INDEPENDENT — the engine walks every org namespace itself — so it authenticates
|
||||
// with the admin S2S service token and sends NO subject. Empty (not an error) when
|
||||
// commerce is unwired, so a partial deploy degrades to an honest empty snapshot.
|
||||
func (c *Client) Metrics(ctx context.Context, window string, limit int) (SaaSMetrics, error) {
|
||||
var out SaaSMetrics
|
||||
if !c.Ready() {
|
||||
return out, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if window != "" {
|
||||
q.Set("window", window)
|
||||
}
|
||||
if limit > 0 {
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/metrics/saas", q, "")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce metrics decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── billing invoices + subscriptions (per-subject fleet rows) ────────────────
|
||||
|
||||
// Invoice is one issued invoice as the fleet god-view renders it: the id (for a future
|
||||
// /v1/billing/invoices/:id detail fetch), the human number, status, amount due,
|
||||
// currency, and the issue/due dates. Sourced from GET /v1/billing/invoices
|
||||
// (invoiceResponse); all timestamps are RFC3339 strings.
|
||||
type Invoice struct {
|
||||
ID string `json:"id"`
|
||||
Number string `json:"numberStr"`
|
||||
Status string `json:"status"`
|
||||
AmountDue money.Cents `json:"amountDue"`
|
||||
Currency string `json:"currency"`
|
||||
Issued string `json:"createdAt"`
|
||||
Due string `json:"dueDate"`
|
||||
}
|
||||
|
||||
// Invoices lists a subject's invoices (GET /v1/billing/invoices), optionally filtered by
|
||||
// status. The subject selects the org's billing namespace via X-Org-Id (trusted only
|
||||
// after the service-token bearer verifies). Empty (not an error) when commerce is unwired.
|
||||
func (c *Client) Invoices(ctx context.Context, subject, status string) ([]Invoice, error) {
|
||||
if !c.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if status != "" {
|
||||
q.Set("status", status)
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/invoices", q, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Invoices []Invoice `json:"invoices"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("commerce invoices decode: %w", err)
|
||||
}
|
||||
return wrap.Invoices, nil
|
||||
}
|
||||
|
||||
// Subscription is one subscription row the fleet god-view renders: the id, the buyer
|
||||
// (userId), plan tier, status, monthly-normalized MRR, and the current-period
|
||||
// start/end (started/renews). MRR reuses monthlyNormalized so a yearly plan is
|
||||
// comparable to a monthly one in the fleet total.
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
MRR money.Cents `json:"mrrCents"`
|
||||
Started string `json:"started"`
|
||||
Renews string `json:"renews"`
|
||||
}
|
||||
|
||||
// subscriptionRowWire is the /v1/billing/subscriptions row shape the fleet view folds —
|
||||
// richer than subscriptionsWire (which Plan() uses for the MRR sum alone).
|
||||
type subscriptionRowWire struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
PlanID string `json:"planId"`
|
||||
Status string `json:"status"`
|
||||
Created string `json:"createdAt"`
|
||||
PeriodStart string `json:"currentPeriodStart"`
|
||||
PeriodEnd string `json:"currentPeriodEnd"`
|
||||
Plan struct {
|
||||
Name string `json:"name"`
|
||||
Price money.Cents `json:"price"`
|
||||
Interval string `json:"interval"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
|
||||
// Subscriptions lists a subject's subscriptions (GET /v1/billing/subscriptions),
|
||||
// optionally filtered by status, as fleet rows with a monthly-normalized MRR. Empty (not
|
||||
// an error) when commerce is unwired.
|
||||
func (c *Client) Subscriptions(ctx context.Context, subject, status string) ([]Subscription, error) {
|
||||
if !c.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
if status != "" {
|
||||
q.Set("status", status)
|
||||
}
|
||||
body, err := c.get(ctx, "/v1/billing/subscriptions", q, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Subscriptions []subscriptionRowWire `json:"subscriptions"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("commerce subscriptions decode: %w", err)
|
||||
}
|
||||
out := make([]Subscription, 0, len(wrap.Subscriptions))
|
||||
for _, s := range wrap.Subscriptions {
|
||||
name := strings.TrimSpace(s.Plan.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(s.PlanID)
|
||||
}
|
||||
started := strings.TrimSpace(s.Created)
|
||||
if started == "" {
|
||||
started = s.PeriodStart
|
||||
}
|
||||
out = append(out, Subscription{
|
||||
ID: s.ID,
|
||||
User: s.UserID,
|
||||
Plan: name,
|
||||
Status: s.Status,
|
||||
MRR: monthlyNormalized(s.Plan.Price, s.Plan.Interval),
|
||||
Started: started,
|
||||
Renews: s.PeriodEnd,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// post performs one admin-authenticated commerce POST (JSON body) and returns the
|
||||
// raw response. The admin S2S service token is the bearer and X-Org-Id=<subject>
|
||||
// the per-org namespace selector commerce's EdgeAuth trusts only after verifying
|
||||
@@ -332,6 +563,48 @@ func (c *Client) post(ctx context.Context, path, subject string, body []byte, id
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// Forward proxies an admin-authenticated request to commerce VERBATIM and returns
|
||||
// the raw body + status. It is the ONE seam a SuperAdmin surface drives commerce's
|
||||
// own endpoints through — the platform plan-promo config (/v1/platform/promo) and a
|
||||
// per-org spend-alert override (/v1/billing/spend-alerts) — without a typed method
|
||||
// per shape. subject is the X-Org-Id namespace selector (the target org for a cap
|
||||
// override, or the admin org for platform config); body is nil for GET/DELETE. The
|
||||
// status is returned so the caller surfaces commerce's OWN verdict (400 validation,
|
||||
// 403, 404) instead of flattening every non-2xx into one code.
|
||||
func (c *Client) Forward(ctx context.Context, method, path, subject string, body []byte) ([]byte, int, error) {
|
||||
if !c.Ready() {
|
||||
return nil, 0, errUnconfigured
|
||||
}
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if subject != "" {
|
||||
req.Header.Set("X-Org-Id", subject)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("commerce unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return raw, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// get performs one admin-authenticated commerce GET and returns the raw body.
|
||||
func (c *Client) get(ctx context.Context, path string, q url.Values, subject string) ([]byte, error) {
|
||||
u := c.base + path
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Package invoices is the fleet INVOICE view (/v1/admin/invoices) — every issued
|
||||
// invoice across every tenant: number, org, amount, status, issue + due date, plus the
|
||||
// id a future detail view fetches /v1/billing/invoices/:id with. SuperAdmin only
|
||||
// (core.Guard).
|
||||
//
|
||||
// Commerce billing is per-tenant (an invoice lives in its org's own datastore
|
||||
// namespace), so — like revenue — this fans out the org directory concurrently and
|
||||
// reads each org's invoices via the admin S2S seam, tagging every row with its owning
|
||||
// org. Best-effort per org: an org whose invoice read fails contributes NO rows rather
|
||||
// than failing the fleet view (the SAME honest-degradation contract the customer list
|
||||
// uses; an unreachable commerce yields an empty list, never fabricated rows). Optional
|
||||
// ?org= scopes to one tenant, ?status= filters, ?limit= caps the merged list.
|
||||
package invoices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// defaultLimit caps the merged fleet invoice list when the caller sends none.
|
||||
const defaultLimit = 500
|
||||
|
||||
// InvoiceRow is one row of GET /v1/admin/invoices — an issued invoice at a glance,
|
||||
// tagged with its owning org. Money is USD cents; timestamps are RFC3339 strings.
|
||||
type InvoiceRow struct {
|
||||
ID string `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
Status string `json:"status"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Currency string `json:"currency"`
|
||||
Issued string `json:"issued"`
|
||||
Due string `json:"due"`
|
||||
}
|
||||
|
||||
// Invoices answers GET /v1/admin/invoices.
|
||||
//
|
||||
// GET /v1/admin/invoices?org=&status=&limit=
|
||||
func Invoices(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := core.CallerCreds(c)
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
wantOrg := strings.TrimSpace(c.Query("org"))
|
||||
limit := parseLimit(c.Query("limit"))
|
||||
|
||||
orgs, err := core.ListOrgs(s, ctx, cr)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
if wantOrg != "" {
|
||||
orgs = filterOrg(orgs, wantOrg)
|
||||
}
|
||||
|
||||
// Per-org invoices, fanned out concurrently (best-effort per org).
|
||||
perOrg := make([][]InvoiceRow, len(orgs))
|
||||
sem := make(chan struct{}, core.MaxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iam.Org) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
perOrg[i] = invoicesOf(s, ctx, o, status)
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rows := make([]InvoiceRow, 0)
|
||||
for _, r := range perOrg {
|
||||
rows = append(rows, r...)
|
||||
}
|
||||
// Newest issued first; cap to the merged limit (total reports the full pre-cap count).
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Issued > rows[j].Issued })
|
||||
total := len(rows)
|
||||
if len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
}
|
||||
return core.OKList(c, rows, total)
|
||||
}
|
||||
|
||||
// invoicesOf reads one org's invoices into fleet rows, tagged with the org. Best-effort:
|
||||
// a failed read yields no rows so the fleet view degrades honestly, never fabricating.
|
||||
func invoicesOf(s *cloud.Service[core.State], ctx context.Context, o iam.Org, status string) []InvoiceRow {
|
||||
entries, err := s.State.Commerce.Invoices(ctx, o.Name, status)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
display := core.Display(o.DisplayName, o.Name)
|
||||
rows := make([]InvoiceRow, 0, len(entries))
|
||||
for _, inv := range entries {
|
||||
rows = append(rows, InvoiceRow{
|
||||
ID: inv.ID,
|
||||
Number: inv.Number,
|
||||
Org: o.Name,
|
||||
Display: display,
|
||||
Status: inv.Status,
|
||||
AmountCents: int64(inv.AmountDue),
|
||||
Currency: inv.Currency,
|
||||
Issued: inv.Issued,
|
||||
Due: inv.Due,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// filterOrg narrows the directory to the one requested org (empty when it does not
|
||||
// exist — an honest empty list, never a fabricated tenant).
|
||||
func filterOrg(orgs []iam.Org, want string) []iam.Org {
|
||||
for _, o := range orgs {
|
||||
if o.Name == want {
|
||||
return []iam.Org{o}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLimit clamps the merged-list cap to [1,5000], defaulting to defaultLimit.
|
||||
func parseLimit(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if n > 5000 {
|
||||
return 5000
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package invoices
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Routes registers the fleet invoice view (SuperAdmin only, cross-tenant).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/invoices", core.Guard(s, Invoices))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The SuperAdmin usage-cap + promo control plane, twinning /v1/admin/flags. It owns
|
||||
// no store: it FORWARDS to commerce (the billing source of truth) over the ONE
|
||||
// service-token seam —
|
||||
//
|
||||
// promos → commerce /v1/platform/promo (the admin-configured plan promo)
|
||||
// spend-caps → commerce /v1/billing/spend-alerts (a per-org usage cap override)
|
||||
//
|
||||
// so admin.hanzo.ai configures the 50%-off promo and oversees/overrides any org's
|
||||
// caps without a parallel model. Promo routes are platform-only (core.Guard); cap
|
||||
// routes are org-scoped (core.GuardScoped) so a SuperAdmin targets any org via ?org=
|
||||
// while a lesser admin is hard-pinned to their own.
|
||||
|
||||
// limitRoutes registers the promo + cap control plane. Called from routes().
|
||||
func limitRoutes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
// Platform plan promo — SuperAdmin only.
|
||||
app.Get("/v1/admin/promos", core.Guard(s, getPromo))
|
||||
app.Put("/v1/admin/promos", core.Guard(s, putPromo))
|
||||
|
||||
// Per-org usage-cap oversight/override — SuperAdmin (any org via ?org=) or an org
|
||||
// admin (own org only). Reuses the customer's OWN self-service spend-alert CRUD,
|
||||
// so a platform override and a customer edit are the same rows.
|
||||
app.Get("/v1/admin/spend-caps", core.GuardScoped(s, listSpendCaps))
|
||||
app.Post("/v1/admin/spend-caps", core.GuardScoped(s, createSpendCap))
|
||||
app.Patch("/v1/admin/spend-caps/:id", core.GuardScoped(s, updateSpendCap))
|
||||
app.Delete("/v1/admin/spend-caps/:id", core.GuardScoped(s, deleteSpendCap))
|
||||
}
|
||||
|
||||
// getPromo returns the current platform plan promo. X-Org-Id is the admin org —
|
||||
// commerce stores the singleton in the reserved platform namespace regardless, and
|
||||
// the service token is what passes commerce's RequirePlatformAdmin.
|
||||
func getPromo(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodGet, "/v1/platform/promo", s.State.AdminOrg, nil)
|
||||
return relay(c, raw, status, err)
|
||||
}
|
||||
|
||||
// putPromo upserts the platform plan promo from the SuperAdmin's {percentOff,start,
|
||||
// end,plans,active} body — the ONE place the 50%-off offer is configured.
|
||||
func putPromo(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPut, "/v1/platform/promo", s.State.AdminOrg, c.Body())
|
||||
return relay(c, raw, status, err)
|
||||
}
|
||||
|
||||
// listSpendCaps returns a target org's usage caps (spend-alerts + derived period
|
||||
// spend/over/warn/resetsAt). The org is the SuperAdmin's ?org= or, for a scoped
|
||||
// admin, their own — never a client-widened scope.
|
||||
func listSpendCaps(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
org, ok := targetOrg(s, c)
|
||||
if !ok {
|
||||
return core.Fail(c, "org required")
|
||||
}
|
||||
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodGet, "/v1/billing/spend-alerts", org, nil)
|
||||
return relay(c, raw, status, err)
|
||||
}
|
||||
|
||||
// createSpendCap sets a cap on a target org (platform override of a customer budget).
|
||||
func createSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
org, ok := targetOrg(s, c)
|
||||
if !ok {
|
||||
return core.Fail(c, "org required")
|
||||
}
|
||||
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPost, "/v1/billing/spend-alerts", org, c.Body())
|
||||
return relay(c, raw, status, err)
|
||||
}
|
||||
|
||||
// updateSpendCap edits a target org's cap by id (raise/lower the ceiling, flip enforce).
|
||||
func updateSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
org, ok := targetOrg(s, c)
|
||||
if !ok {
|
||||
return core.Fail(c, "org required")
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
return core.Fail(c, "cap id required")
|
||||
}
|
||||
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodPatch, "/v1/billing/spend-alerts/"+url.PathEscape(id), org, c.Body())
|
||||
return relay(c, raw, status, err)
|
||||
}
|
||||
|
||||
// deleteSpendCap removes a target org's cap by id.
|
||||
func deleteSpendCap(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
org, ok := targetOrg(s, c)
|
||||
if !ok {
|
||||
return core.Fail(c, "org required")
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
return core.Fail(c, "cap id required")
|
||||
}
|
||||
raw, status, err := s.State.Commerce.Forward(c.Context(), http.MethodDelete, "/v1/billing/spend-alerts/"+url.PathEscape(id), org, nil)
|
||||
return relay(c, raw, status, err)
|
||||
}
|
||||
|
||||
// targetOrg resolves which org a cap operation acts on: a SuperAdmin names it with
|
||||
// ?org=; a scoped admin is hard-pinned to their own subtree (?org= ignored). Empty
|
||||
// (false) when unresolvable, so the handler fails closed rather than acting on a
|
||||
// guessed tenant.
|
||||
func targetOrg(s *cloud.Service[core.State], c *zip.Ctx) (string, bool) {
|
||||
sc := core.ResolveScope(s, c)
|
||||
if sc.Super {
|
||||
if org := strings.TrimSpace(c.Query("org")); org != "" {
|
||||
return org, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if len(sc.Orgs) > 0 && strings.TrimSpace(sc.Orgs[0]) != "" {
|
||||
return sc.Orgs[0], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// relay surfaces commerce's OWN verdict in the /v1 envelope: a 2xx passes the raw
|
||||
// JSON through as data (so the console decodes the exact SpendAlert/Promo shape), a
|
||||
// non-2xx becomes an honest failure carrying commerce's status + message rather than
|
||||
// masking a 400 validation as success.
|
||||
func relay(c *zip.Ctx, raw []byte, status int, err error) error {
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if msg == "" {
|
||||
msg = http.StatusText(status)
|
||||
}
|
||||
return core.Fail(c, msg)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return core.OK(c, map[string]any{"ok": true})
|
||||
}
|
||||
return core.OKRaw(c, json.RawMessage(raw), 0)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recCommerce records the X-Org-Id + method + path of the last forwarded request so a
|
||||
// test can prove the /v1/admin control plane targets the RIGHT tenant namespace, and
|
||||
// serves the promo + spend-alert shapes verbatim.
|
||||
type recCommerce struct {
|
||||
server *httptest.Server
|
||||
mu sync.Mutex
|
||||
lastOrg string
|
||||
lastMethod string
|
||||
lastPath string
|
||||
}
|
||||
|
||||
func newRecCommerce() *recCommerce {
|
||||
f := &recCommerce{}
|
||||
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
f.lastOrg = r.Header.Get("X-Org-Id")
|
||||
f.lastMethod = r.Method
|
||||
f.lastPath = r.URL.Path
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/platform/promo"):
|
||||
io.WriteString(w, `{"percentOff":50,"plans":["pro"],"active":true}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/spend-alerts"):
|
||||
io.WriteString(w, `[{"id":"a1","threshold":10000,"enforce":true,"period":"2026-07","resetsAt":"2026-08-01T00:00:00Z"}]`)
|
||||
default:
|
||||
io.WriteString(w, `{}`)
|
||||
}
|
||||
}))
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *recCommerce) seen() (string, string, string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.lastMethod, f.lastPath, f.lastOrg
|
||||
}
|
||||
|
||||
func envStatus(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
var e struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &e)
|
||||
return e.Status
|
||||
}
|
||||
|
||||
// The promo control plane is SuperAdmin-only (core.Guard) and forwards to commerce's
|
||||
// platform-promo endpoint.
|
||||
func TestLimits_Promo_SuperOnly(t *testing.T) {
|
||||
iam := newScopeIAM()
|
||||
defer iam.server.Close()
|
||||
com := newRecCommerce()
|
||||
defer com.server.Close()
|
||||
do := mount(t, iam.server.URL, com.server.URL, "")
|
||||
|
||||
// SuperAdmin GET → 200 ok, forwarded to /v1/platform/promo.
|
||||
resp, body := do("GET", "/v1/admin/promos", superHdr)
|
||||
if resp.StatusCode != http.StatusOK || envStatus(t, body) != "ok" {
|
||||
t.Fatalf("super GET promos = %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if m, p, _ := com.seen(); m != "GET" || !strings.HasSuffix(p, "/platform/promo") {
|
||||
t.Fatalf("forwarded %s %s, want GET .../platform/promo", m, p)
|
||||
}
|
||||
|
||||
// SuperAdmin PUT → forwarded as PUT.
|
||||
if resp, _ := do("PUT", "/v1/admin/promos", superHdr); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("super PUT promos = %d", resp.StatusCode)
|
||||
}
|
||||
if m, _, _ := com.seen(); m != "PUT" {
|
||||
t.Fatalf("promo PUT forwarded as %s, want PUT", m)
|
||||
}
|
||||
|
||||
// A non-super org admin is REFUSED at the platform gate (403), never reaching commerce.
|
||||
if resp, _ := do("GET", "/v1/admin/promos", orgAdminHdr); resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("org-admin GET promos = %d, want 403 (platform-only)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Cap oversight is org-scoped: a SuperAdmin targets any org via ?org=; a scoped admin
|
||||
// is hard-pinned to their OWN org (a client ?org= is ignored — the escalation line).
|
||||
func TestLimits_SpendCaps_OrgScoped(t *testing.T) {
|
||||
iam := newScopeIAM()
|
||||
defer iam.server.Close()
|
||||
com := newRecCommerce()
|
||||
defer com.server.Close()
|
||||
do := mount(t, iam.server.URL, com.server.URL, "")
|
||||
|
||||
// SuperAdmin with ?org=maxpower → forwards X-Org-Id=maxpower.
|
||||
resp, body := do("GET", "/v1/admin/spend-caps?org=maxpower", superHdr)
|
||||
if resp.StatusCode != http.StatusOK || envStatus(t, body) != "ok" {
|
||||
t.Fatalf("super spend-caps = %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if _, p, org := com.seen(); org != "maxpower" || !strings.HasSuffix(p, "/spend-alerts") {
|
||||
t.Fatalf("forwarded org=%q path=%q, want maxpower .../spend-alerts", org, p)
|
||||
}
|
||||
|
||||
// SuperAdmin WITHOUT ?org → org required (honest error, no guessed tenant).
|
||||
if _, body := do("GET", "/v1/admin/spend-caps", superHdr); envStatus(t, body) != "error" {
|
||||
t.Fatalf("super spend-caps without org must be an error envelope, got %s", body)
|
||||
}
|
||||
|
||||
// A scoped org admin naming a FOREIGN ?org=hanzo is hard-pinned to their OWN org.
|
||||
do("GET", "/v1/admin/spend-caps?org=hanzo", orgAdminHdr)
|
||||
if _, _, org := com.seen(); org != "maxpower" {
|
||||
t.Fatalf("scoped admin forwarded org=%q, want maxpower (client ?org= must be ignored)", org)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package metrics is the fleet SaaS-operations god-view (/v1/admin/metrics) — the
|
||||
// operator's business dashboard: MRR/ARR, net-new vs churned MRR, the plan/category
|
||||
// mix, the top customers, and the recent subscription movements. SuperAdmin only
|
||||
// (core.Guard).
|
||||
//
|
||||
// It OWNS no aggregation. The whole snapshot is computed IN commerce (the system of
|
||||
// record for subscriptions + the usage ledger) by its cross-org SaaS-metrics engine
|
||||
// (GET /v1/metrics/saas), which admin PROXIES with the SAME admin-scoped S2S service
|
||||
// token finance uses for COGS. The engine is ALREADY fleet-wide — it walks every org
|
||||
// namespace itself — so this is a SINGLE upstream read, no per-org fan-out, exactly as
|
||||
// finance consumes commerce Costs. An unwired or unreachable commerce degrades to an
|
||||
// honest empty snapshot (real zeros, `[]` not null) with a not-ok source, never a
|
||||
// fabricated number.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/commerce"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// errUnconfigured marks commerce not wired on this deployment — core.SrcOf reports it as
|
||||
// a not-ok source so the console renders the honest not-configured state.
|
||||
var errUnconfigured = errors.New("commerce metrics not configured")
|
||||
|
||||
// defaultLimit caps the top-customers list when the caller sends none (mirrors the
|
||||
// commerce engine's own default so the proxy never asks for more than it returns).
|
||||
const defaultLimit = 20
|
||||
|
||||
// MetricsData is the GET /v1/admin/metrics payload: the commerce SaaS snapshot, flat,
|
||||
// plus the admin read time and the upstream freshness strip every god-view carries.
|
||||
type MetricsData struct {
|
||||
commerce.SaaSMetrics
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Sources []core.SourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
// Metrics answers GET /v1/admin/metrics by proxying the commerce SaaS-metrics engine
|
||||
// (already a fleet-wide cross-org aggregate). SuperAdmin only.
|
||||
//
|
||||
// GET /v1/admin/metrics?window=30d&limit=20
|
||||
func Metrics(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
window := strings.TrimSpace(c.Query("window"))
|
||||
limit := parseLimit(c.Query("limit"))
|
||||
|
||||
if !s.State.Commerce.Ready() {
|
||||
return core.OK(c, empty(now, window, core.SrcOf("commerce-metrics", errUnconfigured, 0, now)))
|
||||
}
|
||||
m, err := s.State.Commerce.Metrics(ctx, window, limit)
|
||||
if err != nil {
|
||||
return core.OK(c, empty(now, window, core.SrcOf("commerce-metrics", err, 0, now)))
|
||||
}
|
||||
return core.OK(c, MetricsData{
|
||||
SaaSMetrics: normalize(m),
|
||||
GeneratedAt: now,
|
||||
Sources: []core.SourceStatus{core.SrcOf("commerce-metrics", nil, m.Orgs, now)},
|
||||
})
|
||||
}
|
||||
|
||||
// empty is the honest not-configured/unreachable snapshot: real zeros + empty slices
|
||||
// (never null, never fabricated) plus the not-ok source.
|
||||
func empty(now, window string, src core.SourceStatus) MetricsData {
|
||||
return MetricsData{
|
||||
SaaSMetrics: normalize(commerce.SaaSMetrics{AsOf: now, Currency: "usd", Window: window}),
|
||||
GeneratedAt: now,
|
||||
Sources: []core.SourceStatus{src},
|
||||
}
|
||||
}
|
||||
|
||||
// normalize replaces nil slices with empty ones so the JSON is honest arrays (`[]`, not
|
||||
// null) and the console never has to guard a missing collection.
|
||||
func normalize(m commerce.SaaSMetrics) commerce.SaaSMetrics {
|
||||
if m.Revenue.ByCategory == nil {
|
||||
m.Revenue.ByCategory = []commerce.SaaSCategory{}
|
||||
}
|
||||
if m.Subs.ByPlan == nil {
|
||||
m.Subs.ByPlan = []commerce.SaaSPlan{}
|
||||
}
|
||||
if m.Subs.Recent == nil {
|
||||
m.Subs.Recent = []commerce.SaaSEvent{}
|
||||
}
|
||||
if m.Customers == nil {
|
||||
m.Customers = []commerce.SaaSCustomer{}
|
||||
}
|
||||
if m.Gaps == nil {
|
||||
m.Gaps = []string{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseLimit clamps the top-N cap to [1,200], defaulting to defaultLimit — mirrors the
|
||||
// commerce engine's clamp exactly.
|
||||
func parseLimit(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if n > 200 {
|
||||
return 200
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Routes registers the SaaS-metrics god-view (SuperAdmin only, cross-tenant business
|
||||
// aggregate).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/metrics", core.Guard(s, Metrics))
|
||||
}
|
||||
+15
-15
@@ -1,16 +1,16 @@
|
||||
package admin
|
||||
|
||||
// The /v1/admin/services board — the launch-control LENS on the ONE flag engine, twin
|
||||
// The /v1/admin/services board — the launch-control LENS over the waitlist gate, twin
|
||||
// of /v1/admin/flags. Every hosted service (studio/chat/console/app/api/team + runtime
|
||||
// onboards) with its LIVE waitlist mode — the switch waitlist.<svc> evaluated through
|
||||
// clients/flags. This is the "remove the waitlist one service at a time" toggle.
|
||||
// SuperAdmin only (core.Guard), like every platform /v1/admin/*.
|
||||
// onboards) with its LIVE waitlist mode — the switch waitlist.<svc>, evaluated through
|
||||
// clients/admission (which composes the flag engine one-way). This is the "remove the
|
||||
// waitlist one service at a time" toggle. SuperAdmin only (core.Guard), like every
|
||||
// platform /v1/admin/*.
|
||||
//
|
||||
// Formerly clients/featuregate owned its OWN SQLite mode store + this control plane;
|
||||
// both folded onto the flag engine so the platform has ONE decision plane. featuregate
|
||||
// now owns only the native Enforce middleware — a consumer of flags.WaitlistModeForHost.
|
||||
// Per-user approval (the second, orthogonal axis) stays IAM's, reached via the existing
|
||||
// admin IAM proxy — not re-served here.
|
||||
// The registry + mode decide + these admin control funcs live in clients/admission,
|
||||
// the complete launch-gate feature; flags is the pure engine underneath. Per-user
|
||||
// approval (the second, orthogonal axis) stays IAM's, reached via the existing admin IAM
|
||||
// proxy — not re-served here.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -19,13 +19,13 @@ import (
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
"github.com/hanzoai/cloud/clients/admission"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// services answers GET /v1/admin/services — the launch board (every service + live mode).
|
||||
func services(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
rows, err := flags.ListWaitlistServices(c.Context())
|
||||
rows, err := admission.ListWaitlistServices(c.Context())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list services: %v", err)
|
||||
}
|
||||
@@ -35,14 +35,14 @@ func services(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
// upsertService answers POST /v1/admin/services — onboard or edit a hosted service so a
|
||||
// new host is governed WITHOUT a redeploy. A re-register PRESERVES the live switch.
|
||||
func upsertService(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
var in flags.ServiceInput
|
||||
var in admission.ServiceInput
|
||||
if err := c.Bind(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(in.Service) == "" {
|
||||
return zip.ErrBadRequest("service slug is required")
|
||||
}
|
||||
view, err := flags.UpsertWaitlistService(c.Context(), in, c.UserEmail())
|
||||
view, err := admission.UpsertWaitlistService(c.Context(), in, c.UserEmail())
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
@@ -62,9 +62,9 @@ func setServiceMode(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
view, err := flags.SetWaitlistMode(c.Context(), service, body.WaitlistMode, c.UserEmail())
|
||||
view, err := admission.SetWaitlistMode(c.Context(), service, body.WaitlistMode, c.UserEmail())
|
||||
if err != nil {
|
||||
if errors.Is(err, flags.ErrServiceNotFound) {
|
||||
if errors.Is(err, admission.ErrServiceNotFound) {
|
||||
return zip.ErrNotFound("service not found: " + service)
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "set mode: %v", err)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package subscriptions
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Routes registers the fleet subscription view (SuperAdmin only, cross-tenant).
|
||||
func Routes(app *zip.App, s *cloud.Service[core.State]) {
|
||||
app.Get("/v1/admin/subscriptions", core.Guard(s, Subscriptions))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package subscriptions is the fleet SUBSCRIPTION view (/v1/admin/subscriptions) —
|
||||
// every tenant's plan subscription: customer/org, plan, status, monthly-normalized MRR,
|
||||
// and the current-period start/renews. SuperAdmin only (core.Guard).
|
||||
//
|
||||
// Like invoices (and revenue) it fans out the org directory concurrently and reads each
|
||||
// org's subscriptions via the admin S2S seam, tagging every row with its owning org. The
|
||||
// MRR is monthly-normalized in the commerce reader so a yearly plan is comparable to a
|
||||
// monthly one. Best-effort per org (a failed read contributes no rows, never fabricated
|
||||
// ones); optional ?org= scopes to one tenant, ?status= filters, ?limit= caps.
|
||||
package subscriptions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/admin/core"
|
||||
"github.com/hanzoai/cloud/clients/admin/iam"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// defaultLimit caps the merged fleet subscription list when the caller sends none.
|
||||
const defaultLimit = 500
|
||||
|
||||
// SubscriptionRow is one row of GET /v1/admin/subscriptions — a tenant's subscription at
|
||||
// a glance, tagged with its owning org. MRR is USD cents; timestamps are RFC3339 strings.
|
||||
type SubscriptionRow struct {
|
||||
ID string `json:"id"`
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
User string `json:"user"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
MRRCents int64 `json:"mrrCents"`
|
||||
Started string `json:"started"`
|
||||
Renews string `json:"renews"`
|
||||
}
|
||||
|
||||
// Subscriptions answers GET /v1/admin/subscriptions.
|
||||
//
|
||||
// GET /v1/admin/subscriptions?org=&status=&limit=
|
||||
func Subscriptions(s *cloud.Service[core.State], c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := core.CallerCreds(c)
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
wantOrg := strings.TrimSpace(c.Query("org"))
|
||||
limit := parseLimit(c.Query("limit"))
|
||||
|
||||
orgs, err := core.ListOrgs(s, ctx, cr)
|
||||
if err != nil {
|
||||
return core.Fail(c, err.Error())
|
||||
}
|
||||
if wantOrg != "" {
|
||||
orgs = filterOrg(orgs, wantOrg)
|
||||
}
|
||||
|
||||
// Per-org subscriptions, fanned out concurrently (best-effort per org).
|
||||
perOrg := make([][]SubscriptionRow, len(orgs))
|
||||
sem := make(chan struct{}, core.MaxCustomerConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, o := range orgs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, o iam.Org) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
perOrg[i] = subscriptionsOf(s, ctx, o, status)
|
||||
}(i, o)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rows := make([]SubscriptionRow, 0)
|
||||
for _, r := range perOrg {
|
||||
rows = append(rows, r...)
|
||||
}
|
||||
// Highest-MRR first (ties broken by most-recent start); cap to the merged limit.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].MRRCents != rows[j].MRRCents {
|
||||
return rows[i].MRRCents > rows[j].MRRCents
|
||||
}
|
||||
return rows[i].Started > rows[j].Started
|
||||
})
|
||||
total := len(rows)
|
||||
if len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
}
|
||||
return core.OKList(c, rows, total)
|
||||
}
|
||||
|
||||
// subscriptionsOf reads one org's subscriptions into fleet rows, tagged with the org.
|
||||
// Best-effort: a failed read yields no rows so the fleet view degrades honestly.
|
||||
func subscriptionsOf(s *cloud.Service[core.State], ctx context.Context, o iam.Org, status string) []SubscriptionRow {
|
||||
entries, err := s.State.Commerce.Subscriptions(ctx, o.Name, status)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
display := core.Display(o.DisplayName, o.Name)
|
||||
rows := make([]SubscriptionRow, 0, len(entries))
|
||||
for _, sub := range entries {
|
||||
rows = append(rows, SubscriptionRow{
|
||||
ID: sub.ID,
|
||||
Org: o.Name,
|
||||
Display: display,
|
||||
User: sub.User,
|
||||
Plan: sub.Plan,
|
||||
Status: sub.Status,
|
||||
MRRCents: int64(sub.MRR),
|
||||
Started: sub.Started,
|
||||
Renews: sub.Renews,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// filterOrg narrows the directory to the one requested org (empty when it does not
|
||||
// exist — an honest empty list, never a fabricated tenant).
|
||||
func filterOrg(orgs []iam.Org, want string) []iam.Org {
|
||||
for _, o := range orgs {
|
||||
if o.Name == want {
|
||||
return []iam.Org{o}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLimit clamps the merged-list cap to [1,5000], defaulting to defaultLimit.
|
||||
func parseLimit(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if n > 5000 {
|
||||
return 5000
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package featuregate
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
// approval is FAIL-OPEN: a user is approved unless properties.approvalStatus is
|
||||
// EXACTLY "pending" (absent / "approved" / "rejected" all read approved via
|
||||
// IsApproved). Only "pending" holds a user on the waitlist. Keeping the literal
|
||||
// here (not importing IAM) keeps featuregate self-contained.
|
||||
// here (not importing IAM) keeps admission self-contained.
|
||||
const approvalStatusPending = "pending"
|
||||
|
||||
// approvedHeader is the FORWARD-PERFECT path: once IAM carries approvalStatus in
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package featuregate
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -12,16 +12,24 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package featuregate is the launch-control ENFORCEMENT for Hanzo's hosted services:
|
||||
// the native middleware (Enforce) + the per-user approval predicate (Approvals, reused
|
||||
// from IAM). It is a CONSUMER of the ONE policy engine — the per-service waitlist MODE
|
||||
// and the host→service registry live in clients/flags (a service's mode IS the
|
||||
// switch waitlist.<svc>, evaluated through the native engine); the admin board is the
|
||||
// /v1/admin/services lens and the guard's runtime mode read is /v1/featuregate/mode,
|
||||
// both served there. This package owns only enforcement, decomplected into two axes:
|
||||
// Package admission is the launch-control GATE for Hanzo's hosted services — the
|
||||
// COMPLETE waitlist feature, COMPOSING the ONE flag engine (clients/flags) one-way. It
|
||||
// owns:
|
||||
//
|
||||
// - PER-SERVICE waitlist mode on|off — the flags switch waitlist.<svc>,
|
||||
// resolved for a request host via flags.WaitlistModeForHost (the decide).
|
||||
// - the host→service registry (registry.go) + the brand seed (waitlist.go),
|
||||
// - the per-service MODE decide WaitlistModeForHost — a service's mode IS the switch
|
||||
// waitlist.<svc>, evaluated through the flag engine (flags.Bool),
|
||||
// - the admin control funcs (List/Set/Upsert) the /v1/admin/services board calls,
|
||||
// - the guard's public mode read /v1/flags/waitlist, Mount,
|
||||
// - the native enforcement middleware (Enforce, this file),
|
||||
// - the per-user approval predicate (Approvals, reused from IAM — approval.go).
|
||||
//
|
||||
// flags NEVER imports admission; admission imports flags. The engine is the pure
|
||||
// (Principal, context) -> verdict primitive; this package is its first composed tenant.
|
||||
// Enforcement is decomplected into two orthogonal axes:
|
||||
//
|
||||
// - PER-SERVICE waitlist mode on|off — the switch waitlist.<svc>, resolved for a
|
||||
// request host via WaitlistModeForHost (the decide, waitlist.go).
|
||||
// - PER-USER approvalStatus pending|approved — owned by IAM (approval.go), REUSED.
|
||||
//
|
||||
// THE RULE, applied at ONE native enforcement point (Enforce):
|
||||
@@ -29,14 +37,13 @@
|
||||
// if waitlistMode[host] AND NOT user.approved → bounce to the waitlist
|
||||
// if approved OR mode=off → allow
|
||||
// unauthenticated → login first
|
||||
package featuregate
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -59,13 +66,13 @@ import (
|
||||
// INTEGRATION POINT — wire in serve.go RIGHT AFTER SanitizeIdentity:
|
||||
//
|
||||
// app.Use(IdentityMiddleware(cfg)) // establishes the validated principal
|
||||
// app.Use(featuregate.Enforce(featuregate.EnforceConfig{ WaitlistURL: … })) // ← here
|
||||
// app.Use(admission.Enforce(admission.EnforceConfig{ WaitlistURL: … })) // ← here
|
||||
//
|
||||
// It reads the sanitized X-User-Id / X-User-IsAdmin / X-User-Approved that
|
||||
// IdentityMiddleware minted, so it MUST run after it and (like BillingGate) before
|
||||
// the subsystem handlers. It is deliberately NOT wired here — the unified-binary
|
||||
// agent owns serve.go's boot chain; this package exposes Enforce so the one-line
|
||||
// app.Use lands without a merge collision. The decide (flags.WaitlistModeForHost) is
|
||||
// app.Use lands without a merge collision. The decide (WaitlistModeForHost) is
|
||||
// resolved PER REQUEST and fail-opens until the flags engine has mounted, so Enforce
|
||||
// can be constructed before Mount runs.
|
||||
//
|
||||
@@ -102,7 +109,7 @@ type EnforceConfig struct {
|
||||
ExemptPrefixes []string
|
||||
|
||||
// Gate is THE decide: it resolves whether a request host is in waitlist mode,
|
||||
// via the ONE policy engine. When nil it is flags.WaitlistModeForHost —
|
||||
// via the ONE policy engine. When nil it is WaitlistModeForHost —
|
||||
// host→service→waitlist.<svc>. Injected only in tests. Fail-open by contract:
|
||||
// known=false (unmounted / registry error / un-governed host) → not gated.
|
||||
Gate func(ctx context.Context, host string) (mode bool, service string, known bool)
|
||||
@@ -112,9 +119,9 @@ type EnforceConfig struct {
|
||||
// health, the auth/OIDC handshake, and the waitlist join API itself (so a gated
|
||||
// user can still submit the waitlist form).
|
||||
var defaultExemptPrefixes = []string{
|
||||
"/v1/featuregate/", // the mode read + the health route
|
||||
"/v1/iam/", // auth / OIDC / approval-status / get-account handshake
|
||||
"/v1/waitlist", // the waitlist join API (a gated user must reach it)
|
||||
"/v1/flags/waitlist", // the guard's public mode read (flags engine)
|
||||
"/v1/iam/", // auth / OIDC / approval-status / get-account handshake
|
||||
"/v1/waitlist", // the waitlist join API (a gated user must reach it)
|
||||
"/health",
|
||||
"/healthz",
|
||||
"/__guard/", // the @file guard's own callback surface (defense in depth)
|
||||
@@ -131,7 +138,7 @@ func Enforce(cfg EnforceConfig) zip.Handler {
|
||||
}
|
||||
gate := cfg.Gate
|
||||
if gate == nil {
|
||||
gate = flags.WaitlistModeForHost // the ONE decide: host→service→waitlist.<svc>
|
||||
gate = WaitlistModeForHost // the ONE decide: host→service→waitlist.<svc>
|
||||
}
|
||||
exempt := cfg.ExemptPrefixes
|
||||
if len(exempt) == 0 {
|
||||
@@ -199,7 +206,7 @@ func bounce(c *zip.Ctx, waitlistURL string) error {
|
||||
}
|
||||
|
||||
// apiKeyPrefixes are the Hanzo API-key prefixes. This MIRRORS cloud
|
||||
// auth_identity.go isAPIKey (the ONE authority) — kept local so featuregate stays
|
||||
// auth_identity.go isAPIKey (the ONE authority) — kept local so admission stays
|
||||
// self-contained (no cloud-internal import) while agreeing on the exact contract:
|
||||
// a token with one of these prefixes is a possession-gated API key, not a session
|
||||
// principal. If cloud adds a prefix there, add it here.
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package featuregate
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
// testGate is the injected decide (the flags engine's WaitlistModeForHost seam):
|
||||
// hanzo.chat is gated, api.hanzo.ai is open, everything else is un-governed. This is
|
||||
// exactly what flags.WaitlistModeForHost returns for the equivalent registry, without
|
||||
// exactly what WaitlistModeForHost returns for the equivalent registry, without
|
||||
// standing up the native flag engine (cgo) in a middleware unit test.
|
||||
func testGate(_ context.Context, host string) (mode bool, service string, known bool) {
|
||||
switch host {
|
||||
@@ -186,7 +186,7 @@ func TestRule_UngovernedHost_PassesThrough(t *testing.T) {
|
||||
|
||||
func TestRule_ExemptPaths_NeverGated(t *testing.T) {
|
||||
app := gateApp(t, "pending")
|
||||
for _, p := range []string{"/health", "/v1/iam/get-account", "/v1/waitlist/join", "/v1/featuregate/mode"} {
|
||||
for _, p := range []string{"/health", "/v1/iam/get-account", "/v1/waitlist/join", "/v1/flags/waitlist"} {
|
||||
code, _ := drive(t, app, greq{host: "hanzo.chat", path: p, user: "u", org: "acme", accept: html})
|
||||
if code != 200 {
|
||||
t.Fatalf("exempt path %q = %d, want 200 (never gated)", p, code)
|
||||
@@ -205,7 +205,7 @@ func TestRule_ForwardHeaderApproved_ThroughWithoutLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The DEFAULT gate (nil Gate → flags.WaitlistModeForHost) fail-opens before the flags
|
||||
// The DEFAULT gate (nil Gate → WaitlistModeForHost) fail-opens before the flag
|
||||
// engine has mounted: with no engine, WaitlistModeForHost returns known=false for every
|
||||
// host, so Enforce never gates pre-boot.
|
||||
func TestEnforce_DefaultGate_FailsOpenPreBoot(t *testing.T) {
|
||||
@@ -1,11 +1,11 @@
|
||||
package flags
|
||||
package admission
|
||||
|
||||
// The waitlist REGISTRY — the host→service map + service metadata folded in from
|
||||
// the former clients/featuregate SQLite store. It is deliberately MODE-FREE: a
|
||||
// service's waitlist mode is NOT a column here, it is the platform switch
|
||||
// waitlist.<svc> evaluated through the ONE native engine (waitlist.go). This store
|
||||
// answers only "which service owns this host, and what is its display metadata" —
|
||||
// the config the decide needs, with the decision itself owned by the flag engine.
|
||||
// The launch-registry — the host→service map + service display metadata. It is
|
||||
// deliberately MODE-FREE: a service's waitlist mode is NOT a column here, it is the
|
||||
// platform switch waitlist.<svc> evaluated through the ONE flag engine (clients/flags,
|
||||
// composed one-way from waitlist.go). This store answers only "which service owns this
|
||||
// host, and what is its display metadata" — the config the decide needs, with the
|
||||
// decision itself owned by the flag engine.
|
||||
//
|
||||
// It rides the SAME per-(org,project) OrgDB machinery as the flag defs (opened via
|
||||
// cloud.OrgStore, encrypted at rest via cek); the registry is PLATFORM-global, so it
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
)
|
||||
|
||||
// ErrServiceNotFound is returned when a service slug is not in the registry.
|
||||
var ErrServiceNotFound = errors.New("flags: waitlist service not found")
|
||||
var ErrServiceNotFound = errors.New("admission: waitlist service not found")
|
||||
|
||||
// ServiceRow is one hosted service in the registry (host→service + metadata). The
|
||||
// waitlist MODE is intentionally absent — it is the platform switch waitlist.<svc>,
|
||||
@@ -45,7 +45,7 @@ type waitlistStore struct {
|
||||
}
|
||||
|
||||
// openWaitlistStore migrates the registry schema over an already-opened (pragma'd,
|
||||
// cek-wrapped) OrgDB handle — the same open contract as openStore for flag defs.
|
||||
// cek-wrapped) OrgDB handle — the same open contract as flags' openStore for flag defs.
|
||||
func openWaitlistStore(db *sql.DB) (*waitlistStore, error) {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS wl_services (
|
||||
@@ -64,7 +64,7 @@ CREATE TABLE IF NOT EXISTS wl_hosts (
|
||||
CREATE INDEX IF NOT EXISTS ix_wl_hosts_service ON wl_hosts(service);
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return nil, fmt.Errorf("flags: waitlist migrate: %w", err)
|
||||
return nil, fmt.Errorf("admission: waitlist migrate: %w", err)
|
||||
}
|
||||
return &waitlistStore{db: db}, nil
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func (s *waitlistStore) Get(ctx context.Context, service string) (ServiceRow, er
|
||||
func (s *waitlistStore) Upsert(ctx context.Context, in ServiceRow, by string, now int64) (ServiceRow, error) {
|
||||
svc := strings.ToLower(strings.TrimSpace(in.Service))
|
||||
if svc == "" {
|
||||
return ServiceRow{}, fmt.Errorf("flags: waitlist service slug required")
|
||||
return ServiceRow{}, fmt.Errorf("admission: waitlist service slug required")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -1,9 +1,9 @@
|
||||
package flags
|
||||
package admission
|
||||
|
||||
// Registry coverage for the folded host→service store. It drives the store over a raw
|
||||
// sqlite handle (the same driver OrgDB uses), so it exercises the fold WITHOUT the cek
|
||||
// Registry coverage for the host→service store. It drives the store over a raw sqlite
|
||||
// handle (the same driver OrgDB uses), so it exercises the registry WITHOUT the cek
|
||||
// at-rest layer — runnable under CGO=0. The MODE is out of scope here by design (it is
|
||||
// the waitlist.<svc> switch, evaluated by the native engine, covered separately).
|
||||
// the waitlist.<svc> switch, evaluated by the flag engine, covered separately).
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,19 +1,23 @@
|
||||
package flags
|
||||
package admission
|
||||
|
||||
// The waitlist LENS on the ONE flag engine — the launch-control plane folded in from
|
||||
// the former clients/featuregate. Decomplected into the two orthogonal axes it always
|
||||
// was, now with a single decision plane:
|
||||
// The launch-control gate — the COMPLETE waitlist feature, COMPOSING the ONE flag
|
||||
// engine (clients/flags) one-way. Decomplected into the two orthogonal axes it always
|
||||
// was, with a single decision plane:
|
||||
//
|
||||
// - MODE (per service): waitlist.<svc> IS a platform switch, evaluated through the
|
||||
// SAME native engine as every other platform flag. There is no second mode store.
|
||||
// - HOST MAP + metadata: the registry (waitlist_store.go) resolves a request host
|
||||
// to the service whose switch governs it, and carries display metadata.
|
||||
// flag engine (flags.Bool / flags.SetPlatformSwitch / flags.Register). There is no
|
||||
// second mode store.
|
||||
// - HOST MAP + metadata: the registry (registry.go) resolves a request host to the
|
||||
// service whose switch governs it, and carries display metadata.
|
||||
//
|
||||
// The decide is WaitlistModeForHost(host) → (mode, service, known): resolve host→svc,
|
||||
// then read waitlist.<svc>. featuregate.Enforce is now a CONSUMER of this decide, and
|
||||
// /v1/featuregate/mode + the /v1/admin/services board read it too. Per-user approval
|
||||
// (pending|approved) stays IAM's (featuregate/approval.go) — the second, orthogonal
|
||||
// axis, unchanged.
|
||||
// then read waitlist.<svc>. Enforce (middleware.go) consumes this decide; the admin
|
||||
// board (/v1/admin/services) and the guard's runtime mode read (/v1/flags/waitlist,
|
||||
// served here) read it too. Per-user approval (pending|approved) is the second,
|
||||
// orthogonal axis — IAM's, in approval.go.
|
||||
//
|
||||
// flags NEVER imports this package; this package imports flags. That one-way arrow is
|
||||
// the whole point of the decomplection: the engine is pure, the feature composes it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -25,10 +29,29 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/flags"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The reserved platform tenant the launch registry rides in — the SAME reserved
|
||||
// (org, project) the flag engine uses for its platform switches, so the registry and
|
||||
// the waitlist.<svc> switches co-locate. One waitlist.db for the deployment.
|
||||
const (
|
||||
platformOrg = "platform"
|
||||
platformProject = "platform"
|
||||
)
|
||||
|
||||
// registryState is admission's process-wide launch state: the platform-tenant
|
||||
// host→service registry store + the deployment brand it was seeded for. Installed by
|
||||
// Mount, torn down by Shutdown.
|
||||
type registryState struct {
|
||||
store *cloud.OrgStore[*waitlistStore]
|
||||
brand string
|
||||
}
|
||||
|
||||
var mounted *registryState
|
||||
|
||||
// SeedService is one row of the launch registry (a hosted service + its hosts). Mode
|
||||
// is intentionally absent — the launch posture (gated) is waitlistDef's Default "true".
|
||||
type SeedService struct {
|
||||
@@ -60,17 +83,17 @@ func waitlistKey(svc string) string { return "waitlist." + strings.ToLower(strin
|
||||
|
||||
// waitlistDef is the platform switch for one service's mode. Default "true" = the
|
||||
// launch posture (gated until an admin opens it), so a deployment with no stored flag
|
||||
// behaves exactly as the old featuregate seed (waitlistMode ON).
|
||||
func waitlistDef(svc, display string) Def {
|
||||
// behaves exactly as the old admission seed (waitlistMode ON).
|
||||
func waitlistDef(svc, display string) flags.Def {
|
||||
if strings.TrimSpace(display) == "" {
|
||||
display = svc
|
||||
}
|
||||
return Def{
|
||||
return flags.Def{
|
||||
Key: waitlistKey(svc),
|
||||
Category: "Launch",
|
||||
Label: "Waitlist · " + display,
|
||||
Desc: "Waitlist mode for " + display + ": ON gates the service to APPROVED users; OFF opens it.",
|
||||
Type: TypeBool,
|
||||
Type: flags.TypeBool,
|
||||
Default: "true",
|
||||
}
|
||||
}
|
||||
@@ -78,9 +101,13 @@ func waitlistDef(svc, display string) Def {
|
||||
// ensureWaitlistDef registers a service's switch if it is not already registered
|
||||
// (Mount registers the seed set with nicer labels; this covers runtime onboards).
|
||||
func ensureWaitlistDef(svc, display string) {
|
||||
if _, ok := lookupDef(waitlistKey(svc)); !ok {
|
||||
Register(waitlistDef(svc, display))
|
||||
key := waitlistKey(svc)
|
||||
for _, d := range flags.Defs() {
|
||||
if d.Key == key {
|
||||
return
|
||||
}
|
||||
}
|
||||
flags.Register(waitlistDef(svc, display))
|
||||
}
|
||||
|
||||
// boolDef is the minimal PostHog flag definition for a boolean switch value.
|
||||
@@ -92,26 +119,24 @@ func boolDef(on bool) json.RawMessage {
|
||||
}
|
||||
|
||||
// requireRegistry resolves the platform-tenant registry store, or an error when the
|
||||
// engine is not mounted (writes need it; the decide fail-opens instead).
|
||||
// gate is not mounted (writes need it; the decide fail-opens instead).
|
||||
func requireRegistry() (*waitlistStore, error) {
|
||||
c := mounted
|
||||
if c == nil || c.registry == nil {
|
||||
return nil, fmt.Errorf("flags: waitlist registry not mounted")
|
||||
if mounted == nil || mounted.store == nil {
|
||||
return nil, fmt.Errorf("admission: waitlist registry not mounted")
|
||||
}
|
||||
return c.registry.For(platformOrg, platformProject)
|
||||
return mounted.store.For(platformOrg, platformProject)
|
||||
}
|
||||
|
||||
// WaitlistModeForHost is THE decide the Enforce consumer, /v1/featuregate/mode, and
|
||||
// WaitlistModeForHost is THE decide the Enforce consumer, /v1/flags/waitlist, and
|
||||
// the admin board call: resolve host→service, then read the waitlist.<svc> switch
|
||||
// through the engine. FAIL-OPEN by construction — an unmounted registry, a store
|
||||
// through the flag engine. FAIL-OPEN by construction — an unmounted registry, a store
|
||||
// error, or an un-governed host all return known=false, so a request is NEVER gated
|
||||
// pre-boot or on a registry fault (availability over a hard gate, matching the guard).
|
||||
func WaitlistModeForHost(ctx context.Context, host string) (mode bool, service string, known bool) {
|
||||
c := mounted
|
||||
if c == nil || c.registry == nil {
|
||||
if mounted == nil || mounted.store == nil {
|
||||
return false, "", false
|
||||
}
|
||||
st, err := c.registry.For(platformOrg, platformProject)
|
||||
st, err := mounted.store.For(platformOrg, platformProject)
|
||||
if err != nil {
|
||||
return false, "", false
|
||||
}
|
||||
@@ -119,7 +144,7 @@ func WaitlistModeForHost(ctx context.Context, host string) (mode bool, service s
|
||||
if err != nil || !known {
|
||||
return false, "", false
|
||||
}
|
||||
return Bool(waitlistKey(svc)), svc, true
|
||||
return flags.Bool(waitlistKey(svc)), svc, true
|
||||
}
|
||||
|
||||
// ListWaitlistServices returns the admin board: every registered service with its LIVE
|
||||
@@ -135,19 +160,19 @@ func ListWaitlistServices(ctx context.Context) ([]ServiceView, error) {
|
||||
}
|
||||
out := make([]ServiceView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, ServiceView{ServiceRow: r, WaitlistMode: Bool(waitlistKey(r.Service))})
|
||||
out = append(out, ServiceView{ServiceRow: r, WaitlistMode: flags.Bool(waitlistKey(r.Service))})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetWaitlistMode flips one service's waitlist switch — the launch lever — and returns
|
||||
// the updated view. It is the ONE write path (through SetPlatformSwitch, audited in the
|
||||
// flag activity log); the flip is hot (this pod applies immediately, peers converge
|
||||
// within the eval TTL). ErrServiceNotFound when the slug is unknown.
|
||||
// the updated view. It is the ONE write path (through flags.SetPlatformSwitch, audited
|
||||
// in the flag activity log); the flip is hot (this pod applies immediately, peers
|
||||
// converge within the eval TTL). ErrServiceNotFound when the slug is unknown.
|
||||
func SetWaitlistMode(ctx context.Context, service string, mode bool, actor string) (ServiceView, error) {
|
||||
service = strings.ToLower(strings.TrimSpace(service))
|
||||
if service == "" {
|
||||
return ServiceView{}, fmt.Errorf("flags: service is required")
|
||||
return ServiceView{}, fmt.Errorf("admission: service is required")
|
||||
}
|
||||
st, err := requireRegistry()
|
||||
if err != nil {
|
||||
@@ -158,10 +183,10 @@ func SetWaitlistMode(ctx context.Context, service string, mode bool, actor strin
|
||||
return ServiceView{}, err
|
||||
}
|
||||
ensureWaitlistDef(service, row.DisplayName)
|
||||
if err := SetPlatformSwitch(waitlistKey(service), boolDef(mode), actor); err != nil {
|
||||
if err := flags.SetPlatformSwitch(waitlistKey(service), boolDef(mode), actor); err != nil {
|
||||
return ServiceView{}, err
|
||||
}
|
||||
return ServiceView{ServiceRow: row, WaitlistMode: Bool(waitlistKey(service))}, nil
|
||||
return ServiceView{ServiceRow: row, WaitlistMode: flags.Bool(waitlistKey(service))}, nil
|
||||
}
|
||||
|
||||
// UpsertWaitlistService onboards or edits a hosted service so a new host is governed
|
||||
@@ -170,7 +195,7 @@ func SetWaitlistMode(ctx context.Context, service string, mode bool, actor strin
|
||||
func UpsertWaitlistService(ctx context.Context, in ServiceInput, actor string) (ServiceView, error) {
|
||||
svc := strings.ToLower(strings.TrimSpace(in.Service))
|
||||
if svc == "" {
|
||||
return ServiceView{}, fmt.Errorf("flags: service slug is required")
|
||||
return ServiceView{}, fmt.Errorf("admission: service slug is required")
|
||||
}
|
||||
st, err := requireRegistry()
|
||||
if err != nil {
|
||||
@@ -192,43 +217,44 @@ func UpsertWaitlistService(ctx context.Context, in ServiceInput, actor string) (
|
||||
}
|
||||
ensureWaitlistDef(svc, row.DisplayName)
|
||||
if isNew {
|
||||
if err := SetPlatformSwitch(waitlistKey(svc), boolDef(in.WaitlistMode), actor); err != nil {
|
||||
if err := flags.SetPlatformSwitch(waitlistKey(svc), boolDef(in.WaitlistMode), actor); err != nil {
|
||||
return ServiceView{}, err
|
||||
}
|
||||
}
|
||||
return ServiceView{ServiceRow: row, WaitlistMode: Bool(waitlistKey(svc))}, nil
|
||||
return ServiceView{ServiceRow: row, WaitlistMode: flags.Bool(waitlistKey(svc))}, nil
|
||||
}
|
||||
|
||||
// mountWaitlist seeds the registry and registers a waitlist.<svc> switch per known
|
||||
// service. Best-effort + fail-safe: a registry error (e.g. cek master key not yet
|
||||
// injected) degrades to the in-memory seed switches — the decide then fail-opens,
|
||||
// exactly the flag engine's own boot posture. Called from Mount.
|
||||
func mountWaitlist(c *Client, brand string, log luxlog.Logger) {
|
||||
// seedRegistry seeds the registry and registers a waitlist.<svc> switch per known
|
||||
// service, COMPOSING the flag engine (flags.Register). Best-effort + fail-safe: a
|
||||
// registry error (e.g. cek master key not yet injected) degrades to the in-memory seed
|
||||
// switches — the decide then fail-opens, exactly the flag engine's own boot posture.
|
||||
// Returns the number of seeded services (for the mount log). Called from Mount.
|
||||
func seedRegistry(brand string, log luxlog.Logger) int {
|
||||
seed := seedWaitlist(brand)
|
||||
for _, sv := range seed { // in-memory switches — always succeeds
|
||||
Register(waitlistDef(sv.Service, sv.DisplayName))
|
||||
flags.Register(waitlistDef(sv.Service, sv.DisplayName))
|
||||
}
|
||||
st, err := c.registry.For(platformOrg, platformProject)
|
||||
st, err := mounted.store.For(platformOrg, platformProject)
|
||||
if err != nil {
|
||||
log.Warn("waitlist registry unavailable — modes degrade to seed defaults", "err", err)
|
||||
return
|
||||
return len(seed)
|
||||
}
|
||||
if _, err := st.Seed(context.Background(), seed, time.Now().Unix()); err != nil {
|
||||
log.Warn("waitlist registry seed failed", "err", err)
|
||||
return
|
||||
return len(seed)
|
||||
}
|
||||
if rows, err := st.List(context.Background()); err == nil {
|
||||
for _, r := range rows { // register any persisted onboard beyond the seed
|
||||
ensureWaitlistDef(r.Service, r.DisplayName)
|
||||
}
|
||||
}
|
||||
return len(seed)
|
||||
}
|
||||
|
||||
// waitlistModeRoute answers GET /v1/featuregate/mode?host=<h> — the runtime lookup the
|
||||
// waitlistModeRoute answers GET /v1/flags/waitlist?host=<h> — the runtime lookup the
|
||||
// @file waitlist-guard caches. Public (in-cluster) read: it returns ONLY the boolean
|
||||
// mode for the ONE queried host, never an enumeration. Same wire shape as the former
|
||||
// featuregate route, so the interim guard ports 1:1.
|
||||
func waitlistModeRoute(_ *cloud.Service[state], c *zip.Ctx) error {
|
||||
// mode for the ONE queried host, never an enumeration.
|
||||
func waitlistModeRoute(c *zip.Ctx) error {
|
||||
host := strings.TrimSpace(c.Query("host"))
|
||||
if host == "" {
|
||||
host = c.Fiber().Hostname()
|
||||
@@ -242,7 +268,44 @@ func waitlistModeRoute(_ *cloud.Service[state], c *zip.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ── brand seed (moved verbatim from the former featuregate/seed.go) ──────────────
|
||||
// ── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mount installs the launch-control gate: it opens the platform-tenant host→service
|
||||
// registry, seeds it for the deployment brand, registers a waitlist.<svc> switch per
|
||||
// service in the flag engine (flags.Register), and serves the guard's public mode read
|
||||
// at /v1/flags/waitlist. Fail-safe: a registry error (e.g. cek master key not yet
|
||||
// injected) degrades to the in-memory seed switches — WaitlistModeForHost then
|
||||
// fail-opens. Mounts AFTER flags so the engine's platform-switch plane is installed first.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("admission.Mount: nil deps.Logger")
|
||||
}
|
||||
if deps.DataDir == "" {
|
||||
return fmt.Errorf("admission.Mount: empty deps.DataDir")
|
||||
}
|
||||
log := deps.Logger.New("subsystem", "admission")
|
||||
mounted = ®istryState{
|
||||
store: cloud.NewOrgStore[*waitlistStore](deps.DataDir, "waitlist", openWaitlistStore),
|
||||
brand: deps.Brand,
|
||||
}
|
||||
n := seedRegistry(deps.Brand, log)
|
||||
// The guard's public runtime mode read (host→service→waitlist.<svc>), one namespace
|
||||
// under /v1/flags. Exempt from the Enforce gate (see defaultExemptPrefixes) so a
|
||||
// gated user can still resolve mode.
|
||||
app.Get("/v1/flags/waitlist", waitlistModeRoute)
|
||||
log.Info("admission gate ready", "services", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown closes the launch registry's per-org store handles.
|
||||
func Shutdown() error {
|
||||
if mounted == nil || mounted.store == nil {
|
||||
return nil
|
||||
}
|
||||
return mounted.store.CloseAll()
|
||||
}
|
||||
|
||||
// ── brand seed (moved verbatim from the former flags/waitlist.go) ────────────────
|
||||
|
||||
// seedWaitlist returns the launch registry for a brand. White-labeled so a Lux/Zoo/Pars
|
||||
// deployment governs its OWN hosts. New hosted services onboard at runtime via
|
||||
+27
-136
@@ -1,153 +1,44 @@
|
||||
package affiliates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
"github.com/hanzoai/cloud/clients/payout"
|
||||
)
|
||||
|
||||
// commerce is the narrow money seam the affiliate loop needs: read a referred
|
||||
// org's metered spend (the accrual base) and grant a promo credit to a wallet (a
|
||||
// payout made in credits). It is an INTERFACE so the store/handler logic is
|
||||
// testable with a fake ledger — the HTTP impl below is the ONE production binding.
|
||||
// commerce is the narrow money seam the affiliate loop needs: read a referred org's
|
||||
// metered spend (the commission accrual base) and grant a promo credit to a wallet
|
||||
// (a payout made in credits, ledger tag grant:affiliate). It is an INTERFACE so the
|
||||
// store/handler logic is testable with a fake ledger; the production binding is
|
||||
// clients/payout, reached through the thin adapter below.
|
||||
//
|
||||
// This mirrors clients/referrals/commerce.go EXACTLY (which itself mirrors
|
||||
// clients/admin/commerce.go): the same COMMERCE_SERVICE_TOKEN S2S path, the same
|
||||
// X-Org-Id=<org> namespace + bare org `user` subject that admin.grantCredit uses —
|
||||
// so an affiliate payout-in-credits lands in precisely the wallet the balance
|
||||
// panel reads, indistinguishable from an admin grant except by its ledger tag
|
||||
// (grant:affiliate vs grant:referral / grant:admin, all → the commerce Credit/trial
|
||||
// bucket per DepositKind's grant:* rule).
|
||||
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
|
||||
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
|
||||
// clients/payout. An affiliate payout-in-credits still lands in precisely the wallet
|
||||
// the balance panel reads, indistinguishable from an admin grant except by its
|
||||
// grant:affiliate tag.
|
||||
type commerce interface {
|
||||
configured() bool
|
||||
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
|
||||
// grant:affiliate tag) and returns the ledger transaction id.
|
||||
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
|
||||
// spendCents is a referred org's month-to-date metered consumption — the
|
||||
// commission accrual base (spend × the affiliate's rate).
|
||||
spendCents(ctx context.Context, org, user string) (int64, error)
|
||||
}
|
||||
|
||||
// errUnconfigured is returned by a deposit against an unwired commerce so the
|
||||
// caller records an honest failure rather than reporting a phantom payout.
|
||||
var errUnconfigured = errors.New("affiliates: commerce endpoint not configured")
|
||||
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
|
||||
// returns, so the caller records an honest failure rather than a phantom payout.
|
||||
var errUnconfigured = payout.ErrUnconfigured
|
||||
|
||||
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
|
||||
type httpCommerce struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
|
||||
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
|
||||
// delegation; the money path lives in clients/payout.
|
||||
type commerceSeam struct{ c *payout.Client }
|
||||
|
||||
func (s commerceSeam) configured() bool { return s.c.Configured() }
|
||||
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
|
||||
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
|
||||
}
|
||||
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
|
||||
return s.c.SpendCents(ctx, org, user)
|
||||
}
|
||||
|
||||
func newCommerceClient(base, token string) *httpCommerce {
|
||||
return &httpCommerce{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: commerceinproc.Client(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *httpCommerce) configured() bool { return c != nil && c.base != "" && c.token != "" }
|
||||
|
||||
// deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical
|
||||
// to admin.commerceClient.deposit). Commerce's EdgeAuth pins the body `user` to
|
||||
// the X-Org-Id subject, so a payout can never be mis-targeted to another wallet.
|
||||
func (c *httpCommerce) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
|
||||
if !c.configured() {
|
||||
return "", errUnconfigured
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"user": user,
|
||||
"currency": currency,
|
||||
"amount": amountCents,
|
||||
"notes": notes,
|
||||
"tags": tags,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("commerce deposit decode: %w", err)
|
||||
}
|
||||
return out.TransactionID, nil
|
||||
}
|
||||
|
||||
// spendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero
|
||||
// (not an error) when commerce is unconfigured so a partial deploy degrades to
|
||||
// "no spend to accrue yet" rather than a 5xx.
|
||||
func (c *httpCommerce) spendCents(ctx context.Context, org, user string) (int64, error) {
|
||||
if !c.configured() {
|
||||
return 0, nil
|
||||
}
|
||||
q := url.Values{"user": {user}}
|
||||
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var out struct {
|
||||
ConsumedCents int64 `json:"consumedCents"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return 0, fmt.Errorf("commerce rollup decode: %w", err)
|
||||
}
|
||||
return out.ConsumedCents, nil
|
||||
}
|
||||
|
||||
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
|
||||
// namespace selector commerce's EdgeAuth trusts only behind the service token.
|
||||
func (c *httpCommerce) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
|
||||
u := c.base + path
|
||||
if enc := q.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
r = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commerce unreachable: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// newCommerceClient builds the production binding, delegating to clients/payout.
|
||||
func newCommerceClient(base, token string) commerce { return commerceSeam{payout.NewClient(base, token)} }
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
hz "github.com/hanzoai/agent"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/tools"
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
openai "github.com/hanzoai/go-openai"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -71,6 +71,44 @@ func OpenSession(ctx context.Context, org, actor, agent, title string) (string,
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// OpenSessionOn is OpenSession with the run's dispatch TARGET recorded, so
|
||||
// mission-control shows a routed run on the machine it was sent to (session.target
|
||||
// == the target id) exactly as a locally-linked run shows its host. The target is
|
||||
// re-resolved org-scoped and MUST belong to this org — a session can never claim
|
||||
// to run on another tenant's machine (the same fail-closed rule sessionContext
|
||||
// enforces on the HTTP register path). An empty target falls back to OpenSession.
|
||||
func OpenSessionOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return OpenSession(ctx, org, actor, agent, title)
|
||||
}
|
||||
if mounted == nil {
|
||||
return "", fmt.Errorf("agents: not mounted")
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" {
|
||||
return "", fmt.Errorf("agents: org required")
|
||||
}
|
||||
if _, err := mounted.State.store.GetTarget(ctx, org, target); err != nil {
|
||||
if err == errTargetNotFound {
|
||||
return "", fmt.Errorf("agents: target not found in this org")
|
||||
}
|
||||
return "", fmt.Errorf("agents: resolve target: %w", err)
|
||||
}
|
||||
id, err := OpenSession(ctx, org, actor, agent, title)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Stamp the target onto the freshly-opened row (org-scoped update); a failure
|
||||
// here is non-fatal — the session is live, it simply lacks its machine tag.
|
||||
if x, gerr := mounted.State.store.GetSession(ctx, org, id); gerr == nil {
|
||||
x.Target = target
|
||||
x.UpdatedAt = time.Now().Unix()
|
||||
_ = mounted.State.store.UpdateSession(ctx, x)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// LogSessionEvent appends one ordered event (message|tool-call|spawn|log|status|
|
||||
// control) to an org's session and fans it out live. The (org, id) pair is
|
||||
// re-resolved so a caller can only write to a session THIS org owns; kind is
|
||||
|
||||
@@ -114,3 +114,50 @@ func TestInproc_NotMounted_FailsClosed(t *testing.T) {
|
||||
t.Fatal("unmounted OpenSession must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveTarget turns a human's reference (id or friendly label) into the org's
|
||||
// target, org-scoped and fail-closed: an id wins, else an exact case-folded label,
|
||||
// and a reference matching neither — or another org's machine — is not found.
|
||||
func TestResolveTarget_IdThenLabel_OrgScoped(t *testing.T) {
|
||||
mountInproc(t)
|
||||
ctx := context.Background()
|
||||
now := int64(1000)
|
||||
acme := Target{ID: "tgt_acme1", Org: "acme", Label: "evo", Kind: TargetGPU, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
|
||||
evil := Target{ID: "tgt_evil1", Org: "evil", Label: "evo", Kind: TargetGPU, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
|
||||
if err := mounted.State.store.CreateTarget(ctx, acme); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mounted.State.store.CreateTarget(ctx, evil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// By id.
|
||||
if got, err := ResolveTarget(ctx, "acme", "tgt_acme1"); err != nil || got.ID != "tgt_acme1" {
|
||||
t.Fatalf("resolve by id: %+v %v", got, err)
|
||||
}
|
||||
// By label (case-folded), scoped to the caller's org — never evil's same-labelled box.
|
||||
if got, err := ResolveTarget(ctx, "acme", "EVO"); err != nil || got.ID != "tgt_acme1" {
|
||||
t.Fatalf("resolve by label must find acme's own, got %+v %v", got, err)
|
||||
}
|
||||
// Another org's id is not found (no cross-tenant leak).
|
||||
if _, err := ResolveTarget(ctx, "acme", "tgt_evil1"); err != errTargetNotFound {
|
||||
t.Fatalf("cross-org id must be not-found, got %v", err)
|
||||
}
|
||||
// An unknown reference is not found — the caller renders an honest error.
|
||||
if _, err := ResolveTarget(ctx, "acme", "nope"); err != errTargetNotFound {
|
||||
t.Fatalf("unknown ref must be not-found, got %v", err)
|
||||
}
|
||||
// Empty ref is not found (never resolves to "some" machine).
|
||||
if _, err := ResolveTarget(ctx, "acme", ""); err != errTargetNotFound {
|
||||
t.Fatalf("empty ref must be not-found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTarget_NotMounted_FailsClosed(t *testing.T) {
|
||||
prev := mounted
|
||||
mounted = nil
|
||||
t.Cleanup(func() { mounted = prev })
|
||||
if _, err := ResolveTarget(context.Background(), "acme", "evo"); err == nil {
|
||||
t.Fatal("unmounted ResolveTarget must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// mailbox.go is the LIVE hand-off between a routed run's durable owner (the
|
||||
// coding RoutedRunWorkflow, running on the embedded tasks engine) and the
|
||||
// external machine that claims and executes it over HTTP. It is the rendezvous
|
||||
// ONLY — never the durable queue. The tasks engine is the queue of record: it
|
||||
// survives a cloud restart, times a never-claimed run out, and retries. On every
|
||||
// (re)start of the delivery activity the run is (re-)Offered here, so a machine
|
||||
// that long-polls Claim always finds work the engine still owns; a cloud restart
|
||||
// simply re-populates the mailbox from durable history.
|
||||
//
|
||||
// ISOLATION IS STRUCTURAL. Every offer is filed under the key (org, target), and
|
||||
// Claim/Report only ever touch that one key's slot. A run offered for (orgB,
|
||||
// targetY) is unreachable from a Claim or Report for (orgA, targetX) — the tenant
|
||||
// + machine boundary is a property of the map key, not a check a caller can skip.
|
||||
|
||||
// RoutedRun is the NON-SECRET spec of one coding run dispatched to a target. It
|
||||
// carries no credential by design: the executing machine authenticates git +
|
||||
// model routing with its OWN already-held credentials (the same ones `hanzo code`
|
||||
// uses), so no secret ever enters the durable store or crosses to the machine in
|
||||
// the claim response. Everything here is safe to persist in the tasks engine.
|
||||
type RoutedRun struct {
|
||||
Org string `json:"org"`
|
||||
TargetID string `json:"targetId"`
|
||||
SessionID string `json:"sessionId"` // the live session opened at dispatch; the machine streams into it
|
||||
Repo string `json:"repo"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Base string `json:"base,omitempty"`
|
||||
Branch string `json:"branch"`
|
||||
Prompt string `json:"prompt"`
|
||||
CloneURL string `json:"cloneUrl"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
}
|
||||
|
||||
// RoutedResult is a routed run's terminal outcome, reported by the machine and
|
||||
// returned to the durable activity so the workflow completes.
|
||||
type RoutedResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Changed bool `json:"changed"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
CommitSha string `json:"commitSha,omitempty"`
|
||||
Diffstat string `json:"diffstat,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// offer is one run waiting to be claimed, plus the channel its durable owner
|
||||
// blocks on for the terminal result. result is buffered(1) so Report never blocks
|
||||
// even if the owner is between selects; closed fires when the offer is finished
|
||||
// (reported OR abandoned) so a waiter always unblocks.
|
||||
type offer struct {
|
||||
mb *mailbox
|
||||
key string // (org,target)
|
||||
rk string // (org,target,sessionID)
|
||||
run RoutedRun
|
||||
result chan RoutedResult
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Await blocks until the machine reports this run's result, the offer is
|
||||
// abandoned, or ctx (the activity's StartToClose budget) fires. It is the
|
||||
// durable owner's half of the rendezvous.
|
||||
func (o *offer) Await(ctx context.Context) (RoutedResult, bool) {
|
||||
select {
|
||||
case res := <-o.result:
|
||||
return res, true
|
||||
case <-o.closed:
|
||||
// Abandoned or reported-then-closed: drain a delivered result if one raced in.
|
||||
select {
|
||||
case res := <-o.result:
|
||||
return res, true
|
||||
default:
|
||||
return RoutedResult{}, false
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return RoutedResult{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// Close removes the offer from the mailbox (if still present) and unblocks any
|
||||
// waiter. Idempotent — the durable owner defers it so a timed-out or crashed
|
||||
// delivery never leaks a queued or claimed offer.
|
||||
func (o *offer) Close() { o.mb.discard(o) }
|
||||
|
||||
// mailbox is the process-wide rendezvous. queues holds each key's FIFO of
|
||||
// unclaimed offers; byRun indexes every live offer by (org,target,sessionID) for
|
||||
// Report + re-offer dedupe; signal is a per-key broadcast channel (closed and
|
||||
// recreated on Offer) that Claim waits on.
|
||||
type mailbox struct {
|
||||
mu sync.Mutex
|
||||
queues map[string][]*offer
|
||||
byRun map[string]*offer
|
||||
signal map[string]chan struct{}
|
||||
}
|
||||
|
||||
func newMailbox() *mailbox {
|
||||
return &mailbox{
|
||||
queues: map[string][]*offer{},
|
||||
byRun: map[string]*offer{},
|
||||
signal: map[string]chan struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// routedMailbox is the ONE process-wide rendezvous, shared by the coding
|
||||
// delivery activity (Offer/Await) and the machine-facing HTTP surface
|
||||
// (Claim/Report). One mailbox, one way.
|
||||
var routedMailbox = newMailbox()
|
||||
|
||||
func mbKey(org, target string) string { return org + "\x00" + target }
|
||||
func runKey(org, target, sess string) string { return org + "\x00" + target + "\x00" + sess }
|
||||
|
||||
// Offer files run for its (org,target) and returns the handle its durable owner
|
||||
// awaits. A re-offer of the same (org,target,sessionID) — the workflow retrying
|
||||
// or replaying after a restart — supersedes the stale prior offer (removing it
|
||||
// from the queue and unblocking its dead waiter) so a machine never claims a run
|
||||
// whose owner has already moved on.
|
||||
func (m *mailbox) Offer(run RoutedRun) *offer {
|
||||
key := mbKey(run.Org, run.TargetID)
|
||||
rk := runKey(run.Org, run.TargetID, run.SessionID)
|
||||
o := &offer{mb: m, key: key, rk: rk, run: run, result: make(chan RoutedResult, 1), closed: make(chan struct{})}
|
||||
m.mu.Lock()
|
||||
if prev := m.byRun[rk]; prev != nil {
|
||||
m.removeFromQueueLocked(key, prev)
|
||||
prev.finish()
|
||||
}
|
||||
m.byRun[rk] = o
|
||||
m.queues[key] = append(m.queues[key], o)
|
||||
m.broadcastLocked(key)
|
||||
m.mu.Unlock()
|
||||
return o
|
||||
}
|
||||
|
||||
// Claim blocks until an unclaimed run exists for (org,target) or ctx fires,
|
||||
// returning the oldest. The claimed offer leaves the queue but stays in byRun,
|
||||
// awaiting Report. Only this key's queue is ever read, so a claim can never
|
||||
// surface another tenant's or another machine's run.
|
||||
func (m *mailbox) Claim(ctx context.Context, org, target string) (RoutedRun, bool) {
|
||||
key := mbKey(org, target)
|
||||
for {
|
||||
m.mu.Lock()
|
||||
if q := m.queues[key]; len(q) > 0 {
|
||||
o := q[0]
|
||||
m.queues[key] = q[1:]
|
||||
m.mu.Unlock()
|
||||
return o.run, true
|
||||
}
|
||||
sig := m.signalLocked(key)
|
||||
m.mu.Unlock()
|
||||
select {
|
||||
case <-sig:
|
||||
// a new offer (or a superseding one) arrived — re-check
|
||||
case <-ctx.Done():
|
||||
return RoutedRun{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report delivers a terminal result to the run's durable owner. Scoped to
|
||||
// (org,target,sessionID): a report can only ever complete a run that exact key
|
||||
// owns, so one machine can never report on behalf of another. Returns false when
|
||||
// no live offer matches (already reported, abandoned, or never existed).
|
||||
func (m *mailbox) Report(org, target, sess string, res RoutedResult) bool {
|
||||
rk := runKey(org, target, sess)
|
||||
m.mu.Lock()
|
||||
o := m.byRun[rk]
|
||||
if o == nil {
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
delete(m.byRun, rk)
|
||||
m.removeFromQueueLocked(mbKey(org, target), o)
|
||||
m.mu.Unlock()
|
||||
o.deliver(res)
|
||||
return true
|
||||
}
|
||||
|
||||
// discard drops an offer the owner is done with (ctx timeout / crash / normal
|
||||
// close) so neither the queue nor byRun retains it.
|
||||
func (m *mailbox) discard(o *offer) {
|
||||
m.mu.Lock()
|
||||
if m.byRun[o.rk] == o {
|
||||
delete(m.byRun, o.rk)
|
||||
}
|
||||
m.removeFromQueueLocked(o.key, o)
|
||||
m.mu.Unlock()
|
||||
o.finish()
|
||||
}
|
||||
|
||||
func (m *mailbox) removeFromQueueLocked(key string, o *offer) {
|
||||
q := m.queues[key]
|
||||
for i, e := range q {
|
||||
if e == o {
|
||||
m.queues[key] = append(q[:i:i], q[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastLocked wakes every Claim waiting on key by closing its signal channel;
|
||||
// a fresh channel replaces it for the next wait.
|
||||
func (m *mailbox) broadcastLocked(key string) {
|
||||
if ch, ok := m.signal[key]; ok {
|
||||
close(ch)
|
||||
delete(m.signal, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mailbox) signalLocked(key string) chan struct{} {
|
||||
ch, ok := m.signal[key]
|
||||
if !ok {
|
||||
ch = make(chan struct{})
|
||||
m.signal[key] = ch
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (o *offer) deliver(res RoutedResult) {
|
||||
o.once.Do(func() {
|
||||
o.result <- res // buffered(1) — never blocks
|
||||
close(o.closed)
|
||||
})
|
||||
}
|
||||
|
||||
func (o *offer) finish() {
|
||||
o.once.Do(func() { close(o.closed) })
|
||||
}
|
||||
|
||||
// OfferRoutedRun is the exported seam the coding delivery activity uses to place
|
||||
// a run into the live rendezvous. Kept here (agents owns targets + sessions) so
|
||||
// the machine-facing HTTP surface and the durable activity share ONE mailbox.
|
||||
func OfferRoutedRun(run RoutedRun) *offer { return routedMailbox.Offer(run) }
|
||||
@@ -0,0 +1,166 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mkRun(org, target, sess string) RoutedRun {
|
||||
return RoutedRun{Org: org, TargetID: target, SessionID: sess, Repo: "api", Branch: "agent/" + sess}
|
||||
}
|
||||
|
||||
// A claimed run comes back to exactly one claimer, then its report reaches the
|
||||
// offerer that is awaiting it.
|
||||
func TestMailbox_OfferClaimReport(t *testing.T) {
|
||||
m := newMailbox()
|
||||
off := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
|
||||
|
||||
got, ok := m.Claim(context.Background(), "acme", "tgt_1")
|
||||
if !ok || got.SessionID != "sess_1" {
|
||||
t.Fatalf("claim wrong: ok=%v run=%+v", ok, got)
|
||||
}
|
||||
|
||||
done := make(chan RoutedResult, 1)
|
||||
go func() {
|
||||
res, _ := off.Await(context.Background())
|
||||
done <- res
|
||||
}()
|
||||
if !m.Report("acme", "tgt_1", "sess_1", RoutedResult{OK: true, CommitSha: "abc"}) {
|
||||
t.Fatal("report should deliver to the awaiting offer")
|
||||
}
|
||||
select {
|
||||
case res := <-done:
|
||||
if !res.OK || res.CommitSha != "abc" {
|
||||
t.Fatalf("await got wrong result: %+v", res)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("await never received the reported result")
|
||||
}
|
||||
}
|
||||
|
||||
// THE tenant + machine boundary: a claim for (org,target) can NEVER surface a run
|
||||
// offered for a different org OR a different target — it is a property of the key.
|
||||
func TestMailbox_CrossTenantAndCrossMachineIsolation(t *testing.T) {
|
||||
m := newMailbox()
|
||||
m.Offer(mkRun("orgB", "tgt_Y", "sess_foreign_org"))
|
||||
m.Offer(mkRun("acme", "tgt_Y", "sess_foreign_machine"))
|
||||
m.Offer(mkRun("acme", "tgt_X", "sess_mine"))
|
||||
|
||||
// A claim for (acme, tgt_X) gets ONLY acme/tgt_X's run.
|
||||
got, ok := m.Claim(context.Background(), "acme", "tgt_X")
|
||||
if !ok || got.SessionID != "sess_mine" {
|
||||
t.Fatalf("claim leaked across a boundary: ok=%v run=%+v", ok, got)
|
||||
}
|
||||
// And that queue is now empty — no foreign run fell through.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, ok := m.Claim(ctx, "acme", "tgt_X"); ok {
|
||||
t.Fatal("a foreign run must never be claimable as acme/tgt_X")
|
||||
}
|
||||
|
||||
// A Report can only complete a run under its exact key: reporting the foreign
|
||||
// machine's session under tgt_X does nothing.
|
||||
if m.Report("acme", "tgt_X", "sess_foreign_machine", RoutedResult{OK: true}) {
|
||||
t.Fatal("report crossed the machine boundary")
|
||||
}
|
||||
if m.Report("acme", "tgt_Y", "sess_foreign_org", RoutedResult{OK: true}) {
|
||||
t.Fatal("report crossed the org boundary")
|
||||
}
|
||||
}
|
||||
|
||||
// Two racing claimers, one run: exactly one wins.
|
||||
func TestMailbox_NoDoubleClaim(t *testing.T) {
|
||||
m := newMailbox()
|
||||
m.Offer(mkRun("acme", "tgt_1", "sess_1"))
|
||||
|
||||
var wins int
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, ok := m.Claim(ctx, "acme", "tgt_1"); ok {
|
||||
mu.Lock()
|
||||
wins++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if wins != 1 {
|
||||
t.Fatalf("exactly one claimer must win, got %d", wins)
|
||||
}
|
||||
}
|
||||
|
||||
// A claim with no work times out on ctx and reports no run — fail closed, never hang.
|
||||
func TestMailbox_ClaimTimesOut(t *testing.T) {
|
||||
m := newMailbox()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, ok := m.Claim(ctx, "acme", "tgt_empty"); ok {
|
||||
t.Fatal("an empty mailbox must not yield a run")
|
||||
}
|
||||
}
|
||||
|
||||
// The durable owner's Await unblocks (fail-closed) when its budget ctx fires with
|
||||
// no report — the machine never claimed, or claimed and died.
|
||||
func TestMailbox_AwaitFailsClosedOnDeadline(t *testing.T) {
|
||||
m := newMailbox()
|
||||
off := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, ok := off.Await(ctx); ok {
|
||||
t.Fatal("await must fail closed when the deadline fires without a report")
|
||||
}
|
||||
}
|
||||
|
||||
// A re-offer of the same run (workflow retry / cloud restart) supersedes the stale
|
||||
// offer: the old waiter unblocks abandoned, and the fresh run is claimable.
|
||||
func TestMailbox_ReOfferSupersedes(t *testing.T) {
|
||||
m := newMailbox()
|
||||
old := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
|
||||
|
||||
// re-offer BEFORE anyone claims the first
|
||||
fresh := m.Offer(mkRun("acme", "tgt_1", "sess_1"))
|
||||
|
||||
// old is abandoned
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if _, ok := old.Await(ctx); ok {
|
||||
t.Fatal("the superseded offer must not complete")
|
||||
}
|
||||
|
||||
// exactly one claimable run remains, and reporting reaches the fresh offer
|
||||
got, ok := m.Claim(context.Background(), "acme", "tgt_1")
|
||||
if !ok || got.SessionID != "sess_1" {
|
||||
t.Fatalf("fresh run not claimable: %+v", got)
|
||||
}
|
||||
if _, ok := m.Claim(ctxShort(), "acme", "tgt_1"); ok {
|
||||
t.Fatal("the stale offer must not have left a duplicate in the queue")
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { fresh.Await(context.Background()); close(done) }()
|
||||
if !m.Report("acme", "tgt_1", "sess_1", RoutedResult{OK: true}) {
|
||||
t.Fatal("report must reach the fresh offer")
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
// Report for an unknown/already-finished run is a clean false.
|
||||
func TestMailbox_ReportUnknownIsNoOp(t *testing.T) {
|
||||
m := newMailbox()
|
||||
if m.Report("acme", "tgt_1", "nope", RoutedResult{OK: true}) {
|
||||
t.Fatal("report for an unknown run must be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func ctxShort() context.Context {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
|
||||
_ = cancel
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// routing.go is the machine-identity + liveness plane for routed runs (#48 half
|
||||
// B). A run dispatched to a target is executed by an EXTERNAL machine (`hanzo
|
||||
// code --serve`) that claims it over HTTP. Two properties make that safe:
|
||||
//
|
||||
// - MACHINE IDENTITY. A target carries a claim key — a high-entropy capability
|
||||
// minted server-side, returned to the daemon ONCE, and stored only as a
|
||||
// SHA-256 hash (never plaintext, like every other secret). A claim/report
|
||||
// must present the key; cloud verifies it in constant time, scoped to
|
||||
// (org, target). Possession of the key IS being that machine, so one machine
|
||||
// can never claim another's runs even within the same org.
|
||||
//
|
||||
// - LIVENESS. Dispatch routes ONLY to a target with a live runner. A serve
|
||||
// daemon proves liveness by polling Claim, which stamps serving_at; the gate
|
||||
// rejects a target whose last poll is older than servingTTL. A run to a dead
|
||||
// or absent runner fails closed at dispatch (never silently runs elsewhere),
|
||||
// and one that dies mid-flight is re-queued/timed-out by the durable owner.
|
||||
//
|
||||
// The claim key lives in its own table so the live (A) target CRUD is untouched.
|
||||
|
||||
const (
|
||||
// servingTTL bounds how stale a target's last claim poll may be and still be
|
||||
// considered "a runner is listening". The serve daemon re-polls right after a
|
||||
// 25s long-poll returns empty, so a healthy runner stamps well inside this.
|
||||
servingTTL = 90 * time.Second
|
||||
|
||||
claimKeyPrefix = "tgtk_"
|
||||
claimKeyBytes = 32 // 256-bit capability
|
||||
maxClaimKey = 128
|
||||
)
|
||||
|
||||
var (
|
||||
errNoClaimKey = errors.New("agents: target has no claim key")
|
||||
errClaimKeyBad = errors.New("agents: claim key mismatch")
|
||||
errTargetNotLive = errors.New("agents: target has no live runner")
|
||||
errTargetNotReady = errors.New("agents: target is not online")
|
||||
)
|
||||
|
||||
// migrateClaimKeys creates the per-target claim-key + serving-liveness table in
|
||||
// the SAME agents.db (one store, one tenancy column). Idempotent.
|
||||
func (s *Store) migrateClaimKeys() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS agent_target_claim_keys (
|
||||
org TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
serving_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (org, target_id)
|
||||
);`
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("migrate claim keys: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertClaimKeyHash stores (or rotates) a target's claim-key hash. serving_at is
|
||||
// reset to 0 on a fresh mint — the daemon proves liveness by its first poll.
|
||||
func (s *Store) UpsertClaimKeyHash(ctx context.Context, org, targetID, hash string, now int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO agent_target_claim_keys (org,target_id,key_hash,serving_at,updated_at)
|
||||
VALUES (?,?,?,0,?)
|
||||
ON CONFLICT(org,target_id) DO UPDATE SET key_hash=excluded.key_hash, serving_at=0, updated_at=excluded.updated_at`,
|
||||
org, targetID, hash, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert claim key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClaimKeyHash returns a target's stored hash + last serving stamp, or
|
||||
// errNoClaimKey when none was ever minted.
|
||||
func (s *Store) ClaimKeyHash(ctx context.Context, org, targetID string) (hash string, servingAt int64, err error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT key_hash, serving_at FROM agent_target_claim_keys WHERE org=? AND target_id=?`, org, targetID)
|
||||
err = row.Scan(&hash, &servingAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", 0, errNoClaimKey
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("get claim key: %w", err)
|
||||
}
|
||||
return hash, servingAt, nil
|
||||
}
|
||||
|
||||
// StampServing records that a target's runner polled at now (its liveness
|
||||
// heartbeat). Best-effort by the caller; a missing row is a no-op.
|
||||
func (s *Store) StampServing(ctx context.Context, org, targetID string, now int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE agent_target_claim_keys SET serving_at=? WHERE org=? AND target_id=?`, now, org, targetID)
|
||||
return err
|
||||
}
|
||||
|
||||
// hashClaimKey is the at-rest form: SHA-256 hex of a high-entropy token. A random
|
||||
// 256-bit key needs no password KDF; SHA-256 gives a fixed-size, constant-time-
|
||||
// comparable digest and the plaintext is never stored.
|
||||
func hashClaimKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// newClaimKey mints a fresh capability token.
|
||||
func newClaimKey() (string, error) {
|
||||
b := make([]byte, claimKeyBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return claimKeyPrefix + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// verifyClaimKey checks a presented key against the target's stored hash in
|
||||
// constant time. Fail-closed: no key on file, or an empty presented key, is a
|
||||
// mismatch — never an accidental pass.
|
||||
func (s *Store) verifyClaimKey(ctx context.Context, org, targetID, presented string) error {
|
||||
presented = strings.TrimSpace(presented)
|
||||
if presented == "" || len(presented) > maxClaimKey {
|
||||
return errClaimKeyBad
|
||||
}
|
||||
stored, _, err := s.ClaimKeyHash(ctx, org, targetID)
|
||||
if err != nil {
|
||||
return err // errNoClaimKey or a real DB error
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(stored), []byte(hashClaimKey(presented))) != 1 {
|
||||
return errClaimKeyBad
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TargetDispatchable is the DRY liveness gate, used at dispatch (fail closed
|
||||
// before enqueue) AND re-checked at claim. A run is dispatchable only to a target
|
||||
// that (a) exists in this org, (b) is online, and (c) has a live runner — a claim
|
||||
// poll within servingTTL. Any failure is an explicit error the dispatcher renders
|
||||
// honestly; it NEVER falls back to running elsewhere.
|
||||
func (s *Store) TargetDispatchable(ctx context.Context, org, targetID string) error {
|
||||
t, err := s.GetTarget(ctx, org, targetID)
|
||||
if err != nil {
|
||||
return err // errTargetNotFound or a real DB error
|
||||
}
|
||||
if t.Status != TargetOnline {
|
||||
return errTargetNotReady
|
||||
}
|
||||
_, servingAt, err := s.ClaimKeyHash(ctx, org, targetID)
|
||||
if err != nil {
|
||||
return errTargetNotLive // no claim key => no runner ever attached
|
||||
}
|
||||
if servingAt <= 0 || time.Now().Unix()-servingAt > int64(servingTTL/time.Second) {
|
||||
return errTargetNotLive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TargetDispatchable is the exported gate the coding dispatcher injects (it never
|
||||
// imports the store directly). Returns nil when a run may be routed to (org,
|
||||
// targetID), else a descriptive error.
|
||||
func TargetDispatchable(ctx context.Context, org, targetID string) error {
|
||||
if mounted == nil {
|
||||
return fmt.Errorf("agents: not mounted")
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
if org == "" || targetID == "" {
|
||||
return fmt.Errorf("agents: org and target required")
|
||||
}
|
||||
return mounted.State.store.TargetDispatchable(ctx, org, targetID)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// routing_http.go is the machine-facing surface a `hanzo code --serve` daemon
|
||||
// uses to CLAIM and complete routed runs. Every route is BOTH org-scoped (the
|
||||
// gateway-minted X-Org-Id, exactly like the rest of the targets plane) AND
|
||||
// machine-authenticated (the target claim key in X-Target-Key): a caller must
|
||||
// prove it is acting in the target's org and that it holds that specific
|
||||
// machine's capability. A run offered to target X is never reachable from a claim
|
||||
// for target Y, and a claim for another org's target 404s at the org boundary.
|
||||
//
|
||||
// POST /v1/agents/targets/:id/claim-key mint/rotate this target's claim key -> {claimKey}
|
||||
// POST /v1/agents/targets/:id/claim long-poll for the next routed run (X-Target-Key)
|
||||
// POST /v1/agents/targets/:id/runs/:runId/report report a routed run's terminal result (X-Target-Key)
|
||||
|
||||
// claimLongPoll bounds one Claim wait; on expiry the daemon gets 204 and re-polls
|
||||
// immediately, which also refreshes its serving liveness. A var (not a const) so a
|
||||
// test can shrink the empty-poll window without waiting the full window.
|
||||
var claimLongPoll = 25 * time.Second
|
||||
|
||||
const (
|
||||
// claimKeyHeader carries the machine capability. Distinct from Authorization
|
||||
// (which carries the org bearer): org identity and machine identity are two
|
||||
// independent proofs, both required.
|
||||
claimKeyHeader = "X-Target-Key"
|
||||
maxReportField = 64 << 10
|
||||
)
|
||||
|
||||
// mountRouting registers the route-work machine surface. Called from mountTargets
|
||||
// AFTER the target CRUD routes so the extra-segment paths are unambiguous.
|
||||
func mountRouting(s *cloud.Service[state], app *zip.App) {
|
||||
app.Post("/v1/agents/targets/:id/claim-key", cloud.Handle(s, mintClaimKey))
|
||||
app.Post("/v1/agents/targets/:id/claim", cloud.Handle(s, claimRoutedRun))
|
||||
app.Post("/v1/agents/targets/:id/runs/:runId/report", cloud.Handle(s, reportRoutedRun))
|
||||
}
|
||||
|
||||
// mintClaimKey (re)mints the target's claim key and returns it ONCE. Only the
|
||||
// SHA-256 hash is stored. Org-scoped: only a caller in the target's org can mint,
|
||||
// and the key is bound to (org, target). Rotating supersedes any prior daemon.
|
||||
func mintClaimKey(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
// The target must exist in this org before it can carry a capability.
|
||||
if _, err := s.State.store.GetTarget(c.Context(), org, id); err == errTargetNotFound {
|
||||
return zip.ErrNotFound("target not found")
|
||||
} else if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "target: %v", err)
|
||||
}
|
||||
key, err := newClaimKey()
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
if err := s.State.store.UpsertClaimKeyHash(c.Context(), org, id, hashClaimKey(key), time.Now().Unix()); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"targetId": id, "claimKey": key})
|
||||
}
|
||||
|
||||
// claimRoutedRun authenticates the machine, refreshes its serving liveness, and
|
||||
// long-polls the rendezvous for the next run addressed to THIS (org, target).
|
||||
// 200 + the run on a claim; 204 when the poll window elapses with no work.
|
||||
func claimRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
|
||||
return claimAuthError(err)
|
||||
}
|
||||
// The poll itself is the runner's liveness proof — stamp it so the dispatch
|
||||
// gate (TargetDispatchable) sees a live runner. Best-effort.
|
||||
_ = s.State.store.StampServing(c.Context(), org, id, time.Now().Unix())
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Context(), claimLongPoll)
|
||||
defer cancel()
|
||||
run, got := routedMailbox.Claim(ctx, org, id)
|
||||
if !got {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
return c.JSON(http.StatusOK, routedRunView(run))
|
||||
}
|
||||
|
||||
type reportReq struct {
|
||||
OK bool `json:"ok"`
|
||||
Changed bool `json:"changed"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commitSha"`
|
||||
Diffstat string `json:"diffstat"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// reportRoutedRun completes a claimed run: it delivers the terminal result to the
|
||||
// run's durable owner (the RoutedRunWorkflow activity), which lets the workflow
|
||||
// finish. Scoped to (org, target, runId) AND claim-key-authenticated, so a
|
||||
// machine can only ever report a run it legitimately holds. Idempotent: a report
|
||||
// for an unknown/already-finished run is a clean no-op (the session terminal was
|
||||
// already set by the machine's own stream).
|
||||
func reportRoutedRun(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := idParam(c)
|
||||
runID := strings.TrimSpace(c.Param("runId"))
|
||||
if err := s.State.store.verifyClaimKey(c.Context(), org, id, c.Header(claimKeyHeader)); err != nil {
|
||||
return claimAuthError(err)
|
||||
}
|
||||
var body reportReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
res := RoutedResult{
|
||||
OK: body.OK, Changed: body.Changed,
|
||||
Branch: clampStr(body.Branch, maxRepo),
|
||||
CommitSha: clampStr(body.CommitSha, 128),
|
||||
Diffstat: clampStr(body.Diffstat, maxReportField),
|
||||
Error: clampStr(body.Error, maxReportField),
|
||||
}
|
||||
delivered := routedMailbox.Report(org, id, runID, res)
|
||||
return c.JSON(http.StatusOK, map[string]any{"delivered": delivered})
|
||||
}
|
||||
|
||||
// claimAuthError maps the claim-key verdict onto a fail-closed HTTP status. A
|
||||
// missing target row, a missing/mismatched key, and an unknown org all collapse
|
||||
// to 403 so the surface never distinguishes "wrong key" from "no such target" —
|
||||
// an unauthorized caller learns nothing about what exists.
|
||||
func claimAuthError(err error) error {
|
||||
switch err {
|
||||
case errNoClaimKey, errClaimKeyBad, errTargetNotFound:
|
||||
return zip.ErrForbidden("target claim rejected")
|
||||
default:
|
||||
return zip.Errorf(http.StatusInternalServerError, "claim auth: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// routedRunView is the non-secret run spec handed to the machine. It carries no
|
||||
// credential by design — the machine authenticates git + model routing with its
|
||||
// own already-held credentials.
|
||||
func routedRunView(run RoutedRun) map[string]any {
|
||||
return map[string]any{
|
||||
"sessionId": run.SessionID,
|
||||
"repo": run.Repo,
|
||||
"project": run.Project,
|
||||
"base": run.Base,
|
||||
"branch": run.Branch,
|
||||
"prompt": run.Prompt,
|
||||
"cloneUrl": run.CloneURL,
|
||||
"timeoutSeconds": run.TimeoutSeconds,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// doKey is a keyless-body request with a machine claim key (X-Target-Key) attached.
|
||||
func doKey(t *testing.T, app *zip.App, method, path, org, key string) (int, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u-"+org)
|
||||
}
|
||||
if key != "" {
|
||||
req.Header.Set(claimKeyHeader, key)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
|
||||
// registerAndMint registers a target for org and mints its claim key, returning
|
||||
// (targetID, claimKey).
|
||||
func registerAndMint(t *testing.T, app *zip.App, org, host string) (string, string) {
|
||||
t.Helper()
|
||||
code, body := do(t, app, "POST", "/v1/agents/targets", org, map[string]any{"label": host, "host": host})
|
||||
if code != 201 && code != 200 {
|
||||
t.Fatalf("register target: %d %s", code, body)
|
||||
}
|
||||
var tv struct{ ID string `json:"id"` }
|
||||
_ = json.Unmarshal(body, &tv)
|
||||
code, body = doKey(t, app, "POST", "/v1/agents/targets/"+tv.ID+"/claim-key", org, "")
|
||||
if code != 200 {
|
||||
t.Fatalf("mint claim key: %d %s", code, body)
|
||||
}
|
||||
var kv struct{ ClaimKey string `json:"claimKey"` }
|
||||
_ = json.Unmarshal(body, &kv)
|
||||
if kv.ClaimKey == "" {
|
||||
t.Fatal("claim key empty")
|
||||
}
|
||||
return tv.ID, kv.ClaimKey
|
||||
}
|
||||
|
||||
// A claim without the machine's key, or with the WRONG key, is refused — org
|
||||
// membership alone is not enough to claim a machine's runs.
|
||||
func TestClaim_RequiresMachineKey(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
old := claimLongPoll
|
||||
claimLongPoll = 150 * time.Millisecond
|
||||
defer func() { claimLongPoll = old }()
|
||||
|
||||
id, key := registerAndMint(t, app, "acme", "evo")
|
||||
|
||||
// No key => 403.
|
||||
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", ""); code != 403 {
|
||||
t.Fatalf("claim with no key must be 403, got %d", code)
|
||||
}
|
||||
// Wrong key => 403.
|
||||
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", "tgtk_wrong"); code != 403 {
|
||||
t.Fatalf("claim with wrong key must be 403, got %d", code)
|
||||
}
|
||||
// Right key, no work => 204 (never 200, never another tenant's run).
|
||||
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", key); code != 204 {
|
||||
t.Fatalf("claim with right key + no work must be 204, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// THE machine boundary: a key minted for target A cannot claim target B, and a
|
||||
// different org cannot claim at all — even with a real key for its own target.
|
||||
func TestClaim_CrossMachineAndCrossOrgDenied(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
old := claimLongPoll
|
||||
claimLongPoll = 150 * time.Millisecond
|
||||
defer func() { claimLongPoll = old }()
|
||||
|
||||
idA, keyA := registerAndMint(t, app, "acme", "evoA")
|
||||
idB, _ := registerAndMint(t, app, "acme", "evoB")
|
||||
|
||||
// A's key against B => 403 (constant-time mismatch on B's stored hash).
|
||||
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+idB+"/claim", "acme", keyA); code != 403 {
|
||||
t.Fatalf("A's key claiming B must be 403, got %d", code)
|
||||
}
|
||||
|
||||
// Offer a run for acme/idA, then a DIFFERENT org cannot claim idA at all (its
|
||||
// org scope resolves no such target => 403), and the run is never handed out.
|
||||
OfferRoutedRun(RoutedRun{Org: "acme", TargetID: idA, SessionID: "sess_a", Repo: "api"})
|
||||
if code, _ := doKey(t, app, "POST", "/v1/agents/targets/"+idA+"/claim", "evil", keyA); code != 403 {
|
||||
t.Fatalf("another org claiming acme's target must be 403, got %d", code)
|
||||
}
|
||||
// acme WITH A's key claims its own run.
|
||||
code, body := doKey(t, app, "POST", "/v1/agents/targets/"+idA+"/claim", "acme", keyA)
|
||||
if code != 200 {
|
||||
t.Fatalf("acme must claim its own run, got %d %s", code, body)
|
||||
}
|
||||
var rv struct{ SessionID string `json:"sessionId"` }
|
||||
_ = json.Unmarshal(body, &rv)
|
||||
if rv.SessionID != "sess_a" {
|
||||
t.Fatalf("claimed wrong run: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The end-to-end machine round trip: offer -> claim -> report reaches the durable
|
||||
// owner awaiting the result.
|
||||
func TestClaimReport_RoundTrip(t *testing.T) {
|
||||
app := mountApp(t, &fakeAI{content: "x"})
|
||||
id, key := registerAndMint(t, app, "acme", "evo")
|
||||
|
||||
off := OfferRoutedRun(RoutedRun{Org: "acme", TargetID: id, SessionID: "sess_rt", Repo: "api", Branch: "agent/rt"})
|
||||
|
||||
code, body := doKey(t, app, "POST", "/v1/agents/targets/"+id+"/claim", "acme", key)
|
||||
if code != 200 {
|
||||
t.Fatalf("claim: %d %s", code, body)
|
||||
}
|
||||
|
||||
got := make(chan RoutedResult, 1)
|
||||
go func() { res, _ := off.Await(context.Background()); got <- res }()
|
||||
|
||||
code, _ = doKeyBody(t, app, "POST", "/v1/agents/targets/"+id+"/runs/sess_rt/report", "acme", key,
|
||||
map[string]any{"ok": true, "changed": true, "commitSha": "cafe"})
|
||||
if code != 200 {
|
||||
t.Fatalf("report: %d", code)
|
||||
}
|
||||
select {
|
||||
case res := <-got:
|
||||
if !res.OK || res.CommitSha != "cafe" {
|
||||
t.Fatalf("report did not reach the owner: %+v", res)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("owner never received the report")
|
||||
}
|
||||
}
|
||||
|
||||
// doKeyBody is doKey with a JSON body.
|
||||
func doKeyBody(t *testing.T, app *zip.App, method, path, org, key string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u-"+org)
|
||||
}
|
||||
if key != "" {
|
||||
req.Header.Set(claimKeyHeader, key)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
// ---- store-level liveness gate ----
|
||||
|
||||
// TargetDispatchable is the fail-closed gate: online + a live runner (a recent
|
||||
// claim poll). Offline, no key, or a stale poll all reject.
|
||||
func TestTargetDispatchable_LivenessGate(t *testing.T) {
|
||||
s := testSessionStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().Unix()
|
||||
tgt := Target{ID: "t1", Org: "acme", Label: "evo", Kind: TargetMachine, Status: TargetOnline, Host: "evo", CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.CreateTarget(ctx, tgt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// No claim key yet => not live => not dispatchable.
|
||||
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotLive {
|
||||
t.Fatalf("no runner => not dispatchable, got %v", err)
|
||||
}
|
||||
// Mint + a fresh serving stamp => dispatchable.
|
||||
if err := s.UpsertClaimKeyHash(ctx, "acme", "t1", hashClaimKey("k"), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.StampServing(ctx, "acme", "t1", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != nil {
|
||||
t.Fatalf("online + fresh runner => dispatchable, got %v", err)
|
||||
}
|
||||
// A stale serving stamp => not dispatchable (dead runner).
|
||||
if err := s.StampServing(ctx, "acme", "t1", now-int64(servingTTL/time.Second)-5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotLive {
|
||||
t.Fatalf("stale runner => not dispatchable, got %v", err)
|
||||
}
|
||||
// Fresh again but OFFLINE => not dispatchable.
|
||||
_ = s.StampServing(ctx, "acme", "t1", time.Now().Unix())
|
||||
tgt.Status = TargetOffline
|
||||
tgt.UpdatedAt = time.Now().Unix()
|
||||
if err := s.UpdateTarget(ctx, tgt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.TargetDispatchable(ctx, "acme", "t1"); err != errTargetNotReady {
|
||||
t.Fatalf("offline => not dispatchable, got %v", err)
|
||||
}
|
||||
// Unknown target / cross-org => fail closed.
|
||||
if err := s.TargetDispatchable(ctx, "acme", "nope"); err != errTargetNotFound {
|
||||
t.Fatalf("unknown target => not found, got %v", err)
|
||||
}
|
||||
if err := s.TargetDispatchable(ctx, "evil", "t1"); err != errTargetNotFound {
|
||||
t.Fatalf("cross-org => not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The claim key is stored ONLY as a hash; verify is constant-time + fail closed.
|
||||
func TestClaimKey_HashedAtRestAndVerified(t *testing.T) {
|
||||
s := testSessionStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().Unix()
|
||||
_ = s.CreateTarget(ctx, Target{ID: "t1", Org: "acme", Status: TargetOnline, CreatedAt: now, UpdatedAt: now})
|
||||
|
||||
key, _ := newClaimKey()
|
||||
if err := s.UpsertClaimKeyHash(ctx, "acme", "t1", hashClaimKey(key), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The stored value is a hash, never the plaintext.
|
||||
stored, _, _ := s.ClaimKeyHash(ctx, "acme", "t1")
|
||||
if stored == key || stored != hashClaimKey(key) {
|
||||
t.Fatalf("claim key must be stored as a hash, not plaintext")
|
||||
}
|
||||
if err := s.verifyClaimKey(ctx, "acme", "t1", key); err != nil {
|
||||
t.Fatalf("correct key must verify: %v", err)
|
||||
}
|
||||
if err := s.verifyClaimKey(ctx, "acme", "t1", "tgtk_wrong"); err != errClaimKeyBad {
|
||||
t.Fatalf("wrong key must fail: %v", err)
|
||||
}
|
||||
if err := s.verifyClaimKey(ctx, "acme", "t1", ""); err != errClaimKeyBad {
|
||||
t.Fatalf("empty key must fail: %v", err)
|
||||
}
|
||||
// Cross-org verify resolves no key => fail closed.
|
||||
if err := s.verifyClaimKey(ctx, "evil", "t1", key); err != errNoClaimKey {
|
||||
t.Fatalf("cross-org verify must fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,10 @@ CREATE INDEX IF NOT EXISTS ix_runs_org_agent_created ON agent_runs(org, agent_na
|
||||
if err := s.migrateTargets(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Per-target claim keys + serving liveness (the #48 route-work machine plane).
|
||||
if err := s.migrateClaimKeys(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+183
-4
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/samples"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -17,10 +19,11 @@ import (
|
||||
// box, a GPU host, or a whole cluster. It is the #48 link-a-compute seam over the
|
||||
// SAME agents.db (one store, one tenancy column) as sessions/events — NOT a rival
|
||||
// device registry. It composes with the compute fleet rather than duplicating it: a
|
||||
// session records the target id it runs on (agent_sessions.target), and the mission-
|
||||
// control devices view unions these registered targets with the org's BYO workers
|
||||
// (GET /v1/fleet/workers) and BYO clusters (GET /v1/clusters) at the view layer — the
|
||||
// console's established pattern for folding compute sources.
|
||||
// session records the target id it runs on (agent_sessions.target), and the org's
|
||||
// unified board (GET /v1/fleet, clients/visor/board.go) unions these registered
|
||||
// targets with its BYO workers (GET /v1/fleet/workers), BYO clusters and Visor
|
||||
// machines — reading this registry through the in-process seam below rather than
|
||||
// copying it.
|
||||
//
|
||||
// POST /v1/agents/targets register a target -> Target
|
||||
// GET /v1/agents/targets list the org's targets (+ live session load)
|
||||
@@ -30,6 +33,12 @@ import (
|
||||
//
|
||||
// Every route is org-scoped through principal.Org (tenant), fail-closed — a tenant
|
||||
// can never see or mutate another org's targets, exactly like sessions.
|
||||
//
|
||||
// A write carrying `metrics` IS a heartbeat, and a heartbeat is two facts, not one:
|
||||
// the LAST sample (kept on the row, rendered by the views here) and one point in a
|
||||
// utilization SERIES (appended to clients/samples). The row answers "is this machine
|
||||
// alive and what is it doing now"; the series answers "how hot has it been". The
|
||||
// append is best-effort and detached — see recordSample.
|
||||
|
||||
// Target kinds — the closed vocabulary of dispatch destinations.
|
||||
const (
|
||||
@@ -233,6 +242,90 @@ func (s *Store) GetTargetByHost(ctx context.Context, org, host string) (Target,
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ---- the in-process seam (org-scoped, fail-closed) ----
|
||||
//
|
||||
// TargetsForOrg / LoadOn are the exported twins of the list + detail reads above:
|
||||
// the ONE way another in-process subsystem (the /v1/fleet board in clients/visor)
|
||||
// reads this registry WITHOUT an HTTP hop back through the gateway — the same
|
||||
// shape ListForOrg gives the agent registry. They are two ORTHOGONAL values on
|
||||
// purpose: a target is what the machine IS, its load is what is running on it, and
|
||||
// a caller that only needs the inventory does not pay for the rollups.
|
||||
//
|
||||
// ISOLATION: org is the ONLY tenant key and is threaded verbatim into the
|
||||
// org-scoped store methods, so a caller for org A can never enumerate or resolve
|
||||
// org B's targets. The caller MUST pass an org it already validated server-side
|
||||
// (principal.Org), never a raw client header.
|
||||
|
||||
// TargetsForOrg returns the org's registered run-targets from the in-process
|
||||
// store, newest first. Fails closed when the subsystem is not mounted or the org
|
||||
// is empty/oversized.
|
||||
func TargetsForOrg(ctx context.Context, org string) ([]Target, error) {
|
||||
if mounted == nil || mounted.State.store == nil {
|
||||
return nil, fmt.Errorf("agents: not mounted")
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" || len(org) > principal.MaxOrgLen {
|
||||
return nil, fmt.Errorf("agents: invalid org")
|
||||
}
|
||||
return mounted.State.store.ListTargets(ctx, org)
|
||||
}
|
||||
|
||||
// ResolveTarget resolves a human's target REFERENCE — a target id or its friendly
|
||||
// label (the hostname the CLI registers) — to the org's target, org-scoped and
|
||||
// fail-closed. It is the ONE way a trigger surface (the Slack `code: <repo> on
|
||||
// <target>` grammar, a console picker) turns "on evo" into a target id without
|
||||
// leaking another tenant's inventory: an id or label that resolves to no target in
|
||||
// THIS org returns errTargetNotFound, never another org's machine.
|
||||
//
|
||||
// Precedence: an exact id match wins (ids are unambiguous), else an exact,
|
||||
// case-folded label match (newest first, so a re-registered machine's live row is
|
||||
// preferred). A reference that matches neither is not found — the caller renders an
|
||||
// honest error and NEVER falls back to a local run.
|
||||
func ResolveTarget(ctx context.Context, org, ref string) (Target, error) {
|
||||
if mounted == nil || mounted.State.store == nil {
|
||||
return Target{}, fmt.Errorf("agents: not mounted")
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
ref = strings.TrimSpace(ref)
|
||||
if org == "" || len(org) > principal.MaxOrgLen {
|
||||
return Target{}, fmt.Errorf("agents: invalid org")
|
||||
}
|
||||
if ref == "" || len(ref) > maxTargetID {
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
// An id is exact and unambiguous — try it first.
|
||||
if t, err := mounted.State.store.GetTarget(ctx, org, ref); err == nil {
|
||||
return t, nil
|
||||
} else if err != errTargetNotFound {
|
||||
return Target{}, err
|
||||
}
|
||||
// Else an exact, case-folded label match within this org.
|
||||
rows, err := mounted.State.store.ListTargets(ctx, org)
|
||||
if err != nil {
|
||||
return Target{}, err
|
||||
}
|
||||
for _, t := range rows { // ListTargets is newest-first: the live row wins a label tie
|
||||
if strings.EqualFold(strings.TrimSpace(t.Label), ref) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Target{}, errTargetNotFound
|
||||
}
|
||||
|
||||
// LoadOn returns the live session load on one of the org's targets — the same
|
||||
// (target id OR host) mapping the HTTP views use, so the board and /v1/agents/
|
||||
// targets can never disagree about what is running where.
|
||||
func LoadOn(ctx context.Context, org, id, host string) (TargetLoad, error) {
|
||||
if mounted == nil || mounted.State.store == nil {
|
||||
return TargetLoad{}, fmt.Errorf("agents: not mounted")
|
||||
}
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" || len(org) > principal.MaxOrgLen {
|
||||
return TargetLoad{}, fmt.Errorf("agents: invalid org")
|
||||
}
|
||||
return mounted.State.store.SessionLoad(ctx, org, id, host)
|
||||
}
|
||||
|
||||
// DeleteTarget removes an org's target. Sessions keep their recorded target id (a
|
||||
// historical fact); a detached target simply stops appearing in the registry.
|
||||
func (s *Store) DeleteTarget(ctx context.Context, org, id string) (bool, error) {
|
||||
@@ -262,6 +355,83 @@ func (s *Store) SessionLoad(ctx context.Context, org, id, host string) (TargetLo
|
||||
return TargetLoad{Sessions: total, Running: running}, nil
|
||||
}
|
||||
|
||||
// ---- the fleet time series ----
|
||||
//
|
||||
// A heartbeat is the ONE moment this process learns what a linked machine is
|
||||
// doing, so it is also where the fleet's utilization series is fed. The target row
|
||||
// keeps the LAST sample (the snapshot the views render, unchanged); clients/samples
|
||||
// keeps every sample over time. Two different questions — "is it alive now" and
|
||||
// "how hot has it been" — so two homes, one write.
|
||||
|
||||
// sampleTimeout bounds the warehouse write. Generous (the insert is one small row
|
||||
// in-cluster) but finite, so a wedged datastore can never hold the goroutine open.
|
||||
const sampleTimeout = 5 * time.Second
|
||||
|
||||
// sampleOf projects a target's server-stamped heartbeat into a fleet sample. PURE
|
||||
// (no clock, no I/O, no store) so the whole projection is unit-testable and the
|
||||
// caller decides when it runs.
|
||||
//
|
||||
// cost_cents is 0: an agent run-target is the operator's OWN machine (a laptop, a
|
||||
// dialed-in box) — the fleet meters its utilization, it does not resell it. A
|
||||
// priced source (visor/cloud) fills that column from its own resale price.
|
||||
func sampleOf(t Target) samples.Sample {
|
||||
var model string
|
||||
if len(t.Spec.GPUs) > 0 {
|
||||
// The representative accelerator: the count already rides in GPUs, so the
|
||||
// first card's model names the row. A heterogeneous host is rare enough
|
||||
// that naming its first card beats inventing a summary string here.
|
||||
model = t.Spec.GPUs[0].Model
|
||||
if model == "" {
|
||||
model = t.Spec.GPUs[0].Vendor
|
||||
}
|
||||
}
|
||||
return samples.Sample{
|
||||
Org: t.Org,
|
||||
Source: samples.SourceAgent,
|
||||
Unit: t.ID,
|
||||
Host: t.Host,
|
||||
Kind: t.Kind,
|
||||
At: time.Unix(t.MetricsAt, 0).UTC(),
|
||||
|
||||
CPUs: t.Spec.CPUs,
|
||||
Memory: t.Spec.Memory,
|
||||
MemUsed: t.Metrics.MemUsed,
|
||||
MemFree: t.Metrics.MemFree,
|
||||
Load1: t.Metrics.Load1,
|
||||
Load5: t.Metrics.Load5,
|
||||
Load15: t.Metrics.Load15,
|
||||
GPUUtil: t.Metrics.GPUUtil,
|
||||
GPUs: len(t.Spec.GPUs),
|
||||
GPUModel: model,
|
||||
}
|
||||
}
|
||||
|
||||
// recordSample appends a heartbeat to the fleet series. Best-effort and DETACHED
|
||||
// on purpose — the warehouse is never in the heartbeat's critical path:
|
||||
//
|
||||
// - it runs on its own bounded context, so neither a slow datastore nor the
|
||||
// client hanging up mid-request can stall or cancel the write;
|
||||
// - it never touches the response, so the /v1/agents/targets contract is
|
||||
// byte-identical whether the warehouse is present, absent or on fire;
|
||||
// - a failure is logged, never surfaced — a dropped sample must not cost a
|
||||
// machine its heartbeat.
|
||||
//
|
||||
// This is the shape the billing warehouse write already uses (`go zapWriteUsage`):
|
||||
// the seam is synchronous, the CALLER owns the concurrency.
|
||||
func recordSample(s *cloud.Service[state], t Target) {
|
||||
if t.MetricsAt == 0 {
|
||||
return // no heartbeat in this write — nothing to append
|
||||
}
|
||||
sample := sampleOf(t) // project on the caller's goroutine: t must not escape mutably
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sampleTimeout)
|
||||
defer cancel()
|
||||
if err := samples.Record(ctx, sample); err != nil {
|
||||
s.Log.Warn("fleet sample write failed", "org", sample.Org, "unit", sample.Unit, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ---- HTTP shapes (the published contract) ----
|
||||
|
||||
type targetView struct {
|
||||
@@ -311,6 +481,10 @@ func mountTargets(s *cloud.Service[state], app *zip.App) {
|
||||
app.Get("/v1/agents/targets/:id", cloud.Handle(s, getTarget))
|
||||
app.Patch("/v1/agents/targets/:id", cloud.Handle(s, patchTarget))
|
||||
app.Delete("/v1/agents/targets/:id", cloud.Handle(s, deleteTarget))
|
||||
// The #48 route-work machine surface (claim-key, claim long-poll, report)
|
||||
// lives on the same target routes; register after the CRUD so the
|
||||
// extra-segment paths are unambiguous.
|
||||
mountRouting(s, app)
|
||||
}
|
||||
|
||||
// ---- register ----
|
||||
@@ -386,6 +560,7 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := s.State.store.UpdateTarget(c.Context(), existing); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
recordSample(s, existing) // a re-link carrying metrics IS a heartbeat
|
||||
load, _ := s.State.store.SessionLoad(c.Context(), org, existing.ID, existing.Host)
|
||||
return c.JSON(http.StatusOK, toTargetView(existing, load))
|
||||
}
|
||||
@@ -403,6 +578,7 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := s.State.store.CreateTarget(c.Context(), t); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
recordSample(s, t) // a registration carrying metrics is the target's first sample
|
||||
return c.JSON(http.StatusCreated, toTargetView(t, TargetLoad{}))
|
||||
}
|
||||
|
||||
@@ -538,6 +714,9 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
|
||||
}
|
||||
if body.Metrics != nil {
|
||||
recordSample(s, t) // THE heartbeat: append it to the fleet series too
|
||||
}
|
||||
load, _ := s.State.store.SessionLoad(c.Context(), org, t.ID, t.Host)
|
||||
return c.JSON(http.StatusOK, toTargetView(t, load))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/samples"
|
||||
)
|
||||
|
||||
// targetsample_test.go covers the FIRST emitter: a run-target heartbeat also
|
||||
// appends to the fleet series (clients/samples).
|
||||
//
|
||||
// The datastore is absent under test, so samples.Record is a proven no-op (its own
|
||||
// package tests that). What MUST be proven here is everything this side owns:
|
||||
// the projection is faithful, the vocabularies agree, and the HTTP contract is
|
||||
// untouched whether or not the warehouse exists.
|
||||
|
||||
// ---- the projection (pure) ----
|
||||
|
||||
// A heartbeat projects onto a sample with no loss and no invention.
|
||||
func TestSampleOfProjectsTheHeartbeat(t *testing.T) {
|
||||
at := time.Now().Unix()
|
||||
tg := Target{
|
||||
ID: "tgt-1", Org: "acme", Kind: TargetGPU, Host: "box.local", Label: "Box",
|
||||
Spec: Spec{OS: "linux", Arch: "arm64", CPUs: 20, Memory: 128 << 30,
|
||||
GPUs: []GPU{{Vendor: "nvidia", Model: "GB10", Memory: 96 << 30}}},
|
||||
Metrics: Metrics{Load1: 2.5, Load5: 2, Load15: 1.5,
|
||||
MemUsed: 64 << 30, MemFree: 64 << 30, GPUUtil: 0.75},
|
||||
MetricsAt: at,
|
||||
}
|
||||
s := sampleOf(tg)
|
||||
|
||||
if s.Org != "acme" || s.Unit != "tgt-1" || s.Host != "box.local" {
|
||||
t.Fatalf("identity did not project: %+v", s)
|
||||
}
|
||||
if s.Source != samples.SourceAgent {
|
||||
t.Fatalf("source want %q, got %q", samples.SourceAgent, s.Source)
|
||||
}
|
||||
if s.Kind != TargetGPU {
|
||||
t.Fatalf("kind want %q, got %q", TargetGPU, s.Kind)
|
||||
}
|
||||
if !s.At.Equal(time.Unix(at, 0).UTC()) {
|
||||
t.Fatalf("at must be the SERVER-stamped heartbeat clock, got %v", s.At)
|
||||
}
|
||||
if s.CPUs != 20 || s.Memory != 128<<30 {
|
||||
t.Fatalf("spec did not project: %+v", s)
|
||||
}
|
||||
if s.MemUsed != 64<<30 || s.MemFree != 64<<30 || s.Load1 != 2.5 || s.Load5 != 2 || s.Load15 != 1.5 {
|
||||
t.Fatalf("metrics did not project: %+v", s)
|
||||
}
|
||||
if s.GPUUtil != 0.75 || s.GPUs != 1 || s.GPUModel != "GB10" {
|
||||
t.Fatalf("gpu did not project: %+v", s)
|
||||
}
|
||||
// An agent's own machine is metered, never resold.
|
||||
if s.CostCents != 0 {
|
||||
t.Fatalf("an agent sample must be unpriced, got %d", s.CostCents)
|
||||
}
|
||||
// The projection must be acceptable to the plane it feeds.
|
||||
if err := samples.Record(t.Context(), s); err != nil {
|
||||
t.Fatalf("a projected sample must be recordable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The accelerator count comes from the spec, and the row is named by the first
|
||||
// card's model — falling back to its vendor when the model is unknown.
|
||||
func TestSampleOfGPUSummary(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
gpus []GPU
|
||||
wantN int
|
||||
wantModel string
|
||||
}{
|
||||
{"none", nil, 0, ""},
|
||||
{"model", []GPU{{Vendor: "nvidia", Model: "GB10"}}, 1, "GB10"},
|
||||
{"vendor fallback", []GPU{{Vendor: "amd"}}, 1, "amd"},
|
||||
{"multi is counted, first names it", []GPU{{Model: "GB10"}, {Model: "GB10"}}, 2, "GB10"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := sampleOf(Target{ID: "t", Org: "o", Kind: TargetGPU, Spec: Spec{GPUs: tc.gpus}, MetricsAt: 1})
|
||||
if s.GPUs != tc.wantN || s.GPUModel != tc.wantModel {
|
||||
t.Fatalf("want (%d, %q), got (%d, %q)", tc.wantN, tc.wantModel, s.GPUs, s.GPUModel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// THE cross-package contract: every kind a target can be must be a kind the fleet
|
||||
// series accepts, or heartbeats would silently stop being recorded. This fails the
|
||||
// day someone adds a target kind without teaching the series about it.
|
||||
func TestEveryTargetKindIsAFleetKind(t *testing.T) {
|
||||
fleet := map[string]bool{
|
||||
samples.KindLaptop: true, samples.KindCloud: true, samples.KindGPU: true,
|
||||
samples.KindCluster: true, samples.KindMachine: true, samples.KindWorker: true,
|
||||
}
|
||||
for _, k := range []string{TargetLaptop, TargetCloud, TargetGPU, TargetCluster, TargetMachine} {
|
||||
if !fleet[k] {
|
||||
t.Fatalf("target kind %q is not a fleet sample kind — its heartbeats would be dropped", k)
|
||||
}
|
||||
// Proven end to end: a sample carrying this kind validates.
|
||||
s := sampleOf(Target{ID: "t", Org: "o", Kind: k, MetricsAt: 1})
|
||||
if err := samples.Record(t.Context(), s); err != nil {
|
||||
t.Fatalf("kind %q must be recordable: %v", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A write with no heartbeat in it appends nothing — recordSample is a no-op when
|
||||
// the server never stamped a metrics clock.
|
||||
func TestRecordSampleSkipsWhenNoHeartbeat(t *testing.T) {
|
||||
mountApp(t, nil) // sets the `mounted` singleton recordSample logs through
|
||||
// No panic, no goroutine, no write: MetricsAt == 0 means "no sample here".
|
||||
recordSample(mounted, Target{ID: "tgt-1", Org: "acme", Kind: TargetGPU, MetricsAt: 0})
|
||||
}
|
||||
|
||||
// ---- (c) the HTTP contract is untouched by the series ----
|
||||
|
||||
// The heartbeat still 200s with no warehouse, and still returns the snapshot on
|
||||
// the row exactly as before — the series is strictly additive.
|
||||
func TestHeartbeatStill200sWithoutDatastore(t *testing.T) {
|
||||
app := mountApp(t, nil)
|
||||
|
||||
code, body := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
|
||||
"label": "Box", "kind": TargetGPU, "host": "box.local",
|
||||
"spec": map[string]any{"os": "linux", "cpus": 20, "gpus": []map[string]any{{"vendor": "nvidia", "model": "GB10"}}},
|
||||
"metrics": map[string]any{"load1": 2.5, "gpuUtil": 0.75, "memUsed": 100},
|
||||
})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("register want 201 without a datastore, got %d (%s)", code, body)
|
||||
}
|
||||
var created targetView
|
||||
if err := json.Unmarshal(body, &created); err != nil {
|
||||
t.Fatalf("shape: %v (%s)", err, body)
|
||||
}
|
||||
if created.Metrics == nil || created.Metrics.GPUUtil != 0.75 {
|
||||
t.Fatalf("the snapshot on the row must be unchanged: %+v", created.Metrics)
|
||||
}
|
||||
if created.MetricsAt == "" {
|
||||
t.Fatal("the server must still stamp the heartbeat clock")
|
||||
}
|
||||
|
||||
// The heartbeat itself.
|
||||
code, body = do(t, app, http.MethodPatch, "/v1/agents/targets/"+created.ID, "acme", map[string]any{
|
||||
"metrics": map[string]any{"load1": 4, "gpuUtil": 0.9, "memUsed": 200},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("heartbeat want 200 without a datastore, got %d (%s)", code, body)
|
||||
}
|
||||
var beat targetView
|
||||
if err := json.Unmarshal(body, &beat); err != nil {
|
||||
t.Fatalf("shape: %v (%s)", err, body)
|
||||
}
|
||||
if beat.Metrics == nil || beat.Metrics.GPUUtil != 0.9 || beat.Metrics.Load1 != 4 {
|
||||
t.Fatalf("the heartbeat must still refresh the row snapshot: %+v", beat.Metrics)
|
||||
}
|
||||
|
||||
// A re-link (same org+host) is idempotent and still carries a heartbeat.
|
||||
code, body = do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
|
||||
"label": "Box", "kind": TargetGPU, "host": "box.local",
|
||||
"metrics": map[string]any{"load1": 1},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("re-link want 200 (idempotent), got %d (%s)", code, body)
|
||||
}
|
||||
var relinked targetView
|
||||
if err := json.Unmarshal(body, &relinked); err != nil {
|
||||
t.Fatalf("shape: %v (%s)", err, body)
|
||||
}
|
||||
if relinked.ID != created.ID {
|
||||
t.Fatalf("a re-link must refresh the SAME target: %s != %s", relinked.ID, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the in-process seam ----
|
||||
|
||||
// TargetsForOrg / LoadOn are org-keyed and fail closed — the board reads through
|
||||
// them, so a cross-tenant id must never resolve.
|
||||
func TestInProcessSeamIsOrgScopedAndFailsClosed(t *testing.T) {
|
||||
app := mountApp(t, nil)
|
||||
code, body := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
|
||||
"label": "Secret", "kind": TargetGPU, "host": "secret.local",
|
||||
})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("register: %d (%s)", code, body)
|
||||
}
|
||||
var created targetView
|
||||
_ = json.Unmarshal(body, &created)
|
||||
|
||||
// The owner sees it.
|
||||
own, err := TargetsForOrg(t.Context(), "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("TargetsForOrg(acme): %v", err)
|
||||
}
|
||||
if len(own) != 1 || own[0].ID != created.ID {
|
||||
t.Fatalf("the owner must see its target, got %+v", own)
|
||||
}
|
||||
|
||||
// Another tenant sees nothing — the same id is unreachable.
|
||||
other, err := TargetsForOrg(t.Context(), "other")
|
||||
if err != nil {
|
||||
t.Fatalf("TargetsForOrg(other): %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("CROSS-TENANT LEAK: org 'other' enumerated %+v", other)
|
||||
}
|
||||
|
||||
// A blank/oversized org fails closed on both.
|
||||
for _, bad := range []string{"", " "} {
|
||||
if _, err := TargetsForOrg(t.Context(), bad); err == nil {
|
||||
t.Fatalf("TargetsForOrg(%q) must fail closed", bad)
|
||||
}
|
||||
if _, err := LoadOn(t.Context(), bad, created.ID, ""); err == nil {
|
||||
t.Fatalf("LoadOn(%q) must fail closed", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadOn is org-keyed too: the foreign tenant resolves no load for the id.
|
||||
load, err := LoadOn(t.Context(), "other", created.ID, "secret.local")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOn(other): %v", err)
|
||||
}
|
||||
if load.Sessions != 0 || load.Running != 0 {
|
||||
t.Fatalf("CROSS-TENANT LEAK: foreign load %+v", load)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openai "github.com/sashabaranov/go-openai"
|
||||
openai "github.com/hanzoai/go-openai"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
|
||||
@@ -100,15 +100,21 @@ func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Get("/v1/analytics/timeseries", cloud.Handle(s, timeseries))
|
||||
app.Get("/v1/analytics/top", cloud.Handle(s, top))
|
||||
|
||||
// Capture (WRITE) side — the ingest that fills hanzo.events (capture.go). All
|
||||
// POST, all tenant-gated in-handler. /v1/tracker is the page-unload beacon
|
||||
// alias (bare route; never collides with the /v1/tracker/projects* issue tracker).
|
||||
// Capture (WRITE) side — the ingest that fills hanzo.events. POST /v1/event
|
||||
// (event.go) is the ONE canonical front door: body Event | [Event], org
|
||||
// resolved IAM-only and fail-closed, into the ONE write core (ingestEvents).
|
||||
app.Post("/v1/event", cloud.Handle(s, eventIngest))
|
||||
|
||||
// DEPRECATED ingest aliases — thin wire adapters that normalize onto the SAME
|
||||
// write core (log a one-shot deprecation, keep working). /v1/analytics{,/batch}
|
||||
// and /v1/tracker speak the Segment/beacon CaptureBatch wire; /v1/tracker is a
|
||||
// bare route (never collides with the /v1/tracker/projects* issue tracker).
|
||||
app.Post("/v1/analytics", cloud.Handle(s, capture))
|
||||
app.Post("/v1/analytics/batch", cloud.Handle(s, capture))
|
||||
app.Post("/v1/tracker", cloud.Handle(s, capture))
|
||||
|
||||
// /v1/insights — the unified native surface (insights.go): PostHog-wire
|
||||
// ingest + console reads over the SAME engine. Flags live at /v1/flags.
|
||||
// /v1/insights — console reads over the SAME engine + the DEPRECATED PostHog-
|
||||
// wire ingest adapter (/v1/insights/e → the ONE write core). Flags live at /v1/flags.
|
||||
app.Get("/v1/insights/health", cloud.Handle(s, insightsHealth))
|
||||
app.Post("/v1/insights/e", cloud.Handle(s, insightsIngest))
|
||||
app.Get("/v1/insights/events", cloud.Handle(s, insightsEvents))
|
||||
|
||||
+143
-29
@@ -52,6 +52,7 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -498,19 +499,72 @@ func publicCaptureEnabled() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// captureTenant resolves the tenant a batch is attributed to. A VALIDATED
|
||||
// principal always wins (authenticated product traffic → its own org). Otherwise,
|
||||
// for anonymous marketing traffic, the tenant is the PUBLIC brand org derived
|
||||
// SERVER-SIDE from the request Host via the white-label registry — never a
|
||||
// client-claimed org, so the isolation invariant holds: a caller with no bearer
|
||||
// can only ever write into the brand-public partition of the Host it actually
|
||||
// reached, and a forged X-Org-Id is ignored exactly as on the read path. An
|
||||
// unrecognized Host is refused (we never dump anonymous events into a default
|
||||
// org). Returns ("", false) when the caller must be answered 403.
|
||||
// resolveKeyOrg maps a presented project/API key to its org through the ONE IAM
|
||||
// key seam (cloud.OrgForKey). It is a package var ONLY so a test can substitute a
|
||||
// resolver without standing up IAM; production is always cloud.OrgForKey.
|
||||
var resolveKeyOrg = cloud.OrgForKey
|
||||
|
||||
// projectKey returns the project/API key a keyed SDK presents OUT-OF-BAND of the
|
||||
// Authorization header — the transports SanitizeIdentity does NOT mint identity
|
||||
// from, so they never reach tenant() as a principal. In priority order: the
|
||||
// ?api_key= query, the x-api-key / api-key headers, and the PostHog-wire body
|
||||
// field `api_key` (posthog-js and the insights-go batch envelope put it there).
|
||||
// "" when none is present.
|
||||
//
|
||||
// The body is PEEKED via c.Body() — fasthttp buffers the full body, so the later
|
||||
// c.Bind in the handler re-reads the same bytes; peeking does not consume it. Only
|
||||
// the api_key field is decoded (a bad/unrelated JSON body simply yields "").
|
||||
func projectKey(c *zip.Ctx) string {
|
||||
if k := trim(c.Query("api_key")); k != "" {
|
||||
return k
|
||||
}
|
||||
if k := trim(c.Header("x-api-key")); k != "" {
|
||||
return k
|
||||
}
|
||||
if k := trim(c.Header("api-key")); k != "" {
|
||||
return k
|
||||
}
|
||||
if body := c.Body(); len(body) > 0 {
|
||||
var probe struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
if json.Unmarshal(body, &probe) == nil {
|
||||
if k := trim(probe.APIKey); k != "" {
|
||||
return k
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// captureTenant resolves the tenant a batch is attributed to, in strict trust
|
||||
// order:
|
||||
//
|
||||
// 1. A VALIDATED principal always wins (authenticated product traffic → its own
|
||||
// org; this also covers a Hanzo key sent as a bearer, which SanitizeIdentity
|
||||
// has already resolved to a principal upstream).
|
||||
// 2. Otherwise, if the caller PRESENTS a project key out-of-band (posthog-js /
|
||||
// insights-go: api_key in the body/query/x-api-key), resolve it to its org
|
||||
// through the ONE IAM key seam. This FAILS CLOSED: a presented-but-unresolvable
|
||||
// key is refused (→ 403), NEVER falling through to the brand-host fallback —
|
||||
// attributing a keyed request to the wrong (brand-public) partition would be a
|
||||
// cross-tenant write.
|
||||
// 3. Only for TRULY anonymous traffic (no principal, no key) is the tenant the
|
||||
// PUBLIC brand org derived SERVER-SIDE from the request Host via the
|
||||
// white-label registry — never a client-claimed org. An unrecognized Host is
|
||||
// refused (we never dump anonymous events into a default org).
|
||||
//
|
||||
// Returns ("", false) when the caller must be answered 403.
|
||||
func captureTenant(c *zip.Ctx) (string, bool) {
|
||||
if org, ok := tenant(c); ok {
|
||||
return org, true
|
||||
}
|
||||
if key := projectKey(c); key != "" {
|
||||
if org, ok := resolveKeyOrg(c.Context(), key); ok {
|
||||
return org, true
|
||||
}
|
||||
return "", false // presented key that does not resolve → fail CLOSED
|
||||
}
|
||||
if !publicCaptureEnabled() {
|
||||
return "", false
|
||||
}
|
||||
@@ -520,36 +574,75 @@ func captureTenant(c *zip.Ctx) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// capture ingests one batch into hanzo.events, tenant-scoped. Shared by
|
||||
// /v1/analytics, /v1/analytics/batch, and /v1/tracker.
|
||||
func capture(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := captureTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a recognized brand host required")
|
||||
// ── ONE write core ───────────────────────────────────────────────────────────
|
||||
|
||||
// event source tags — the ingest adapter each row arrived through. Stamped into
|
||||
// properties.$source by ingestEvents so the ONE hanzo.events table stays honest
|
||||
// about origin (canonical vs. deprecated wire) WITHOUT a second table or a schema
|
||||
// migration: the read lenses are unchanged and $source is queryable in the
|
||||
// properties JSON, which is exactly the migration signal for the alias sunset.
|
||||
const (
|
||||
sourceEvent = "event" // canonical POST /v1/event (native Event wire)
|
||||
sourcePostHog = "posthog" // POST /v1/insights/e (PostHog wire adapter, deprecated)
|
||||
sourceCapture = "capture" // POST /v1/analytics{,/batch}, /v1/tracker (Segment/beacon, deprecated)
|
||||
)
|
||||
|
||||
// withSource returns a copy of p carrying $source=source (the ingest adapter), so
|
||||
// normalizeEvent's scrub+store path records origin as a property. nil-safe; never
|
||||
// mutates the caller's map (the adapters share their event structs).
|
||||
func withSource(p map[string]any, source string) map[string]any {
|
||||
if source == "" {
|
||||
return p
|
||||
}
|
||||
var batch CaptureBatch
|
||||
if err := c.Bind(&batch); err != nil {
|
||||
return zip.ErrBadRequest("malformed capture batch")
|
||||
out := make(map[string]any, len(p)+1)
|
||||
for k, v := range p {
|
||||
out[k] = v
|
||||
}
|
||||
evs := batch.events()
|
||||
out["$source"] = source
|
||||
return out
|
||||
}
|
||||
|
||||
// deprecatedOnce records one deprecation log per alias path per process, so a
|
||||
// high-volume ingest alias signals its sunset exactly once instead of flooding.
|
||||
var deprecatedOnce sync.Map
|
||||
|
||||
// deprecated logs (once per path) that a superseded ingest alias was hit, pointing
|
||||
// callers at the canonical front door. It NEVER changes behavior — the alias keeps
|
||||
// working — it only records the migration signal (also visible as $source in the
|
||||
// warehouse).
|
||||
func deprecated(s *cloud.Service[state], c *zip.Ctx, canonical string) {
|
||||
p := c.Path()
|
||||
if _, seen := deprecatedOnce.LoadOrStore(p, struct{}{}); seen {
|
||||
return
|
||||
}
|
||||
s.Log.Warn("deprecated analytics ingest endpoint; migrate to the canonical event front door",
|
||||
"path", p, "canonical", canonical)
|
||||
}
|
||||
|
||||
// ingestEvents is the ONE write core: normalize → scrub → batch INSERT into the
|
||||
// ONE hanzo.events table. org is the SERVER-resolved tenant (never client input);
|
||||
// source tags the ingest adapter. Every front door — the canonical /v1/event and
|
||||
// the deprecated PostHog / Segment / beacon adapters — funnels here, so there is
|
||||
// exactly one write path. Returns the honest accepted/dropped receipt; the errors
|
||||
// it returns are already HTTP-shaped (zip) for the handler to pass straight up.
|
||||
func ingestEvents(ctx context.Context, org, source string, evs []CaptureEvent) (CaptureResult, error) {
|
||||
if len(evs) == 0 {
|
||||
return c.JSON(http.StatusOK, CaptureResult{})
|
||||
return CaptureResult{}, nil
|
||||
}
|
||||
if len(evs) > maxBatch {
|
||||
return zip.ErrBadRequest("batch too large")
|
||||
return CaptureResult{}, zip.ErrBadRequest("batch too large")
|
||||
}
|
||||
if err := requireDatastore(); err != nil {
|
||||
return err
|
||||
return CaptureResult{}, err
|
||||
}
|
||||
ctx := c.Context()
|
||||
if err := EnsureEventsTable(ctx); err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
return CaptureResult{}, zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rows := make([]eventRow, 0, len(evs))
|
||||
dropped := 0
|
||||
for _, e := range evs {
|
||||
e.Properties = withSource(e.Properties, source)
|
||||
row, ok := normalizeEvent(org, now, e)
|
||||
if !ok {
|
||||
dropped++
|
||||
@@ -558,12 +651,33 @@ func capture(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return c.JSON(http.StatusOK, CaptureResult{Dropped: dropped})
|
||||
return CaptureResult{Dropped: dropped}, nil
|
||||
}
|
||||
|
||||
stmt, args := buildEventsInsert(rows)
|
||||
if err := aiobject.DatastoreExec(ctx, stmt, args...); err != nil {
|
||||
return warehouseErr("capture", err)
|
||||
return CaptureResult{}, warehouseErr("capture", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, CaptureResult{Accepted: len(rows), Dropped: dropped})
|
||||
return CaptureResult{Accepted: len(rows), Dropped: dropped}, nil
|
||||
}
|
||||
|
||||
// capture ingests a Segment/beacon batch into hanzo.events, tenant-scoped. It is
|
||||
// the DEPRECATED wire adapter behind /v1/analytics, /v1/analytics/batch, and
|
||||
// /v1/tracker: a thin CaptureBatch decoder over the ONE write core (ingestEvents).
|
||||
// New callers post the canonical Event to /v1/event; this alias keeps working and
|
||||
// keeps captureTenant's brand-host path for anonymous marketing traffic.
|
||||
func capture(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
deprecated(s, c, "/v1/event")
|
||||
org, ok := captureTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a recognized brand host required")
|
||||
}
|
||||
var batch CaptureBatch
|
||||
if err := c.Bind(&batch); err != nil {
|
||||
return zip.ErrBadRequest("malformed capture batch")
|
||||
}
|
||||
res, err := ingestEvents(c.Context(), org, sourceCapture, batch.events())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// These tests cover the project-API-key → org resolution added to captureTenant so
|
||||
// keyed, bearer-less SDK traffic (posthog-js / insights-go batch) maps to a tenant.
|
||||
// They drive the REAL /v1/insights/e handler through the injectable resolveKeyOrg
|
||||
// seam, so no IAM is needed. The observable proxy for "resolved to a tenant" is
|
||||
// "passed the tenant gate" — i.e. NOT 403; without a datastore the handler then
|
||||
// returns 503, so any non-403 status means captureTenant admitted the request.
|
||||
|
||||
// stubResolver swaps resolveKeyOrg for the test and records the key it was handed,
|
||||
// so a test asserts BOTH that projectKey extracted the right key AND that
|
||||
// captureTenant honored the resolution. Restored via t.Cleanup.
|
||||
func stubResolver(t *testing.T, fn func(key string) (string, bool)) *string {
|
||||
t.Helper()
|
||||
var got string
|
||||
orig := resolveKeyOrg
|
||||
resolveKeyOrg = func(_ context.Context, key string) (string, bool) {
|
||||
got = key
|
||||
return fn(key)
|
||||
}
|
||||
t.Cleanup(func() { resolveKeyOrg = orig })
|
||||
return &got
|
||||
}
|
||||
|
||||
// postKeyed issues POST path (with optional ?query) to the mounted app, setting an
|
||||
// optional Host and headers and a raw JSON body — no middleware, mirroring the
|
||||
// SanitizeIdentity-minted-header harness the other analytics tests use.
|
||||
func postKeyed(t *testing.T, app *zip.App, path, host, body string, hdr map[string]string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if host != "" {
|
||||
req.Host = host
|
||||
}
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test POST %s: %v", path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// TestCaptureTenant_KeyExtractionReachesResolver: a project key presented in the
|
||||
// body, the ?api_key= query, or the x-api-key header is extracted and handed to the
|
||||
// resolver, and a resolved key passes the tenant gate (never 403).
|
||||
func TestCaptureTenant_KeyExtractionReachesResolver(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
cases := []struct {
|
||||
name, path, body string
|
||||
hdr map[string]string
|
||||
wantKey string
|
||||
}{
|
||||
{"body", "/v1/insights/e", `{"api_key":"hk-body","event":"e","distinct_id":"d"}`, nil, "hk-body"},
|
||||
{"query", "/v1/insights/e?api_key=hk-query", `{"event":"e","distinct_id":"d"}`, nil, "hk-query"},
|
||||
{"x-api-key", "/v1/insights/e", `{"event":"e","distinct_id":"d"}`, map[string]string{"x-api-key": "hk-hdr"}, "hk-hdr"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := stubResolver(t, func(string) (string, bool) { return "acme", true })
|
||||
code := postKeyed(t, app, tc.path, "", tc.body, tc.hdr)
|
||||
if *got != tc.wantKey {
|
||||
t.Fatalf("resolver handed key %q, want %q", *got, tc.wantKey)
|
||||
}
|
||||
if code == http.StatusForbidden {
|
||||
t.Fatalf("a resolved key must pass the tenant gate, got 403")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureTenant_UnresolvableKeyFailsClosed: a PRESENTED key that does not
|
||||
// resolve is refused (403) EVEN on a recognized brand host — it must never fall
|
||||
// through to the brand-public partition (that would be a cross-tenant write).
|
||||
func TestCaptureTenant_UnresolvableKeyFailsClosed(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
stubResolver(t, func(string) (string, bool) { return "", false }) // nothing resolves
|
||||
code := postKeyed(t, app, "/v1/insights/e", "hanzo.ai",
|
||||
`{"api_key":"hk-bad","event":"e","distinct_id":"d"}`, nil)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("presented-but-unresolvable key on a brand host must 403 (fail closed), got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureTenant_AnonBrandHostFallsBack: with NO key presented, anonymous
|
||||
// traffic on a recognized brand host still resolves to the brand org (not 403), and
|
||||
// the key resolver is never consulted — the key path only triggers on a real key.
|
||||
func TestCaptureTenant_AnonBrandHostFallsBack(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
stubResolver(t, func(key string) (string, bool) {
|
||||
t.Fatalf("resolver consulted for a keyless request (key=%q)", key)
|
||||
return "", false
|
||||
})
|
||||
code := postKeyed(t, app, "/v1/insights/e", "hanzo.ai",
|
||||
`{"event":"e","distinct_id":"d"}`, nil)
|
||||
if code == http.StatusForbidden {
|
||||
t.Fatalf("anonymous capture on a recognized brand host must pass the tenant gate, got 403")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// event.go — the ONE canonical event-ingestion front door.
|
||||
//
|
||||
// POST /v1/event body: Event | [Event] -> {accepted, dropped}
|
||||
//
|
||||
// A JSON object is one event; a JSON array IS the batch (there is deliberately no
|
||||
// /v1/event/batch). Every other ingest surface (the PostHog wire at
|
||||
// /v1/insights/e, the Segment/beacon wire at /v1/analytics{,/batch} and
|
||||
// /v1/tracker) is a thin DEPRECATED adapter that normalizes its own wire shape
|
||||
// onto CaptureEvent and funnels through the SAME write core (ingestEvents) into
|
||||
// the SAME hanzo.events table. One write path, many adapters.
|
||||
//
|
||||
// AUTH — IAM ONLY, FAIL-CLOSED: the tenant is resolved SERVER-SIDE from a
|
||||
// validated bearer principal (its owner org) or, for a keyed bearer-less SDK, an
|
||||
// access key resolved through the ONE IAM key seam (cloud.OrgForKey). There is NO
|
||||
// brand-host fallback on this endpoint: an unauthenticated or unresolvable caller
|
||||
// is refused (403), so the canonical door never writes an event into a tenant IAM
|
||||
// did not vouch for. The org is NEVER read from the body.
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Event is the canonical analytics event — the entire ingest contract in four
|
||||
// fields. Only these are first-class; everything else a caller wants to record
|
||||
// travels in Properties (the scrubber runs over it downstream, same as every
|
||||
// event). The tenant is NOT a field: it is resolved server-side from IAM, so a
|
||||
// caller can only ever write into its OWN org's partition.
|
||||
type Event struct {
|
||||
Event string `json:"event"` // event name (required; empty ⇒ dropped as unroutable)
|
||||
DistinctID string `json:"distinctId"` // the person/visitor id the caller owns
|
||||
Time string `json:"time"` // optional RFC3339; clamped to server-now on skew/absent
|
||||
Properties map[string]any `json:"properties"` // everything non-core
|
||||
}
|
||||
|
||||
// toCapture adapts the canonical Event onto the internal CaptureEvent the write
|
||||
// core consumes. Type is left empty (canonicalType ⇒ "event"); no $-property is
|
||||
// promoted to a column here — /v1/event stays a strict four-field contract, and
|
||||
// every non-core field the caller sent stays in Properties.
|
||||
func (e Event) toCapture() CaptureEvent {
|
||||
return CaptureEvent{
|
||||
Event: e.Event,
|
||||
DistinctID: e.DistinctID,
|
||||
Timestamp: e.Time,
|
||||
Properties: e.Properties,
|
||||
}
|
||||
}
|
||||
|
||||
// eventTenant resolves the tenant for POST /v1/event — IAM ONLY, FAIL-CLOSED. A
|
||||
// validated bearer principal wins (its owner org); otherwise a presented access
|
||||
// key is resolved to its org through the ONE IAM key seam (resolveKeyOrg →
|
||||
// cloud.OrgForKey). There is NO brand-host fallback: an unauthenticated or
|
||||
// unresolvable caller returns ("", false) → 403. (The deprecated aliases keep
|
||||
// captureTenant's brand-host path for anonymous marketing traffic; the canonical
|
||||
// endpoint is deliberately stricter — IAM is the only tenant authority here.)
|
||||
func eventTenant(c *zip.Ctx) (string, bool) {
|
||||
if org, ok := tenant(c); ok {
|
||||
return org, true
|
||||
}
|
||||
if key := projectKey(c); key != "" {
|
||||
if org, ok := resolveKeyOrg(c.Context(), key); ok {
|
||||
return org, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// decodeEvents decodes a request body as Event | []Event. The first non-space
|
||||
// byte decides: '[' ⇒ the array batch, anything else ⇒ a single Event. An empty
|
||||
// body yields no events (an honest empty receipt, not an error). Pure over the
|
||||
// raw bytes (the handler passes c.Body() — fasthttp-buffered, the same bytes
|
||||
// projectKey peeked) so the decode is driven directly by tests.
|
||||
func decodeEvents(body []byte) ([]Event, error) {
|
||||
i := 0
|
||||
for i < len(body) {
|
||||
if b := body[i]; b == ' ' || b == '\t' || b == '\r' || b == '\n' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if i >= len(body) {
|
||||
return nil, nil
|
||||
}
|
||||
if body[i] == '[' {
|
||||
var evs []Event
|
||||
if err := json.Unmarshal(body, &evs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return evs, nil
|
||||
}
|
||||
var e Event
|
||||
if err := json.Unmarshal(body, &e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Event{e}, nil
|
||||
}
|
||||
|
||||
// eventIngest answers POST /v1/event — the ONE canonical ingestion front door.
|
||||
// Org is IAM-derived and fail-closed (eventTenant); the body is Event | [Event];
|
||||
// every event flows through the ONE write core (ingestEvents) into the ONE
|
||||
// hanzo.events table, tagged source=event.
|
||||
func eventIngest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
org, ok := eventTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a resolvable access key required")
|
||||
}
|
||||
evs, err := decodeEvents(c.Body())
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("malformed event payload")
|
||||
}
|
||||
caps := make([]CaptureEvent, len(evs))
|
||||
for i, e := range evs {
|
||||
caps[i] = e.toCapture()
|
||||
}
|
||||
res, err := ingestEvents(c.Context(), org, sourceEvent, caps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── decodeEvents: Event | []Event (single + batch) ──────────────────────────
|
||||
|
||||
func TestDecodeEvents_Single(t *testing.T) {
|
||||
evs, err := decodeEvents([]byte(`{"event":"signup","distinctId":"u1","properties":{"plan":"pro"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("decode single: %v", err)
|
||||
}
|
||||
if len(evs) != 1 {
|
||||
t.Fatalf("single want 1 event, got %d", len(evs))
|
||||
}
|
||||
if evs[0].Event != "signup" || evs[0].DistinctID != "u1" {
|
||||
t.Fatalf("single decoded = %+v", evs[0])
|
||||
}
|
||||
if evs[0].Properties["plan"] != "pro" {
|
||||
t.Fatalf("single properties = %v", evs[0].Properties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvents_Batch(t *testing.T) {
|
||||
evs, err := decodeEvents([]byte(`[{"event":"a","distinctId":"d"},{"event":"b","distinctId":"d"}]`))
|
||||
if err != nil {
|
||||
t.Fatalf("decode batch: %v", err)
|
||||
}
|
||||
if len(evs) != 2 || evs[0].Event != "a" || evs[1].Event != "b" {
|
||||
t.Fatalf("batch decoded = %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvents_BatchLeadingWhitespace(t *testing.T) {
|
||||
// The array is detected past leading whitespace, not only at byte 0.
|
||||
evs, err := decodeEvents([]byte(" \n\t [{\"event\":\"a\"}]"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode ws-batch: %v", err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Event != "a" {
|
||||
t.Fatalf("ws-batch decoded = %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvents_EmptyIsNoEvents(t *testing.T) {
|
||||
// Empty / whitespace-only body ⇒ zero events, NOT an error (honest empty receipt).
|
||||
for _, b := range []string{"", " ", "\n\t"} {
|
||||
evs, err := decodeEvents([]byte(b))
|
||||
if err != nil || len(evs) != 0 {
|
||||
t.Fatalf("empty %q ⇒ evs=%v err=%v", b, evs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEvents_Malformed(t *testing.T) {
|
||||
for _, b := range []string{`{"event":`, `[{"event":"a"},`, `not json`} {
|
||||
if _, err := decodeEvents([]byte(b)); err == nil {
|
||||
t.Fatalf("malformed %q want error, got nil", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── adapter → Event/CaptureEvent normalization ──────────────────────────────
|
||||
|
||||
// TestEventToCapture: the canonical Event maps onto CaptureEvent with ONLY the
|
||||
// four core fields promoted; Type is left empty (⇒ "event") and everything else
|
||||
// stays in Properties (nothing is lifted to a column).
|
||||
func TestEventToCapture(t *testing.T) {
|
||||
e := Event{
|
||||
Event: "purchase",
|
||||
DistinctID: "u9",
|
||||
Time: "2026-07-18T00:00:00Z",
|
||||
Properties: map[string]any{"amount": 42, "$current_url": "https://x/y"},
|
||||
}
|
||||
ce := e.toCapture()
|
||||
if ce.Event != "purchase" || ce.DistinctID != "u9" || ce.Timestamp != "2026-07-18T00:00:00Z" {
|
||||
t.Fatalf("core fields = %+v", ce)
|
||||
}
|
||||
if ce.Type != "" {
|
||||
t.Fatalf("Type must be empty (⇒ canonicalType event), got %q", ce.Type)
|
||||
}
|
||||
if ce.URL != "" {
|
||||
t.Fatalf("no $-property is promoted to a column on the canonical wire; URL=%q", ce.URL)
|
||||
}
|
||||
// non-core stays in properties
|
||||
if ce.Properties["amount"] != 42 || ce.Properties["$current_url"] != "https://x/y" {
|
||||
t.Fatalf("properties passthrough = %v", ce.Properties)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventNormalizeThroughCore: a canonical Event, adapted and normalized, yields
|
||||
// a row stamped with the SERVER org and the resolved event name.
|
||||
func TestEventNormalizeThroughCore(t *testing.T) {
|
||||
row, ok := normalizeEvent("acme", time.Now(), Event{Event: "signup", DistinctID: "u1"}.toCapture())
|
||||
if !ok {
|
||||
t.Fatal("want routable")
|
||||
}
|
||||
if row.tenant != "acme" || row.event != "signup" || row.eventType != "event" {
|
||||
t.Fatalf("row = tenant %q event %q type %q", row.tenant, row.event, row.eventType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInsightsAdapterNormalization: the PostHog wire adapter lifts well-known
|
||||
// $-properties to columns (that is its job) — the OTHER adapter feeding the ONE
|
||||
// core, distinct from the canonical Event wire.
|
||||
func TestInsightsAdapterNormalization(t *testing.T) {
|
||||
ce := insightsEvent{
|
||||
Event: "$pageview",
|
||||
DistinctID: "d",
|
||||
Properties: map[string]any{"$current_url": "https://x/y", "$session_id": "s1"},
|
||||
}.toCapture()
|
||||
if ce.Type != "pageview" {
|
||||
t.Fatalf("posthog $pageview ⇒ type pageview, got %q", ce.Type)
|
||||
}
|
||||
if ce.URL != "https://x/y" || ce.SessionID != "s1" {
|
||||
t.Fatalf("posthog $-props ⇒ columns: url=%q session=%q", ce.URL, ce.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureBatchAdapter: the Segment/beacon adapter prefers `batch`, falling
|
||||
// back to `events`.
|
||||
func TestCaptureBatchAdapter(t *testing.T) {
|
||||
if got := (CaptureBatch{Batch: []CaptureEvent{{Event: "a"}}, Events: []CaptureEvent{{Event: "b"}}}).events(); len(got) != 1 || got[0].Event != "a" {
|
||||
t.Fatalf("batch preferred over events, got %+v", got)
|
||||
}
|
||||
if got := (CaptureBatch{Events: []CaptureEvent{{Event: "b"}}}).events(); len(got) != 1 || got[0].Event != "b" {
|
||||
t.Fatalf("events fallback, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── source tagging (the $source property, one-table origin discriminator) ────
|
||||
|
||||
func TestWithSource(t *testing.T) {
|
||||
// stamps $source
|
||||
got := withSource(nil, sourceEvent)
|
||||
if got["$source"] != "event" {
|
||||
t.Fatalf("withSource(nil,event) = %v", got)
|
||||
}
|
||||
// does not mutate the caller's map, and preserves existing keys
|
||||
orig := map[string]any{"a": 1}
|
||||
out := withSource(orig, sourcePostHog)
|
||||
if out["a"] != 1 || out["$source"] != "posthog" {
|
||||
t.Fatalf("withSource copy = %v", out)
|
||||
}
|
||||
if _, leaked := orig["$source"]; leaked {
|
||||
t.Fatalf("withSource mutated the caller's map: %v", orig)
|
||||
}
|
||||
// empty source is a no-op passthrough (same map)
|
||||
if got := withSource(orig, ""); got["$source"] != nil {
|
||||
t.Fatalf("empty source must not stamp, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStampedIntoProperties: source flows through withSource → normalizeEvent
|
||||
// → the stored properties JSON, so the ONE hanzo.events table carries origin
|
||||
// WITHOUT a schema column.
|
||||
func TestSourceStampedIntoProperties(t *testing.T) {
|
||||
e := CaptureEvent{Event: "x", Properties: withSource(nil, sourceEvent)}
|
||||
row, ok := normalizeEvent("acme", time.Now(), e)
|
||||
if !ok {
|
||||
t.Fatal("want routable")
|
||||
}
|
||||
props := decodeProps(t, row.properties)
|
||||
if props["$source"] != "event" {
|
||||
t.Fatalf("row.properties $source = %v (props=%v)", props["$source"], props)
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /v1/event: IAM-only, fail-closed auth ──────────────────────────────
|
||||
//
|
||||
// Observable proxy (mirrors capture_keyorg_test): a REFUSED request is 403; an
|
||||
// ADMITTED one reaches requireDatastore and returns 503 (no datastore in tests).
|
||||
// So "not 403" ⇒ the tenant gate admitted the request.
|
||||
|
||||
func TestEvent_NoPrincipalNoKeyForbidden(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "", "", `{"event":"e","distinctId":"d"}`); code != http.StatusForbidden {
|
||||
t.Fatalf("no-principal no-key /v1/event want 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvent_BearerPrincipalAdmitted(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
// A validated principal (X-User/X-Org) is admitted → 503 (datastore down), not 403.
|
||||
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "user-dave", "acme", `{"event":"signup","distinctId":"u1"}`); code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("bearer /v1/event want 503 (admitted, datastore down), got %d", code)
|
||||
}
|
||||
// A batch body is admitted the same way.
|
||||
if code, _ := doBody(t, app, http.MethodPost, "/v1/event", "user-dave", "acme", `[{"event":"a","distinctId":"d"},{"event":"b","distinctId":"d"}]`); code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("bearer /v1/event batch want 503, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvent_ResolvedKeyAdmitted(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
got := stubResolver(t, func(string) (string, bool) { return "acme", true })
|
||||
code := postKeyed(t, app, "/v1/event", "", `{"api_key":"hk-k","event":"e","distinctId":"d"}`, nil)
|
||||
if *got != "hk-k" {
|
||||
t.Fatalf("resolver handed key %q, want hk-k", *got)
|
||||
}
|
||||
if code == http.StatusForbidden {
|
||||
t.Fatalf("a resolved access key must pass the /v1/event gate, got 403")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvent_UnresolvableKeyFailsClosedEvenOnBrandHost(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
stubResolver(t, func(string) (string, bool) { return "", false })
|
||||
code := postKeyed(t, app, "/v1/event", "hanzo.ai", `{"api_key":"hk-bad","event":"e","distinctId":"d"}`, nil)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("presented-but-unresolvable key on /v1/event must 403 (fail closed), got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvent_NoBrandHostFallback is THE distinguishing invariant: anonymous traffic
|
||||
// on a recognized brand host is ADMITTED by the deprecated /v1/analytics alias
|
||||
// (brand-public partition) but REFUSED by the canonical /v1/event — IAM is the
|
||||
// only tenant authority on the canonical door.
|
||||
func TestEvent_NoBrandHostFallback(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
body := `{"event":"e","distinctId":"d"}`
|
||||
if code, _ := doHost(t, app, "/v1/event", "", "", "hanzo.ai", body); code != http.StatusForbidden {
|
||||
t.Fatalf("anonymous brand-host /v1/event must 403 (no brand fallback), got %d", code)
|
||||
}
|
||||
// Contrast: the deprecated alias still admits the same anonymous brand-host
|
||||
// traffic (503 = admitted, datastore down), proving the difference is by design.
|
||||
if code, _ := doHost(t, app, "/v1/analytics", "", "", "hanzo.ai", `{"batch":[{"type":"pageview"}]}`); code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("deprecated alias still brand-admits (want 503), got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,11 @@ import (
|
||||
)
|
||||
|
||||
// insightsEvent is the PostHog wire shape (subset that matters for ingest).
|
||||
// UUID is the top-level per-event id PostHog SDKs mint for idempotency; the rest
|
||||
// of the identity/attribution the SDKs carry rides inside Properties (mapped in
|
||||
// toCapture).
|
||||
type insightsEvent struct {
|
||||
UUID string `json:"uuid"`
|
||||
Event string `json:"event"`
|
||||
DistinctID string `json:"distinct_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
@@ -65,6 +69,11 @@ func (e insightsEvent) toCapture() CaptureEvent {
|
||||
typ = "pageview"
|
||||
}
|
||||
return CaptureEvent{
|
||||
// Idempotency id: PostHog SDKs carry a top-level event `uuid`; some send it
|
||||
// as an `$insert_id` property instead. Preserve it as the client MessageID so
|
||||
// a retried batch (insights-go retries with backoff) keeps a STABLE row id
|
||||
// rather than the server minting a fresh one per attempt.
|
||||
MessageID: firstNonEmptyStr(strings.TrimSpace(e.UUID), strings.TrimSpace(str("$insert_id"))),
|
||||
Type: typ,
|
||||
Event: e.Event,
|
||||
Timestamp: e.Timestamp,
|
||||
@@ -73,6 +82,19 @@ func (e insightsEvent) toCapture() CaptureEvent {
|
||||
URL: str("$current_url"),
|
||||
Path: str("$pathname"),
|
||||
Referrer: str("$referrer"),
|
||||
// UTM attribution: PostHog SDKs put campaign params in BARE `utm_*`
|
||||
// properties (not $-prefixed — confirmed against the SDK/ingest source).
|
||||
// hanzo.events has first-class utm_* columns and the native capture path
|
||||
// maps CaptureEvent.UTM into them (capture.go), so surfacing them here is
|
||||
// what lets the web/commerce lens attribute traffic to a campaign. They were
|
||||
// previously dropped on the PostHog-wire front door.
|
||||
UTM: UTM{
|
||||
Source: str("utm_source"),
|
||||
Medium: str("utm_medium"),
|
||||
Campaign: str("utm_campaign"),
|
||||
Term: str("utm_term"),
|
||||
Content: str("utm_content"),
|
||||
},
|
||||
Product: str("product"),
|
||||
Library: str("$lib"),
|
||||
LibraryVer: str("$lib_version"),
|
||||
@@ -80,9 +102,13 @@ func (e insightsEvent) toCapture() CaptureEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// insightsIngest answers POST /v1/insights/e — the PostHog-compatible front
|
||||
// door. Same tenant gate, same normalize/scrub, same warehouse as /v1/analytics.
|
||||
// insightsIngest answers POST /v1/insights/e — the DEPRECATED PostHog-wire
|
||||
// adapter. It normalizes the PostHog single/batch shape onto CaptureEvent and
|
||||
// funnels through the ONE write core (ingestEvents, source=posthog); it keeps
|
||||
// captureTenant's brand-host path so anonymous PostHog-wire traffic is unbroken.
|
||||
// New callers post the canonical Event to /v1/event.
|
||||
func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
deprecated(s, c, "/v1/event")
|
||||
org, ok := captureTenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("valid bearer or a recognized brand host required")
|
||||
@@ -95,39 +121,15 @@ func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if len(events) == 0 && body.Event != "" {
|
||||
events = []insightsEvent{body.insightsEvent}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return c.JSON(http.StatusOK, CaptureResult{})
|
||||
caps := make([]CaptureEvent, len(events))
|
||||
for i, e := range events {
|
||||
caps[i] = e.toCapture()
|
||||
}
|
||||
if len(events) > maxBatch {
|
||||
return zip.ErrBadRequest("batch too large")
|
||||
}
|
||||
if err := requireDatastore(); err != nil {
|
||||
res, err := ingestEvents(c.Context(), org, sourcePostHog, caps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := c.Context()
|
||||
if err := EnsureEventsTable(ctx); err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rows := make([]eventRow, 0, len(events))
|
||||
dropped := 0
|
||||
for _, e := range events {
|
||||
row, ok := normalizeEvent(org, now, e.toCapture())
|
||||
if !ok {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return c.JSON(http.StatusOK, CaptureResult{Dropped: dropped})
|
||||
}
|
||||
stmt, args := buildEventsInsert(rows)
|
||||
if err := aiobject.DatastoreExec(ctx, stmt, args...); err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse write failed: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, CaptureResult{Accepted: len(rows), Dropped: dropped})
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// insightsEvents answers GET /v1/insights/events — the console's recent-events
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestToCapture_PreservesUTMAttribution proves the PostHog-wire adapter carries the
|
||||
// BARE utm_* campaign params (what PostHog SDKs emit) through to the native
|
||||
// CaptureEvent — and thence, via normalizeEvent, into the hanzo.events utm_*
|
||||
// columns the INSERT binds. Regression guard: these were previously dropped, so
|
||||
// every campaign-attributed pageview lost its source/medium/campaign on the
|
||||
// /v1/insights/e front door and the web/commerce lens could never attribute it.
|
||||
func TestToCapture_PreservesUTMAttribution(t *testing.T) {
|
||||
e := insightsEvent{
|
||||
Event: "$pageview",
|
||||
DistinctID: "visitor-1",
|
||||
Properties: map[string]any{
|
||||
"utm_source": "newsletter",
|
||||
"utm_medium": "email",
|
||||
"utm_campaign": "launch",
|
||||
"utm_term": "analytics",
|
||||
"utm_content": "hero-cta",
|
||||
"$current_url": "https://hanzo.ai/insights",
|
||||
},
|
||||
}
|
||||
cap := e.toCapture()
|
||||
if cap.UTM.Source != "newsletter" || cap.UTM.Medium != "email" ||
|
||||
cap.UTM.Campaign != "launch" || cap.UTM.Term != "analytics" || cap.UTM.Content != "hero-cta" {
|
||||
t.Fatalf("UTM not mapped from PostHog wire: %+v", cap.UTM)
|
||||
}
|
||||
// End-to-end through the normalizer into the positional row the INSERT binds.
|
||||
row, ok := normalizeEvent("acme", time.Now(), cap)
|
||||
if !ok {
|
||||
t.Fatal("want ok")
|
||||
}
|
||||
if row.utmSource != "newsletter" || row.utmMedium != "email" ||
|
||||
row.utmCampaign != "launch" || row.utmTerm != "analytics" || row.utmContent != "hero-cta" {
|
||||
t.Fatalf("UTM lost before the events row: src=%q med=%q camp=%q term=%q content=%q",
|
||||
row.utmSource, row.utmMedium, row.utmCampaign, row.utmTerm, row.utmContent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToCapture_IdempotencyID proves the client event id (PostHog top-level `uuid`,
|
||||
// or the `$insert_id` property fallback) is preserved as the stable row id, so a
|
||||
// retried batch does not mint a fresh id per attempt — while an absent id still
|
||||
// falls back to a server-minted one (existing behavior unchanged).
|
||||
func TestToCapture_IdempotencyID(t *testing.T) {
|
||||
// top-level uuid wins
|
||||
row, _ := normalizeEvent("acme", time.Now(),
|
||||
insightsEvent{Event: "signup", DistinctID: "u1", UUID: "evt-abc"}.toCapture())
|
||||
if row.id != "evt-abc" {
|
||||
t.Fatalf("top-level uuid not preserved as row id, got %q", row.id)
|
||||
}
|
||||
// $insert_id property fallback when no top-level uuid
|
||||
row2, _ := normalizeEvent("acme", time.Now(),
|
||||
insightsEvent{Event: "signup", DistinctID: "u1", Properties: map[string]any{"$insert_id": "ins-9"}}.toCapture())
|
||||
if row2.id != "ins-9" {
|
||||
t.Fatalf("$insert_id fallback not preserved, got %q", row2.id)
|
||||
}
|
||||
// absent → server still mints a non-empty id
|
||||
row3, _ := normalizeEvent("acme", time.Now(),
|
||||
insightsEvent{Event: "signup", DistinctID: "u1"}.toCapture())
|
||||
if row3.id == "" {
|
||||
t.Fatal("server must still mint an id when the client sends none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToCapture_MapsCoreFields guards that the pre-existing $-property mappings
|
||||
// still hold alongside the new UTM/idempotency mappings (no regression).
|
||||
func TestToCapture_MapsCoreFields(t *testing.T) {
|
||||
cap := insightsEvent{
|
||||
Event: "$pageview",
|
||||
DistinctID: "v1",
|
||||
Properties: map[string]any{
|
||||
"$session_id": "s1",
|
||||
"$current_url": "https://hanzo.ai/x",
|
||||
"$pathname": "/x",
|
||||
"$referrer": "https://news.ycombinator.com/",
|
||||
"$lib": "insights-go",
|
||||
"$lib_version": "1.2.3",
|
||||
"product": "console",
|
||||
},
|
||||
}.toCapture()
|
||||
if cap.Type != "pageview" || cap.SessionID != "s1" || cap.URL != "https://hanzo.ai/x" ||
|
||||
cap.Path != "/x" || cap.Referrer != "https://news.ycombinator.com/" ||
|
||||
cap.Library != "insights-go" || cap.LibraryVer != "1.2.3" || cap.Product != "console" {
|
||||
t.Fatalf("core PostHog-wire mapping regressed: %+v", cap)
|
||||
}
|
||||
}
|
||||
+26
-134
@@ -1,152 +1,44 @@
|
||||
package authors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/commerceinproc"
|
||||
"github.com/hanzoai/cloud/clients/payout"
|
||||
)
|
||||
|
||||
// commerce is the narrow money seam the author royalty loop needs: read a deploying
|
||||
// org's metered spend (the accrual base) and grant a promo credit to a wallet (a
|
||||
// payout made in credits). It is an INTERFACE so the store/handler logic is testable
|
||||
// with a fake ledger — the HTTP impl below is the ONE production binding.
|
||||
// org's metered spend (the royalty accrual base) and grant a promo credit to a wallet
|
||||
// (a payout made in credits, ledger tag grant:author). It is an INTERFACE so the
|
||||
// store/handler logic is testable with a fake ledger; the production binding is
|
||||
// clients/payout, reached through the thin adapter below.
|
||||
//
|
||||
// This mirrors clients/affiliates/commerce.go EXACTLY: the same
|
||||
// COMMERCE_SERVICE_TOKEN S2S path, the same X-Org-Id=<org> namespace + bare org
|
||||
// `user` subject — so an author payout-in-credits lands in precisely the wallet the
|
||||
// balance panel reads, indistinguishable from an admin/affiliate grant except by its
|
||||
// ledger tag (grant:author, → the commerce Credit/trial bucket per DepositKind's
|
||||
// grant:* rule).
|
||||
// The S2S impl (COMMERCE_SERVICE_TOKEN path, X-Org-Id=<org> namespace, bare-org
|
||||
// `user` subject) was three byte-identical commerce.go copies; it now lives ONCE in
|
||||
// clients/payout. An author payout-in-credits still lands in precisely the wallet the
|
||||
// balance panel reads, indistinguishable from an admin grant except by its
|
||||
// grant:author tag.
|
||||
type commerce interface {
|
||||
configured() bool
|
||||
// deposit grants amountCents to org's wallet (Credit/trial bucket via the
|
||||
// grant:author tag) and returns the ledger transaction id.
|
||||
deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (txnID string, err error)
|
||||
// spendCents is a deploying org's month-to-date metered consumption — the royalty
|
||||
// accrual base (spend × the author's share).
|
||||
spendCents(ctx context.Context, org, user string) (int64, error)
|
||||
}
|
||||
|
||||
// errUnconfigured is returned by a deposit against an unwired commerce so the caller
|
||||
// records an honest failure rather than reporting a phantom payout.
|
||||
var errUnconfigured = errors.New("authors: commerce endpoint not configured")
|
||||
// errUnconfigured is the shared sentinel a deposit against an unwired commerce
|
||||
// returns, so the caller records an honest failure rather than a phantom payout.
|
||||
var errUnconfigured = payout.ErrUnconfigured
|
||||
|
||||
// httpCommerce is the production commerce binding (COMMERCE_SERVICE_TOKEN S2S).
|
||||
type httpCommerce struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
// commerceSeam adapts the shared payout.Client onto this program's lowercase seam
|
||||
// (Go package-scoped interface methods cannot cross packages). Zero logic — pure
|
||||
// delegation; the money path lives in clients/payout.
|
||||
type commerceSeam struct{ c *payout.Client }
|
||||
|
||||
func (s commerceSeam) configured() bool { return s.c.Configured() }
|
||||
func (s commerceSeam) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
|
||||
return s.c.Deposit(ctx, org, user, amountCents, currency, notes, tags)
|
||||
}
|
||||
func (s commerceSeam) spendCents(ctx context.Context, org, user string) (int64, error) {
|
||||
return s.c.SpendCents(ctx, org, user)
|
||||
}
|
||||
|
||||
func newCommerceClient(base, token string) *httpCommerce {
|
||||
return &httpCommerce{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: commerceinproc.Client(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *httpCommerce) configured() bool { return c != nil && c.base != "" && c.token != "" }
|
||||
|
||||
// deposit posts POST /v1/billing/deposit — the ONE money-in primitive (identical to
|
||||
// affiliates.httpCommerce.deposit). Commerce's EdgeAuth pins the body `user` to the
|
||||
// X-Org-Id subject, so a payout can never be mis-targeted to another wallet.
|
||||
func (c *httpCommerce) deposit(ctx context.Context, org, user string, amountCents int64, currency, notes, tags string) (string, error) {
|
||||
if !c.configured() {
|
||||
return "", errUnconfigured
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"user": user,
|
||||
"currency": currency,
|
||||
"amount": amountCents,
|
||||
"notes": notes,
|
||||
"tags": tags,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/v1/billing/deposit", nil, org, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("commerce deposit decode: %w", err)
|
||||
}
|
||||
return out.TransactionID, nil
|
||||
}
|
||||
|
||||
// spendCents reads GET /v1/billing/usage-rollup and returns consumedCents. Zero (not
|
||||
// an error) when commerce is unconfigured so a partial deploy degrades to "no spend
|
||||
// to accrue yet" rather than a 5xx.
|
||||
func (c *httpCommerce) spendCents(ctx context.Context, org, user string) (int64, error) {
|
||||
if !c.configured() {
|
||||
return 0, nil
|
||||
}
|
||||
q := url.Values{"user": {user}}
|
||||
raw, err := c.do(ctx, http.MethodGet, "/v1/billing/usage-rollup", q, org, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var out struct {
|
||||
ConsumedCents int64 `json:"consumedCents"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return 0, fmt.Errorf("commerce rollup decode: %w", err)
|
||||
}
|
||||
return out.ConsumedCents, nil
|
||||
}
|
||||
|
||||
// do performs one admin-S2S commerce request. X-Org-Id=<org> is the per-org
|
||||
// namespace selector commerce's EdgeAuth trusts only behind the service token.
|
||||
func (c *httpCommerce) do(ctx context.Context, method, path string, q url.Values, org string, body []byte) ([]byte, error) {
|
||||
u := c.base + path
|
||||
if enc := q.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
r = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commerce unreachable: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("commerce status %d", resp.StatusCode)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// newCommerceClient builds the production binding, delegating to clients/payout.
|
||||
func newCommerceClient(base, token string) commerce { return commerceSeam{payout.NewClient(base, token)} }
|
||||
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/cloud/clients/connectorruntime"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/hanzoai/cloud/clients/tools"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -138,6 +139,15 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
tools.Register(connectorToolProvider{})
|
||||
|
||||
b.Log.Info("automations mounted", "connectors", catalog.ConnectorCount, "runtime", len(registry), "brand", deps.Brand)
|
||||
|
||||
// Native single-connector execution (HIP-0126): POST /v1/automations/connectors/:id/run,
|
||||
// the in-process goja runner paired with the connector catalogue above. It mounts one
|
||||
// route DISTINCT from every automations route (no /v1/automations/* wildcard here, so no
|
||||
// shadow), and was a separate Wire entry purely for that one route — fold it in as a
|
||||
// terminal sub-mount so connector catalogue + execution are ONE automations subsystem.
|
||||
if err := connectorruntime.Mount(app, deps); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// LANE 2 — managed Base hosting (what superbase/PocketHost provided). ONE Base
|
||||
// app PER ORG, opened lazily and pooled, each on its OWN SQLite under
|
||||
// {DataDir}/base/{orgSegment}/ — the same "prod = SQLite per tenant" model
|
||||
// (HIP-0302) the gojabase leaves (captable/sign/dataroom) use, so an org's
|
||||
// (HIP-0302) the NewBase leaves (captable/sign/dataroom) use, so an org's
|
||||
// collections/records are PHYSICALLY isolated. Served AUTHENTICATED under
|
||||
// /v1/base/*, the org resolved from the VALIDATED cloud principal (never a
|
||||
// client header). This is the console Bases manager's backend.
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
baseapp "github.com/hanzoai/base"
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/gojabase"
|
||||
"github.com/hanzoai/cloud/clients/goja"
|
||||
luxlog "github.com/luxfi/log"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -170,8 +170,8 @@ func TestPerOrgIsolatedCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
// The isolation is physical: distinct on-disk data dirs per org segment.
|
||||
acmeDir := filepath.Join(dataDir, "base", gojabase.TenantSegment("acme"))
|
||||
globexDir := filepath.Join(dataDir, "base", gojabase.TenantSegment("globex"))
|
||||
acmeDir := filepath.Join(dataDir, "base", goja.TenantSegment("acme"))
|
||||
globexDir := filepath.Join(dataDir, "base", goja.TenantSegment("globex"))
|
||||
if acmeDir == globexDir {
|
||||
t.Fatalf("orgs share a data dir: %s", acmeDir)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
baseapp "github.com/hanzoai/base"
|
||||
"github.com/hanzoai/base/apis"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/gojabase"
|
||||
"github.com/hanzoai/cloud/clients/goja"
|
||||
)
|
||||
|
||||
// Pool sizing (env-overridable). A full Base app is heavier than a bare *sql.DB
|
||||
@@ -35,7 +35,7 @@ const (
|
||||
// ({DataDir}/base/{TenantSegment}/), the "prod = SQLite per tenant" rule
|
||||
// (HIP-0302). Apps open lazily on first request, migrate once, and are pooled
|
||||
// (LRU-capped, idle-evicted). Concurrent opens of the same org are
|
||||
// single-flighted under mu. The org→segment encoding is gojabase.TenantSegment —
|
||||
// single-flighted under mu. The org→segment encoding is goja.TenantSegment —
|
||||
// the ONE injective, traversal-safe tenant→path encoder, shared so an org maps
|
||||
// to exactly one physical identity everywhere in the binary.
|
||||
type pool struct {
|
||||
@@ -85,7 +85,7 @@ func (p *pool) acquire(org string) (http.Handler, func(), error) {
|
||||
if strings.TrimSpace(org) == "" {
|
||||
return nil, nil, fmt.Errorf("base: empty org")
|
||||
}
|
||||
seg := gojabase.TenantSegment(org)
|
||||
seg := goja.TenantSegment(org)
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@@ -111,7 +111,7 @@ func (p *pool) acquire(org string) (http.Handler, func(), error) {
|
||||
// for in-process callers that drive the engine's Go API directly (collection
|
||||
// provisioning, seeding) rather than the HTTP path.
|
||||
func (p *pool) appFor(org string) (*baseapp.Base, error) {
|
||||
seg := gojabase.TenantSegment(org)
|
||||
seg := goja.TenantSegment(org)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if e, ok := p.m[seg]; ok {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/hanzoai/account"
|
||||
"github.com/hanzoai/cloud/clients/finance"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -57,7 +58,14 @@ import (
|
||||
// production tokens carry one. Called out for review rather than papered over.
|
||||
func subjectFor(c *zip.Ctx, org string) string {
|
||||
if name := strings.TrimSpace(c.Header("X-User-Name")); name != "" {
|
||||
return account.Payer(account.Credential{Owner: org, Name: name}).Subject()
|
||||
// Hand Payer the account the credential NAMES (the validated `billing_account`
|
||||
// claim, minted into X-Billing-Account-Id). The ai gate reads the same claim,
|
||||
// so this view and that gate resolve one wallet. Reading it here is what keeps
|
||||
// them from drifting the way the org-vs-"org/user" split once did — except
|
||||
// that split was two rules, and this would be one rule fed two different
|
||||
// credentials, which reads the same to a user: a funded balance the gate
|
||||
// refuses. Absent ⟹ Payer's legacy rule, exactly today's answer.
|
||||
return account.Payer(account.Credential{Owner: org, Name: name, Account: principal.BillingAccount(c)}).Subject()
|
||||
}
|
||||
return account.PayerOf(org, strings.TrimSpace(c.User())).Subject()
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
// business LOGIC (ported to a self-contained goja bundle in github.com/hanzoai/
|
||||
// captable) and gives it PERSISTENCE over per-tenant Base/SQLite. The bundle
|
||||
// carries logic; the Go host carries storage. The seam between them is the
|
||||
// REUSABLE clients/gojabase binding (the RW-Base goja host), which esign (#100)
|
||||
// REUSABLE clients/goja binding (the RW-Base goja host), which esign (#100)
|
||||
// and dataroom (#101) reuse unchanged — this leaf is just:
|
||||
//
|
||||
// captable bundle (github.com/hanzoai/captable.Bundle) + the per-tenant Schema
|
||||
// │
|
||||
// clients/gojabase.New(...) ← injects __db/__newId/__now,
|
||||
// clients/goja.NewBase(...) ← injects __db/__newId/__now,
|
||||
// │ one SQLite file per tenant,
|
||||
// /v1/captable/* zip routes one transaction per request
|
||||
//
|
||||
@@ -37,7 +37,7 @@ import (
|
||||
|
||||
hcaptable "github.com/hanzoai/captable"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/gojabase"
|
||||
"github.com/hanzoai/cloud/clients/goja"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
@@ -48,7 +48,7 @@ const maxBody = 1 << 20 // 1 MiB
|
||||
|
||||
// state is captable's own data; shared deps live in the embedded cloud.Base.
|
||||
type state struct {
|
||||
host *gojabase.Host
|
||||
host *goja.BaseHost
|
||||
}
|
||||
|
||||
// mounted is the active service so Shutdown can release the per-tenant stores.
|
||||
@@ -72,7 +72,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("captable.Mount: load bundle: %w", err)
|
||||
}
|
||||
host, err := gojabase.New(gojabase.Config{
|
||||
host, err := goja.NewBase(goja.BaseConfig{
|
||||
Name: "captable",
|
||||
Bundle: bundle,
|
||||
Schema: schema,
|
||||
@@ -80,7 +80,7 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
OnOpen: seedCompany,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("captable.Mount: gojabase host: %w", err)
|
||||
return fmt.Errorf("captable.Mount: goja NewBase host: %w", err)
|
||||
}
|
||||
s := &cloud.Service[state]{Base: cloud.NewBase(deps, "captable"), State: state{host: host}}
|
||||
mounted = s
|
||||
@@ -176,7 +176,7 @@ func dispatch(s *cloud.Service[state], c *zip.Ctx, route string, params map[stri
|
||||
}
|
||||
}
|
||||
}
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
|
||||
Route: route,
|
||||
Params: params,
|
||||
Body: body,
|
||||
|
||||
@@ -6,22 +6,22 @@ import (
|
||||
"testing"
|
||||
|
||||
hcaptable "github.com/hanzoai/captable"
|
||||
"github.com/hanzoai/cloud/clients/gojabase"
|
||||
"github.com/hanzoai/cloud/clients/goja"
|
||||
)
|
||||
|
||||
// TestFullLifecycle drives the REAL embedded captable bundle against a REAL
|
||||
// per-tenant SQLite through gojabase — the end-to-end proof that the bundle's SQL
|
||||
// per-tenant SQLite through NewBase — the end-to-end proof that the bundle's SQL
|
||||
// matches the Go host schema and that the whole cap-table fold round-trips
|
||||
// through Base: company → stakeholders → share class → equity plan → share
|
||||
// issuance → options → SAFE → priced round + investment (dilution) → transfer →
|
||||
// computed cap table. Any column/route drift fails here, not in production.
|
||||
func newHost(t *testing.T) *gojabase.Host {
|
||||
func newHost(t *testing.T) *goja.BaseHost {
|
||||
t.Helper()
|
||||
bundle, err := hcaptable.Bundle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := gojabase.New(gojabase.Config{
|
||||
h, err := goja.NewBase(goja.BaseConfig{
|
||||
Name: "captable",
|
||||
Bundle: bundle,
|
||||
Schema: schema,
|
||||
@@ -36,9 +36,9 @@ func newHost(t *testing.T) *gojabase.Host {
|
||||
}
|
||||
|
||||
// do dispatches a route and returns (status, decoded body).
|
||||
func do(t *testing.T, h *gojabase.Host, org, route string, params map[string]string, body any) (int, any) {
|
||||
func do(t *testing.T, h *goja.BaseHost, org, route string, params map[string]string, body any) (int, any) {
|
||||
t.Helper()
|
||||
resp, err := h.Dispatch(context.Background(), org, gojabase.Request{Route: route, Params: params, Body: body})
|
||||
resp, err := h.Dispatch(context.Background(), org, goja.BaseRequest{Route: route, Params: params, Body: body})
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch %s: %v", route, err)
|
||||
}
|
||||
@@ -300,7 +300,7 @@ func TestDilutiveOptionsExcludeTerminal(t *testing.T) {
|
||||
}
|
||||
|
||||
// firstShareID returns the founder's original certificate share id.
|
||||
func firstShareID(t *testing.T, h *gojabase.Host, org string) string {
|
||||
func firstShareID(t *testing.T, h *goja.BaseHost, org string) string {
|
||||
t.Helper()
|
||||
_, body := do(t, h, org, "shares.list", nil, nil)
|
||||
data := body.(map[string]any)["data"].([]any)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/gojabase"
|
||||
"github.com/hanzoai/cloud/clients/goja"
|
||||
)
|
||||
|
||||
// facade.go is the in-process cap-table seam: it lets a sibling subsystem (Hanzo
|
||||
@@ -74,7 +74,7 @@ type RoundInput struct {
|
||||
// response. The body is round-tripped through JSON to a generic value so the goja
|
||||
// bundle sees the SAME wire shape (lower-case json keys) the HTTP path produces —
|
||||
// passing a typed Go struct straight to goja would expose Go field names instead.
|
||||
func facadeDispatch(ctx context.Context, org, route string, params map[string]string, body any) (*gojabase.Response, error) {
|
||||
func facadeDispatch(ctx context.Context, org, route string, params map[string]string, body any) (*goja.Response, error) {
|
||||
if mounted == nil || mounted.State.host == nil {
|
||||
return nil, ErrNotMounted
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func facadeDispatch(ctx context.Context, org, route string, params map[string]st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mounted.State.host.Dispatch(ctx, org, gojabase.Request{Route: route, Params: params, Body: wire})
|
||||
return mounted.State.host.Dispatch(ctx, org, goja.BaseRequest{Route: route, Params: params, Body: wire})
|
||||
}
|
||||
|
||||
// toWire normalizes a typed value to a generic JSON value (map[string]any /
|
||||
@@ -107,7 +107,7 @@ func toWire(body any) (any, error) {
|
||||
|
||||
// okBody checks the response is 2xx and returns the body bytes, else a descriptive
|
||||
// error carrying the bundle's own message.
|
||||
func okBody(resp *gojabase.Response, route string) ([]byte, error) {
|
||||
func okBody(resp *goja.Response, route string) ([]byte, error) {
|
||||
if resp.Status/100 != 2 {
|
||||
return nil, fmt.Errorf("captable %s: status %d: %s", route, resp.Status, string(resp.Body))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// mountApp builds a bare zip.App (no SanitizeIdentity middleware, so X-Org-Id +
|
||||
// X-User-Id are trusted verbatim — the standard cloud leaf test harness) and
|
||||
// mounts the captable leaf on it. This exercises the REAL HTTP path: routing →
|
||||
// body decode → principal gate → gojabase dispatch → per-tenant Base → response,
|
||||
// body decode → principal gate → NewBase dispatch → per-tenant Base → response,
|
||||
// the same path the live binary serves under CLOUD_ENABLE=captable.
|
||||
func mountApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// schema is the per-tenant SQLite DDL — the Go host owns migrations; the goja
|
||||
// bundle only issues SQL against these tables. Column names MUST match the SQL in
|
||||
// the captable bundle (github.com/hanzoai/captable goja/src/routes/*). Idempotent
|
||||
// (IF NOT EXISTS), so it runs on every tenant DB open via gojabase.
|
||||
// (IF NOT EXISTS), so it runs on every tenant DB open via NewBase.
|
||||
//
|
||||
// This is the Prisma data model (prisma/schema.prisma) translated to SQLite:
|
||||
// DateTime → TEXT (ISO strings stored verbatim; the bundle never parses them),
|
||||
@@ -217,7 +217,7 @@ CREATE INDEX IF NOT EXISTS ix_investment_company ON investment(company_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_investment_round ON investment(round_id);
|
||||
`
|
||||
|
||||
// seedCompany is the gojabase OnOpen hook: it ensures the tenant's cap-table
|
||||
// seedCompany is the NewBase OnOpen hook: it ensures the tenant's cap-table
|
||||
// company row exists (id == the validated tenant), so the bundle's companyId
|
||||
// always resolves. The name defaults to the tenant and is renamed via
|
||||
// PUT /v1/captable/company. INSERT OR IGNORE makes it idempotent across reopens.
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
// Package cloudflare is the per-org Cloudflare asset plane for the unified Hanzo
|
||||
// Cloud binary — the /v1/integrations/cloudflare/* surface that manages an org's Cloudflare
|
||||
// Pages, Workers, and (Phase 2) R2/KV/D1 through the SAME per-org, KMS-sealed API
|
||||
// token the org connected via clients/integrations. It is a sibling of hanzodns
|
||||
// (which owns /v1/dns as a separate CoreDNS process): both drive Cloudflare with an
|
||||
// org's own scoped token, so the platform never reaches Cloudflare with a global
|
||||
// env token again — one token, one custody boundary, one org.
|
||||
//
|
||||
// TENANT ISOLATION (the crown jewel). Every handler resolves the caller's org from
|
||||
// the VALIDATED principal (principal.Org → the X-Org-Id the identity boundary minted
|
||||
// from a verified credential, HIP-0026 / SanitizeIdentity), NEVER from a body or
|
||||
// query field. The org is then the ONLY input to token custody: the per-org token is
|
||||
// read in-process through the ONE seam integrations.TokenFor, which keys KMS on that
|
||||
// org (/orgs/{org}/integrations/cloudflare/api_token). So a request can ONLY ever
|
||||
// address its own org's Cloudflare account:
|
||||
// - no validated principal ⟹ principal.Org fails ⟹ 403 (a forged X-Org-Id with no
|
||||
// bearer is refused by the identity boundary, then again here);
|
||||
// - a non-SuperAdmin bearer has X-Org-Id pinned to its own owner (SanitizeIdentity),
|
||||
// so it cannot name another org;
|
||||
// - cross-org token reach is structurally impossible — the token path is derived
|
||||
// from the validated org, not from any caller-controlled field.
|
||||
//
|
||||
// The token rides ONLY the Authorization header on the outbound Cloudflare request;
|
||||
// it is never logged, echoed in an error, or stored by this subsystem.
|
||||
//
|
||||
// FAIL-CLOSED. An org that has not connected Cloudflare, an unmounted integrations
|
||||
// plane, or a KMS that is not Ready each yield an error and a 503 — never another
|
||||
// org's data and never a silent success.
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/integrations"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
const (
|
||||
// providerCloudflare is the integrations provider slug the token is custodied
|
||||
// under, and secretAPIToken the secret name — the SAME coordinate the connector
|
||||
// (clients/integrations/cloudflare.go) seals BOTH the apikey and OAuth paths to,
|
||||
// and hanzodns reads for DNS. One coordinate, auth-method-agnostic.
|
||||
providerCloudflare = "cloudflare"
|
||||
secretAPIToken = "api_token"
|
||||
)
|
||||
|
||||
// tokenFor is the ONE door to per-org Cloudflare token custody. It defaults to
|
||||
// integrations.TokenFor (KMS-sealed, fail-closed, org-validated). It is a package
|
||||
// var ONLY so a test can inject per-org tokens and prove every fetch scopes to the
|
||||
// caller's org; production never reassigns it.
|
||||
var tokenFor = integrations.TokenFor
|
||||
|
||||
// connectionFor reads an org's NON-secret connection metadata (the account id captured
|
||||
// at connect time, ExternalID) from the integrations plane. Also a package var ONLY
|
||||
// for test injection; production never reassigns it.
|
||||
var connectionFor = integrations.ConnectionFor
|
||||
|
||||
// cfAPIBase is Cloudflare's API v4 origin. Overridable via CLOUDFLARE_API_BASE for
|
||||
// tests (an httptest server) and CF-compatible endpoints; read at call time. The
|
||||
// default is the real Cloudflare API. (Same knob hanzodns uses, so a test harness
|
||||
// points both planes at one stub.)
|
||||
func cfAPIBase() string {
|
||||
if v := strings.TrimSpace(os.Getenv("CLOUDFLARE_API_BASE")); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return "https://api.cloudflare.com/client/v4"
|
||||
}
|
||||
|
||||
// cfHTTPClient is the shared client for every Cloudflare call. A bounded timeout so a
|
||||
// slow/hung Cloudflare never wedges a request goroutine.
|
||||
var cfHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
var (
|
||||
// nameRE bounds a Cloudflare NAME path segment (Pages project, Worker script,
|
||||
// custom-domain name/id, bucket, namespace, database). It is validated before it
|
||||
// is folded into an upstream URL so a hostile value can never smuggle path
|
||||
// structure (a `/` or `..`) into the Cloudflare request.
|
||||
nameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
// idRE bounds a Cloudflare 32-hex ID path segment (account id, zone id, route id).
|
||||
idRE = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
||||
)
|
||||
|
||||
// state is this subsystem's own data — none. Token custody lives in integrations;
|
||||
// the account id is resolved per request. So the value is the shared Base only.
|
||||
type state struct{}
|
||||
|
||||
// Mount wires /v1/integrations/cloudflare/* onto app. The subsystem is stateless (no store, no
|
||||
// goroutine): it reads the per-org token in-process per request and proxies to
|
||||
// Cloudflare, so there is nothing to build or tear down.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
return cloud.Mount(app, deps, "cloudflare",
|
||||
func(cloud.Base) (state, error) { return state{}, nil },
|
||||
routes)
|
||||
}
|
||||
|
||||
// routes registers the /v1/integrations/cloudflare surface. Every route runs through authClient
|
||||
// (validated-org gate + fail-closed per-org token) FIRST, so no route is a softer
|
||||
// target than another. Pages + Workers are WIRED; R2/KV/D1 are typed Phase-2 stubs
|
||||
// that answer an honest 501 (never a fake success).
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
// Pages (wired) — account-scoped.
|
||||
app.Get("/v1/integrations/cloudflare/pages/projects", cloud.Handle(s, pagesList))
|
||||
app.Post("/v1/integrations/cloudflare/pages/projects", cloud.Handle(s, pagesCreate))
|
||||
app.Get("/v1/integrations/cloudflare/pages/projects/:project", cloud.Handle(s, pagesGet))
|
||||
app.Delete("/v1/integrations/cloudflare/pages/projects/:project", cloud.Handle(s, pagesDelete))
|
||||
app.Post("/v1/integrations/cloudflare/pages/projects/:project/deployments", cloud.Handle(s, pagesDeploy))
|
||||
app.Post("/v1/integrations/cloudflare/pages/projects/:project/domains", cloud.Handle(s, pagesDomainAdd))
|
||||
app.Delete("/v1/integrations/cloudflare/pages/projects/:project/domains/:domain", cloud.Handle(s, pagesDomainDelete))
|
||||
|
||||
// Workers (wired) — scripts + workers.dev subdomain are account-scoped; routes
|
||||
// are zone-scoped.
|
||||
app.Get("/v1/integrations/cloudflare/workers/scripts", cloud.Handle(s, workersScriptList))
|
||||
app.Put("/v1/integrations/cloudflare/workers/scripts/:script", cloud.Handle(s, workersScriptPut))
|
||||
app.Delete("/v1/integrations/cloudflare/workers/scripts/:script", cloud.Handle(s, workersScriptDelete))
|
||||
app.Post("/v1/integrations/cloudflare/workers/scripts/:script/subdomain", cloud.Handle(s, workersScriptSubdomainSet))
|
||||
app.Get("/v1/integrations/cloudflare/workers/subdomain", cloud.Handle(s, workersSubdomainGet))
|
||||
app.Get("/v1/integrations/cloudflare/workers/zones/:zone/routes", cloud.Handle(s, workersRouteList))
|
||||
app.Post("/v1/integrations/cloudflare/workers/zones/:zone/routes", cloud.Handle(s, workersRouteCreate))
|
||||
app.Delete("/v1/integrations/cloudflare/workers/zones/:zone/routes/:route", cloud.Handle(s, workersRouteDelete))
|
||||
|
||||
// R2 / KV / D1 (Phase-2 stubs) — routes + typed provider methods exist; bodies
|
||||
// ship in Phase 2. Each answers an honest 501, never a misleading 200.
|
||||
app.Get("/v1/integrations/cloudflare/r2/buckets", cloud.Handle(s, r2BucketList))
|
||||
app.Post("/v1/integrations/cloudflare/r2/buckets", cloud.Handle(s, r2BucketCreate))
|
||||
app.Delete("/v1/integrations/cloudflare/r2/buckets/:bucket", cloud.Handle(s, r2BucketDelete))
|
||||
app.Get("/v1/integrations/cloudflare/kv/namespaces", cloud.Handle(s, kvNamespaceList))
|
||||
app.Post("/v1/integrations/cloudflare/kv/namespaces", cloud.Handle(s, kvNamespaceCreate))
|
||||
app.Delete("/v1/integrations/cloudflare/kv/namespaces/:namespace", cloud.Handle(s, kvNamespaceDelete))
|
||||
app.Get("/v1/integrations/cloudflare/d1/databases", cloud.Handle(s, d1DatabaseList))
|
||||
app.Post("/v1/integrations/cloudflare/d1/databases", cloud.Handle(s, d1DatabaseCreate))
|
||||
app.Delete("/v1/integrations/cloudflare/d1/databases/:database", cloud.Handle(s, d1DatabaseDelete))
|
||||
}
|
||||
|
||||
// ── client (the cfDo shape, reused verbatim from hanzodns) ──────────────────────
|
||||
|
||||
// client drives the Cloudflare API v4 with a per-org scoped token. The token rides
|
||||
// only the Authorization header — never a query parameter, error, or log line.
|
||||
type client struct {
|
||||
token string
|
||||
base string
|
||||
}
|
||||
|
||||
// cfEnvelope is the shared Cloudflare API v4 response envelope.
|
||||
type cfEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
// cfError carries the upstream Cloudflare HTTP status so a proxied not-found/bad
|
||||
// request is reported with a recognizable code rather than a blanket 502. Its
|
||||
// message is Cloudflare's own — token-free by construction.
|
||||
type cfError struct {
|
||||
upstream int
|
||||
code int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *cfError) Error() string { return e.msg }
|
||||
|
||||
func (e cfEnvelope) err(status int) error {
|
||||
if e.Success {
|
||||
return nil
|
||||
}
|
||||
if len(e.Errors) > 0 {
|
||||
return &cfError{upstream: status, code: e.Errors[0].Code,
|
||||
msg: fmt.Sprintf("cloudflare API error (%d): [%d] %s", status, e.Errors[0].Code, e.Errors[0].Message)}
|
||||
}
|
||||
return &cfError{upstream: status, msg: fmt.Sprintf("cloudflare API error (status %d)", status)}
|
||||
}
|
||||
|
||||
// cfDo performs a Cloudflare API v4 call, JSON-encoding body, unwrapping the
|
||||
// {success, errors, result} envelope, and decoding result into out. It fails closed:
|
||||
// a transport error, an unsuccessful envelope, or a non-2xx status yields an error,
|
||||
// and the error NEVER contains the token. (The hanzodns cfDo shape, verbatim.)
|
||||
func (cl *client) cfDo(ctx context.Context, method, path string, body, out any) error {
|
||||
var reader io.Reader
|
||||
var contentType string
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(b)
|
||||
contentType = "application/json"
|
||||
}
|
||||
return cl.do(ctx, method, path, contentType, reader, out)
|
||||
}
|
||||
|
||||
// cfUpload performs a Cloudflare call with a pre-built body + content type (the
|
||||
// multipart Worker-script upload), sharing cfDo's fail-closed envelope handling.
|
||||
func (cl *client) cfUpload(ctx context.Context, method, path, contentType string, body []byte, out any) error {
|
||||
return cl.do(ctx, method, path, contentType, bytes.NewReader(body), out)
|
||||
}
|
||||
|
||||
// do is the shared request core for cfDo and cfUpload: Bearer-only auth, bounded
|
||||
// response read, envelope unwrap, fail-closed. Token appears ONLY in the header.
|
||||
func (cl *client) do(ctx context.Context, method, path, contentType string, body io.Reader, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, cl.base+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cl.token)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
resp, err := cfHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
// Never wrap err: a transport error can echo the request URL but never the
|
||||
// header. Keep the message token-free regardless.
|
||||
return fmt.Errorf("cloudflare request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
|
||||
var env cfEnvelope
|
||||
if len(data) > 0 {
|
||||
_ = json.Unmarshal(data, &env)
|
||||
}
|
||||
if err := env.err(resp.StatusCode); err != nil {
|
||||
return err
|
||||
}
|
||||
if out != nil {
|
||||
var wrap struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrap); err != nil {
|
||||
return fmt.Errorf("cloudflare: malformed response")
|
||||
}
|
||||
if len(wrap.Result) > 0 {
|
||||
if err := json.Unmarshal(wrap.Result, out); err != nil {
|
||||
return fmt.Errorf("cloudflare: malformed result")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pass runs a Cloudflare call and relays its result to the caller VERBATIM (as raw
|
||||
// JSON), so the upstream shape reaches the platform without field loss — the ONE
|
||||
// response path for every wired handler. An empty result (e.g. a 204 delete) becomes
|
||||
// {"success":true}.
|
||||
func (cl *client) pass(c *zip.Ctx, method, path string, body any) error {
|
||||
var out json.RawMessage
|
||||
if err := cl.cfDo(c.Context(), method, path, body, &out); err != nil {
|
||||
return cfErr(err)
|
||||
}
|
||||
return writeResult(c, out)
|
||||
}
|
||||
|
||||
func writeResult(c *zip.Ctx, out json.RawMessage) error {
|
||||
if len(out) == 0 {
|
||||
return c.JSON(http.StatusOK, map[string]any{"success": true})
|
||||
}
|
||||
c.SetHeader("Content-Type", "application/json; charset=utf-8")
|
||||
return c.Bytes(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ── request gate + token custody ────────────────────────────────────────────────
|
||||
|
||||
// actingOrgHeader is stamped on every SERVED /v1/integrations/cloudflare response with the org
|
||||
// whose Cloudflare token was actually used. A per-org caller (the platform BFF) MUST
|
||||
// assert it equals the org it requested: if a misdeployed, non-org-switch-capable
|
||||
// service credential made the identity boundary PIN X-Org-Id to the token's OWN
|
||||
// owner, this header exposes the mismatch so the caller fails LOUD instead of
|
||||
// silently reading/writing another tenant's Cloudflare account.
|
||||
const actingOrgHeader = "X-Hanzo-Org"
|
||||
|
||||
// authClient is the READ front door: it resolves the caller's validated org (403 if
|
||||
// unvalidated — a forged X-Org-Id with no bearer never gets past this) and builds a
|
||||
// Cloudflare client bound to THAT org's KMS-sealed token (503 if the org has not
|
||||
// connected Cloudflare or KMS is down). On success it stamps actingOrgHeader with the
|
||||
// served org. The token detail is logged token-free and never surfaced to the client.
|
||||
func authClient(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
|
||||
org, ok := principal.Org(c)
|
||||
if !ok {
|
||||
return nil, "", zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
tok, err := tokenFor(c.Context(), org, providerCloudflare, secretAPIToken)
|
||||
if err != nil || len(bytes.TrimSpace(tok)) == 0 {
|
||||
// err is custody-authored and token-free (not-connected / invalid-org /
|
||||
// KMS-down). Log the reason, tell the client only that CF is unavailable.
|
||||
s.Log.Warn("cloudflare token unavailable", "org", org, "err", err)
|
||||
return nil, org, zip.Errorf(http.StatusServiceUnavailable, "cloudflare is not connected for this org")
|
||||
}
|
||||
// Stamp the org actually served so a per-org caller can prove no tenant comingling.
|
||||
c.SetHeader(actingOrgHeader, org)
|
||||
return &client{token: string(bytes.TrimSpace(tok)), base: cfAPIBase()}, org, nil
|
||||
}
|
||||
|
||||
// authWrite is the MUTATION front door (POST/PUT/DELETE): it additionally requires the
|
||||
// caller be an admin of its OWN org (principal.IsOrgAdmin — NOT SuperAdmin), parity
|
||||
// with the AdminOnly connector that seals the token. Wielding the token's dangerous
|
||||
// verbs (a Worker script PUT is arbitrary code on the org's Cloudflare account/domains;
|
||||
// a Pages project DELETE is production destruction) must match connecting it. The admin
|
||||
// check is FIRST, so a non-admin is refused before any KMS token read. Reads stay
|
||||
// validated-org-only via authClient — org members may look, only org admins may change.
|
||||
func authWrite(s *cloud.Service[state], c *zip.Ctx) (*client, string, error) {
|
||||
if !principal.IsOrgAdmin(c) {
|
||||
return nil, "", zip.ErrForbidden("this action requires org admin")
|
||||
}
|
||||
return authClient(s, c)
|
||||
}
|
||||
|
||||
// resolveAccount resolves the Cloudflare account id for account-scoped endpoints
|
||||
// (Pages / Workers). Order: (1) an explicit, validated ?account= override wins (for an
|
||||
// org whose token spans multiple accounts); (2) the account captured at connect time
|
||||
// (the connection's ExternalID) — no per-call round-trip and deterministic for a
|
||||
// multi-account token; (3) only if none is stored, discover it live from the token's
|
||||
// own /accounts. Every candidate is validated 32-hex so it can never inject path
|
||||
// structure. Fails closed (400) when nothing yields a usable account.
|
||||
func (cl *client) resolveAccount(ctx context.Context, org string, c *zip.Ctx) (string, error) {
|
||||
if a := strings.TrimSpace(c.Query("account")); a != "" {
|
||||
if !idRE.MatchString(a) {
|
||||
return "", zip.ErrBadRequest("account must be a 32-character hex id")
|
||||
}
|
||||
return url.PathEscape(a), nil
|
||||
}
|
||||
if conn, ok := connectionFor(org, providerCloudflare); ok {
|
||||
if id := strings.TrimSpace(conn.ExternalID); idRE.MatchString(id) {
|
||||
return url.PathEscape(id), nil
|
||||
}
|
||||
}
|
||||
var accts []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := cl.cfDo(ctx, http.MethodGet, "/accounts?per_page=1", nil, &accts); err != nil {
|
||||
return "", cfErr(err)
|
||||
}
|
||||
for _, a := range accts {
|
||||
if idRE.MatchString(a.ID) {
|
||||
return url.PathEscape(a.ID), nil
|
||||
}
|
||||
}
|
||||
return "", zip.ErrBadRequest("no cloudflare account is resolvable for this token; pass ?account=<id>")
|
||||
}
|
||||
|
||||
// pathSeg reads a route param, rejects anything not matching re (so it can never
|
||||
// smuggle path structure into the upstream Cloudflare URL), and returns the
|
||||
// url.PathEscape'd value ready to concatenate into a CF path.
|
||||
func pathSeg(c *zip.Ctx, name string, re *regexp.Regexp) (string, error) {
|
||||
v := strings.TrimSpace(c.Param(name))
|
||||
if !re.MatchString(v) {
|
||||
return "", zip.ErrBadRequest(name + " is invalid")
|
||||
}
|
||||
return url.PathEscape(v), nil
|
||||
}
|
||||
|
||||
// cfErr maps a Cloudflare call failure to a client-facing HTTP error, propagating a
|
||||
// recognizable upstream status (404/400/403/409) so a proxied not-found is not
|
||||
// mis-reported as a 502, and defaulting to 502 Bad Gateway otherwise. The message is
|
||||
// Cloudflare's own (token-free by construction), never this process's token.
|
||||
func cfErr(err error) error {
|
||||
var ce *cfError
|
||||
if errors.As(err, &ce) {
|
||||
switch ce.upstream {
|
||||
case http.StatusNotFound, http.StatusBadRequest, http.StatusForbidden, http.StatusConflict:
|
||||
return zip.Errorf(ce.upstream, "%s", ce.msg)
|
||||
}
|
||||
}
|
||||
return zip.Errorf(http.StatusBadGateway, "%s", err.Error())
|
||||
}
|
||||
|
||||
// ── Phase-2 stub plumbing ───────────────────────────────────────────────────────
|
||||
|
||||
// errPhase2 marks a provider capability whose route + typed method exist but whose
|
||||
// body ships in Phase 2. stubResult maps it to an honest 501 — never a fake 200, so
|
||||
// a caller is never misled into thinking a no-op succeeded.
|
||||
var errPhase2 = errors.New("cloudflare: not yet implemented (phase 2)")
|
||||
|
||||
// stubResult maps a Phase-2 provider seam's result to the wire: errPhase2 → 501, any
|
||||
// real error surfaces as-is, and even a nil still yields 501 (a stub can never report
|
||||
// success). This guarantees a stub route NEVER returns a misleading 200.
|
||||
func stubResult(c *zip.Ctx, capability string, err error) error {
|
||||
if err != nil && !errors.Is(err, errPhase2) {
|
||||
return cfErr(err)
|
||||
}
|
||||
return zip.Errorf(http.StatusNotImplemented, "cloudflare %s is not yet wired (phase 2)", capability)
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/integrations"
|
||||
)
|
||||
|
||||
// testAccountID is a valid 32-hex Cloudflare account id the stub discovers.
|
||||
const testAccountID = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// capture records every request the fake Cloudflare API received, so a test can
|
||||
// assert WHICH token (Authorization) and WHICH path a handler used.
|
||||
type capture struct {
|
||||
mu sync.Mutex
|
||||
reqs []capturedReq
|
||||
}
|
||||
|
||||
type capturedReq struct {
|
||||
method, path, auth, ctype string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (c *capture) add(r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
c.mu.Lock()
|
||||
c.reqs = append(c.reqs, capturedReq{r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Content-Type"), body})
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// find returns the first captured request whose path CONTAINS sub.
|
||||
func (c *capture) find(sub string) (capturedReq, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, r := range c.reqs {
|
||||
if strings.Contains(r.path, sub) {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return capturedReq{}, false
|
||||
}
|
||||
|
||||
// hasExact reports whether any captured request hit EXACTLY path p — used to detect
|
||||
// the account-discovery call (GET /accounts), which "/accounts/{id}/..." contains as
|
||||
// a substring but is not.
|
||||
func (c *capture) hasExact(p string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, r := range c.reqs {
|
||||
if r.path == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fakeCF is a minimal Cloudflare API v4 stub: it answers account discovery and
|
||||
// echoes a success envelope for any account/zone-scoped call, recording every
|
||||
// request. resultFor lets a test control the result body per path substring.
|
||||
func fakeCF(rec *capture, resultFor func(path string) (int, string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rec.add(r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/accounts" { // discovery
|
||||
io.WriteString(w, `{"success":true,"errors":[],"result":[{"id":"`+testAccountID+`","name":"acme"}]}`)
|
||||
return
|
||||
}
|
||||
if resultFor != nil {
|
||||
if status, body := resultFor(r.URL.Path); body != "" {
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, body)
|
||||
return
|
||||
}
|
||||
}
|
||||
io.WriteString(w, `{"success":true,"errors":[],"result":{"ok":true}}`)
|
||||
}
|
||||
}
|
||||
|
||||
// harness mounts the subsystem against a fake CF and per-org token seam.
|
||||
func harness(t *testing.T, tokens map[string]string, rec *capture, resultFor func(string) (int, string)) *zip.App {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(fakeCF(rec, resultFor))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("CLOUDFLARE_API_BASE", srv.URL)
|
||||
|
||||
prev := tokenFor
|
||||
tokenFor = func(_ context.Context, org, provider, name string) ([]byte, error) {
|
||||
if provider != providerCloudflare || name != secretAPIToken {
|
||||
return nil, fmt.Errorf("seam called with unexpected coordinate %s/%s", provider, name)
|
||||
}
|
||||
tok, ok := tokens[org]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cloudflare not connected for org %q", org)
|
||||
}
|
||||
return []byte(tok), nil
|
||||
}
|
||||
t.Cleanup(func() { tokenFor = prev })
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// doReq drives one request with the given minted identity headers (as
|
||||
// SanitizeIdentity would set them): user!="" makes a validated principal; admin sets
|
||||
// X-User-IsOrgAdmin. Returns status + body + response headers.
|
||||
func doReq(t *testing.T, app *zip.App, method, path, user, org string, admin bool, body string) (int, string, http.Header) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if user != "" {
|
||||
req.Header.Set("X-User-Id", user)
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
if admin {
|
||||
req.Header.Set("X-User-IsOrgAdmin", "true")
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test(%s %s): %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b), resp.Header
|
||||
}
|
||||
|
||||
// do is the common non-admin read/driver: validated principal, no admin bit.
|
||||
func do(t *testing.T, app *zip.App, method, path, user, org, body string) (int, string) {
|
||||
status, b, _ := doReq(t, app, method, path, user, org, false, body)
|
||||
return status, b
|
||||
}
|
||||
|
||||
// ── tenant isolation (the crown jewel) ──────────────────────────────────────────
|
||||
|
||||
// A request with no validated principal (no X-User-Id) is refused 403 and NEVER
|
||||
// reaches token custody or Cloudflare — the forged-X-Org-Id path is dead.
|
||||
func TestForgedOrgWithoutPrincipalIs403(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"victim": "tok-victim"}, rec, nil)
|
||||
|
||||
status, body := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "", "victim", "")
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", status, body)
|
||||
}
|
||||
if len(rec.reqs) != 0 {
|
||||
t.Fatalf("Cloudflare was contacted %d time(s) for an unvalidated request; must be 0", len(rec.reqs))
|
||||
}
|
||||
if strings.Contains(body, "tok-victim") {
|
||||
t.Fatalf("victim token leaked into response: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Each org's request uses ONLY its own org's token; there is no input by which one
|
||||
// org's request can carry another org's token (org is derived solely from the
|
||||
// validated principal, never from a body/query field).
|
||||
func TestTokenScopedToCallerOrg(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A", "orgb": "tok-B"}, rec, nil)
|
||||
|
||||
if status, body := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", ""); status != 200 {
|
||||
t.Fatalf("orgA status=%d body=%s", status, body)
|
||||
}
|
||||
rA, ok := rec.find("/pages/projects")
|
||||
if !ok {
|
||||
t.Fatal("orgA: no /pages/projects request reached Cloudflare")
|
||||
}
|
||||
if rA.auth != "Bearer tok-A" {
|
||||
t.Fatalf("orgA used %q, want Bearer tok-A (cross-org token reach!)", rA.auth)
|
||||
}
|
||||
|
||||
rec.reqs = nil
|
||||
if status, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ub", "orgb", ""); status != 200 {
|
||||
t.Fatalf("orgB status=%d", status)
|
||||
}
|
||||
rB, ok := rec.find("/pages/projects")
|
||||
if !ok {
|
||||
t.Fatal("orgB: no /pages/projects request reached Cloudflare")
|
||||
}
|
||||
if rB.auth != "Bearer tok-B" {
|
||||
t.Fatalf("orgB used %q, want Bearer tok-B", rB.auth)
|
||||
}
|
||||
if rB.auth == "Bearer tok-A" {
|
||||
t.Fatal("orgB reached orgA's token — cross-tenant break")
|
||||
}
|
||||
}
|
||||
|
||||
// A body/query field naming another org is IGNORED: the token is the caller-org's,
|
||||
// proving the handler derives the org only from the validated principal. (Mutation →
|
||||
// org-admin driver.)
|
||||
func TestBodyOrgFieldCannotRedirectToken(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A", "orgb": "tok-B"}, rec, nil)
|
||||
|
||||
body := `{"name":"site","org":"orgb","organizationId":"orgb","account":"ffffffffffffffffffffffffffffffff"}`
|
||||
if status, resp, _ := doReq(t, app, http.MethodPost, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", true, body); status != 200 {
|
||||
t.Fatalf("status=%d resp=%s", status, resp)
|
||||
}
|
||||
r, ok := rec.find("/pages/projects")
|
||||
if !ok {
|
||||
t.Fatal("no create request reached Cloudflare")
|
||||
}
|
||||
if r.auth != "Bearer tok-A" {
|
||||
t.Fatalf("hostile body redirected the token to %q; must stay Bearer tok-A", r.auth)
|
||||
}
|
||||
}
|
||||
|
||||
// An org that has not connected Cloudflare fails closed with 503, never another
|
||||
// org's data and never a fake success.
|
||||
func TestNotConnectedIs503(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
|
||||
status, body := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ux", "stranger", "")
|
||||
if status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d, want 503; body=%s", status, body)
|
||||
}
|
||||
if len(rec.reqs) != 0 {
|
||||
t.Fatalf("Cloudflare contacted for an unconnected org; must be 0 (got %d)", len(rec.reqs))
|
||||
}
|
||||
}
|
||||
|
||||
// Every served response stamps X-Hanzo-Org with the org whose token was used, so a
|
||||
// per-org caller can detect a pinned/comingled org (fix for the non-SuperAdmin
|
||||
// platform-token misdeploy).
|
||||
func TestResponseStampsActingOrg(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
|
||||
status, _, hdr := doReq(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", false, "")
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if got := hdr.Get(actingOrgHeader); got != "orga" {
|
||||
t.Fatalf("%s = %q, want orga", actingOrgHeader, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── mutation authorization (least privilege) ────────────────────────────────────
|
||||
|
||||
// Mutations (POST/PUT/DELETE) require org admin; reads do not. A refused non-admin
|
||||
// mutation never reaches Cloudflare (rejected before the token read).
|
||||
func TestMutationRequiresOrgAdmin(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
|
||||
|
||||
mutations := []struct{ method, path, body string }{
|
||||
{http.MethodPost, "/v1/integrations/cloudflare/pages/projects", `{"name":"site"}`},
|
||||
{http.MethodDelete, "/v1/integrations/cloudflare/pages/projects/site", ""},
|
||||
{http.MethodPost, "/v1/integrations/cloudflare/pages/projects/site/deployments", ""},
|
||||
{http.MethodPut, "/v1/integrations/cloudflare/workers/scripts/hello", `{"script":"export default {}"}`},
|
||||
{http.MethodDelete, "/v1/integrations/cloudflare/workers/scripts/hello", ""},
|
||||
{http.MethodPost, "/v1/integrations/cloudflare/r2/buckets", `{"name":"b"}`},
|
||||
}
|
||||
for _, m := range mutations {
|
||||
if s, _, _ := doReq(t, app, m.method, m.path, "member", "orga", false, m.body); s != http.StatusForbidden {
|
||||
t.Fatalf("non-admin %s %s: status=%d, want 403", m.method, m.path, s)
|
||||
}
|
||||
}
|
||||
// A refused non-admin mutation must not have reached Cloudflare at all.
|
||||
if len(rec.reqs) != 0 {
|
||||
t.Fatalf("refused non-admin mutations reached Cloudflare %d time(s); must be 0", len(rec.reqs))
|
||||
}
|
||||
|
||||
// An org ADMIN is allowed through to Cloudflare.
|
||||
if s, b, _ := doReq(t, app, http.MethodPost, "/v1/integrations/cloudflare/pages/projects", "admin", "orga", true, `{"name":"site"}`); s != 200 {
|
||||
t.Fatalf("org-admin create: status=%d body=%s, want 200", s, b)
|
||||
}
|
||||
// A read stays open to a non-admin member.
|
||||
if s, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "member", "orga", ""); s != 200 {
|
||||
t.Fatalf("non-admin read: status=%d, want 200", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── wired behavior ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestPagesListHappyPath(t *testing.T) {
|
||||
rec := &capture{}
|
||||
resultFor := func(path string) (int, string) {
|
||||
if strings.HasSuffix(path, "/pages/projects") {
|
||||
return 200, `{"success":true,"errors":[],"result":[{"id":"p1","name":"marketing"}]}`
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, resultFor)
|
||||
status, body := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", "")
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
if !strings.Contains(body, `"name":"marketing"`) {
|
||||
t.Fatalf("result not relayed verbatim: %s", body)
|
||||
}
|
||||
r, _ := rec.find("/pages/projects")
|
||||
if r.path != "/accounts/"+testAccountID+"/pages/projects" {
|
||||
t.Fatalf("addressed %q, want the resolved-account path", r.path)
|
||||
}
|
||||
if strings.Contains(body, "tok-A") {
|
||||
t.Fatalf("token leaked into response body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Worker script PUT sends the modern multipart module upload with the caller-org
|
||||
// token (org-admin driver).
|
||||
func TestWorkersScriptPutMultipart(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
|
||||
body := `{"script":"export default { fetch(){ return new Response('hi') } }","mainModule":"worker.js"}`
|
||||
status, resp, _ := doReq(t, app, http.MethodPut, "/v1/integrations/cloudflare/workers/scripts/hello", "ua", "orga", true, body)
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d resp=%s", status, resp)
|
||||
}
|
||||
r, ok := rec.find("/workers/scripts/hello")
|
||||
if !ok {
|
||||
t.Fatal("no script PUT reached Cloudflare")
|
||||
}
|
||||
if !strings.HasPrefix(r.ctype, "multipart/form-data") {
|
||||
t.Fatalf("content-type = %q, want multipart/form-data", r.ctype)
|
||||
}
|
||||
if !strings.Contains(string(r.body), `"main_module":"worker.js"`) {
|
||||
t.Fatalf("multipart metadata missing main_module: %s", r.body)
|
||||
}
|
||||
if !strings.Contains(string(r.body), "export default") {
|
||||
t.Fatal("multipart body missing the module source")
|
||||
}
|
||||
if r.auth != "Bearer tok-A" {
|
||||
t.Fatalf("script PUT used %q, want Bearer tok-A", r.auth)
|
||||
}
|
||||
}
|
||||
|
||||
// Zone routes are zone-scoped and do NOT resolve an account (org-admin driver).
|
||||
func TestWorkersRouteBindZoneScoped(t *testing.T) {
|
||||
rec := &capture{}
|
||||
zone := "abcdef0123456789abcdef0123456789"
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
|
||||
body := `{"pattern":"example.com/*","script":"hello"}`
|
||||
status, resp, _ := doReq(t, app, http.MethodPost, "/v1/integrations/cloudflare/workers/zones/"+zone+"/routes", "ua", "orga", true, body)
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d resp=%s", status, resp)
|
||||
}
|
||||
if r, ok := rec.find("/zones/" + zone + "/workers/routes"); !ok {
|
||||
t.Fatalf("route bind did not address the zone path; got %+v", rec.reqs)
|
||||
} else if r.auth != "Bearer tok-A" {
|
||||
t.Fatalf("route bind used %q, want Bearer tok-A", r.auth)
|
||||
}
|
||||
if rec.hasExact("/accounts") {
|
||||
t.Fatal("route bind resolved an account; zone routes must not")
|
||||
}
|
||||
}
|
||||
|
||||
// ── stubs never lie ─────────────────────────────────────────────────────────────
|
||||
|
||||
// R2/KV/D1 stub READ routes answer an honest 501 for a CONNECTED org — never a fake
|
||||
// 200 — and still enforce the validated-principal gate.
|
||||
func TestStubRoutesReturn501NeverSuccess(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
|
||||
for _, path := range []string{
|
||||
"/v1/integrations/cloudflare/r2/buckets",
|
||||
"/v1/integrations/cloudflare/kv/namespaces",
|
||||
"/v1/integrations/cloudflare/d1/databases",
|
||||
} {
|
||||
status, body := do(t, app, http.MethodGet, path, "ua", "orga", "")
|
||||
if status != http.StatusNotImplemented {
|
||||
t.Fatalf("%s: status=%d, want 501; body=%s", path, status, body)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(body), `"success":true`) || strings.Contains(strings.ToLower(body), `"ok":true`) {
|
||||
t.Fatalf("%s: stub returned a misleading success: %s", path, body)
|
||||
}
|
||||
if s, _ := do(t, app, http.MethodGet, path, "", "orga", ""); s != http.StatusForbidden {
|
||||
t.Fatalf("%s: unvalidated status=%d, want 403", path, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── input hardening + account resolution ────────────────────────────────────────
|
||||
|
||||
// An explicit ?account= override must be a 32-hex id (defense against path
|
||||
// injection), and it skips discovery.
|
||||
func TestAccountOverrideValidated(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
|
||||
|
||||
if status, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects?account=../../evil", "ua", "orga", ""); status != http.StatusBadRequest {
|
||||
t.Fatalf("hostile account override status=%d, want 400", status)
|
||||
}
|
||||
rec.reqs = nil
|
||||
override := "ffffffffffffffffffffffffffffffff"
|
||||
if status, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects?account="+override, "ua", "orga", ""); status != 200 {
|
||||
t.Fatalf("valid account override status=%d", status)
|
||||
}
|
||||
r, _ := rec.find("/pages/projects")
|
||||
if r.path != "/accounts/"+override+"/pages/projects" {
|
||||
t.Fatalf("override not honored: addressed %q", r.path)
|
||||
}
|
||||
if rec.hasExact("/accounts") {
|
||||
t.Fatal("discovery call made despite an explicit account override")
|
||||
}
|
||||
}
|
||||
|
||||
// The account captured at connect time (ConnectionFor.ExternalID) is used without a
|
||||
// live discovery round-trip.
|
||||
func TestStoredAccountSkipsDiscovery(t *testing.T) {
|
||||
rec := &capture{}
|
||||
app := harness(t, map[string]string{"orga": "tok-A"}, rec, nil)
|
||||
stored := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
prev := connectionFor
|
||||
connectionFor = func(org, provider string) (integrations.Connection, bool) {
|
||||
if org == "orga" && provider == providerCloudflare {
|
||||
return integrations.Connection{ExternalID: stored}, true
|
||||
}
|
||||
return integrations.Connection{}, false
|
||||
}
|
||||
t.Cleanup(func() { connectionFor = prev })
|
||||
|
||||
if s, _ := do(t, app, http.MethodGet, "/v1/integrations/cloudflare/pages/projects", "ua", "orga", ""); s != 200 {
|
||||
t.Fatalf("status=%d", s)
|
||||
}
|
||||
r, ok := rec.find("/pages/projects")
|
||||
if !ok {
|
||||
t.Fatal("no pages request reached Cloudflare")
|
||||
}
|
||||
if r.path != "/accounts/"+stored+"/pages/projects" {
|
||||
t.Fatalf("addressed %q, want the stored-account path", r.path)
|
||||
}
|
||||
if rec.hasExact("/accounts") {
|
||||
t.Fatal("live discovery happened despite a stored account id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cloudflare
|
||||
|
||||
// d1.go — Cloudflare D1 (account-scoped: /accounts/{id}/d1/database). PHASE-2 STUB:
|
||||
// routes + typed provider methods exist so the surface is complete and typed; the
|
||||
// bodies ship in Phase 2, and each handler answers an honest 501 (via stubResult)
|
||||
// after the authClient gate — never a fake success.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// D1Database is the Phase-2 D1 database shape.
|
||||
type D1Database struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// listD1Databases / createD1Database / deleteD1Database are the typed Phase-2
|
||||
// provider seams; the wired bodies land in Phase 2.
|
||||
func (cl *client) listD1Databases(context.Context, string) ([]D1Database, error) {
|
||||
return nil, errPhase2
|
||||
}
|
||||
func (cl *client) createD1Database(context.Context, string, string) (*D1Database, error) {
|
||||
return nil, errPhase2
|
||||
}
|
||||
func (cl *client) deleteD1Database(context.Context, string, string) error {
|
||||
return errPhase2
|
||||
}
|
||||
|
||||
func d1DatabaseList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cl.listD1Databases(c.Context(), strings.TrimSpace(c.Query("account")))
|
||||
return stubResult(c, "d1", err)
|
||||
}
|
||||
|
||||
func d1DatabaseCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(c.Body(), &in)
|
||||
_, err = cl.createD1Database(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(in.Name))
|
||||
return stubResult(c, "d1", err)
|
||||
}
|
||||
|
||||
func d1DatabaseDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cl.deleteD1Database(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(c.Param("database")))
|
||||
return stubResult(c, "d1", err)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cloudflare
|
||||
|
||||
// kv.go — Cloudflare Workers KV (account-scoped: /accounts/{id}/storage/kv/
|
||||
// namespaces). PHASE-2 STUB: routes + typed provider methods exist so the surface is
|
||||
// complete and typed; the bodies ship in Phase 2, and each handler answers an honest
|
||||
// 501 (via stubResult) after the authClient gate — never a fake success.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// KVNamespace is the Phase-2 KV namespace shape.
|
||||
type KVNamespace struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// listKVNamespaces / createKVNamespace / deleteKVNamespace are the typed Phase-2
|
||||
// provider seams; the wired bodies land in Phase 2.
|
||||
func (cl *client) listKVNamespaces(context.Context, string) ([]KVNamespace, error) {
|
||||
return nil, errPhase2
|
||||
}
|
||||
func (cl *client) createKVNamespace(context.Context, string, string) (*KVNamespace, error) {
|
||||
return nil, errPhase2
|
||||
}
|
||||
func (cl *client) deleteKVNamespace(context.Context, string, string) error {
|
||||
return errPhase2
|
||||
}
|
||||
|
||||
func kvNamespaceList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cl.listKVNamespaces(c.Context(), strings.TrimSpace(c.Query("account")))
|
||||
return stubResult(c, "kv", err)
|
||||
}
|
||||
|
||||
func kvNamespaceCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
_ = json.Unmarshal(c.Body(), &in)
|
||||
_, err = cl.createKVNamespace(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(in.Title))
|
||||
return stubResult(c, "kv", err)
|
||||
}
|
||||
|
||||
func kvNamespaceDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cl.deleteKVNamespace(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(c.Param("namespace")))
|
||||
return stubResult(c, "kv", err)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package cloudflare
|
||||
|
||||
// pages.go — Cloudflare Pages, WIRED. Each handler resolves the caller-org token
|
||||
// (authClient for reads, authWrite for mutations) and the account id (resolveAccount),
|
||||
// then proxies to the CF Pages API under /accounts/{account_id}/pages/*. Request
|
||||
// bodies are decoded into the typed params ported from the platform's cloudflare.ts
|
||||
// (PagesProjectCreateParams et al.), so only modeled fields reach Cloudflare;
|
||||
// responses relay verbatim (no field loss).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ── ported request structs (mirror platform pkg/platform/src/services/cloudflare.ts) ──
|
||||
|
||||
// PagesKVBinding / PagesD1Binding / PagesR2Binding are the deployment-config resource
|
||||
// bindings (ported from PagesDeploymentConfig).
|
||||
type PagesKVBinding struct {
|
||||
NamespaceID string `json:"namespace_id"`
|
||||
}
|
||||
type PagesD1Binding struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
type PagesR2Binding struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// PagesEnvVar is one deployment env var (plain_text | secret_text).
|
||||
type PagesEnvVar struct {
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// PagesBuildConfig is the project build config.
|
||||
type PagesBuildConfig struct {
|
||||
BuildCommand string `json:"build_command,omitempty"`
|
||||
DestinationDir string `json:"destination_dir,omitempty"`
|
||||
RootDir string `json:"root_dir,omitempty"`
|
||||
}
|
||||
|
||||
// PagesDeploymentConfig is a preview/production deployment config.
|
||||
type PagesDeploymentConfig struct {
|
||||
CompatibilityDate string `json:"compatibility_date,omitempty"`
|
||||
CompatibilityFlags []string `json:"compatibility_flags,omitempty"`
|
||||
EnvVars map[string]PagesEnvVar `json:"env_vars,omitempty"`
|
||||
KVNamespaces map[string]PagesKVBinding `json:"kv_namespaces,omitempty"`
|
||||
D1Databases map[string]PagesD1Binding `json:"d1_databases,omitempty"`
|
||||
R2Buckets map[string]PagesR2Binding `json:"r2_buckets,omitempty"`
|
||||
}
|
||||
|
||||
// PagesDeploymentConfigs pairs the preview + production deployment configs.
|
||||
type PagesDeploymentConfigs struct {
|
||||
Preview *PagesDeploymentConfig `json:"preview,omitempty"`
|
||||
Production *PagesDeploymentConfig `json:"production,omitempty"`
|
||||
}
|
||||
|
||||
// PagesProjectCreate is the create-project request body (ported from
|
||||
// PagesProjectCreateParams). The platform sends {name, production_branch}; the full
|
||||
// shape is modeled so a richer caller is forwarded faithfully.
|
||||
type PagesProjectCreate struct {
|
||||
Name string `json:"name"`
|
||||
ProductionBranch string `json:"production_branch,omitempty"`
|
||||
BuildConfig *PagesBuildConfig `json:"build_config,omitempty"`
|
||||
DeploymentConfigs *PagesDeploymentConfigs `json:"deployment_configs,omitempty"`
|
||||
}
|
||||
|
||||
// ── handlers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func pagesList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/pages/projects", nil)
|
||||
}
|
||||
|
||||
func pagesGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/pages/projects/"+proj, nil)
|
||||
}
|
||||
|
||||
func pagesCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in PagesProjectCreate
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
if !nameRE.MatchString(strings.TrimSpace(in.Name)) {
|
||||
return zip.ErrBadRequest("project name is invalid")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects", in)
|
||||
}
|
||||
|
||||
func pagesDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/pages/projects/"+proj, nil)
|
||||
}
|
||||
|
||||
func pagesDeploy(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A Git-connected project builds from a branch (or its production branch when
|
||||
// none is given); an absent branch is an empty POST.
|
||||
var in struct {
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
_ = json.Unmarshal(c.Body(), &in)
|
||||
var body any
|
||||
if b := strings.TrimSpace(in.Branch); b != "" {
|
||||
body = map[string]string{"branch": b}
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects/"+proj+"/deployments", body)
|
||||
}
|
||||
|
||||
func pagesDomainAdd(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return zip.ErrBadRequest("domain name is required")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/pages/projects/"+proj+"/domains", map[string]string{"name": name})
|
||||
}
|
||||
|
||||
func pagesDomainDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proj, err := pathSeg(c, "project", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dom, err := pathSeg(c, "domain", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/pages/projects/"+proj+"/domains/"+dom, nil)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cloudflare
|
||||
|
||||
// r2.go — Cloudflare R2 (account-scoped: /accounts/{id}/r2/buckets). PHASE-2 STUB:
|
||||
// the routes and the typed provider methods exist so the surface is complete and
|
||||
// typed, but the bodies ship in Phase 2 — each handler answers an honest 501 (via
|
||||
// stubResult), never a fake success. Every handler still runs the authClient gate,
|
||||
// so an R2 route is no softer a target than a wired one.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// R2Bucket is the Phase-2 R2 bucket shape.
|
||||
type R2Bucket struct {
|
||||
Name string `json:"name"`
|
||||
CreationDate string `json:"creation_date,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
// listR2Buckets / createR2Bucket / deleteR2Bucket are the typed Phase-2 provider
|
||||
// seams; the wired bodies land in Phase 2.
|
||||
func (cl *client) listR2Buckets(context.Context, string) ([]R2Bucket, error) {
|
||||
return nil, errPhase2
|
||||
}
|
||||
func (cl *client) createR2Bucket(context.Context, string, string) (*R2Bucket, error) {
|
||||
return nil, errPhase2
|
||||
}
|
||||
func (cl *client) deleteR2Bucket(context.Context, string, string) error {
|
||||
return errPhase2
|
||||
}
|
||||
|
||||
func r2BucketList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cl.listR2Buckets(c.Context(), strings.TrimSpace(c.Query("account")))
|
||||
return stubResult(c, "r2", err)
|
||||
}
|
||||
|
||||
func r2BucketCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(c.Body(), &in)
|
||||
_, err = cl.createR2Bucket(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(in.Name))
|
||||
return stubResult(c, "r2", err)
|
||||
}
|
||||
|
||||
func r2BucketDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cl.deleteR2Bucket(c.Context(), strings.TrimSpace(c.Query("account")), strings.TrimSpace(c.Param("bucket")))
|
||||
return stubResult(c, "r2", err)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package cloudflare
|
||||
|
||||
// workers.go — Cloudflare Workers, WIRED: script put/list/delete, the account
|
||||
// workers.dev subdomain + per-script enable, and zone route bind/list/delete. Reads
|
||||
// use authClient (validated org); mutations use authWrite (validated org + org admin).
|
||||
// Script upload is the modern multipart module format (a metadata part + the module
|
||||
// part). Scripts + subdomain are account-scoped (/accounts/{id}/workers/*); routes are
|
||||
// zone-scoped (/zones/{zone_id}/workers/routes).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// WorkerScriptPut is the upload request for a Workers module script. Script is the
|
||||
// ES-module source; MainModule names the entry file (default "worker.js").
|
||||
// CompatibilityDate/Flags and Bindings ride the multipart metadata part.
|
||||
type WorkerScriptPut struct {
|
||||
Script string `json:"script"`
|
||||
MainModule string `json:"mainModule,omitempty"`
|
||||
CompatibilityDate string `json:"compatibilityDate,omitempty"`
|
||||
CompatibilityFlags []string `json:"compatibilityFlags,omitempty"`
|
||||
Bindings json.RawMessage `json:"bindings,omitempty"`
|
||||
}
|
||||
|
||||
// WorkerRouteCreate binds a Worker script to a URL pattern within a zone.
|
||||
type WorkerRouteCreate struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Script string `json:"script,omitempty"`
|
||||
}
|
||||
|
||||
// ── scripts ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func workersScriptList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/workers/scripts", nil)
|
||||
}
|
||||
|
||||
func workersScriptPut(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := pathSeg(c, "script", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in WorkerScriptPut
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
if strings.TrimSpace(in.Script) == "" {
|
||||
return zip.ErrBadRequest("script source is required")
|
||||
}
|
||||
body, contentType, err := buildWorkerUpload(in)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
var out json.RawMessage
|
||||
if err := cl.cfUpload(c.Context(), http.MethodPut, "/accounts/"+acct+"/workers/scripts/"+name, contentType, body, &out); err != nil {
|
||||
return cfErr(err)
|
||||
}
|
||||
return writeResult(c, out)
|
||||
}
|
||||
|
||||
func workersScriptDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := pathSeg(c, "script", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/accounts/"+acct+"/workers/scripts/"+name, nil)
|
||||
}
|
||||
|
||||
// buildWorkerUpload builds the Cloudflare multipart/form-data body for a module
|
||||
// Worker upload: a metadata JSON part (main_module + optional compatibility settings
|
||||
// and bindings) plus the module source part, whose form field name MUST equal
|
||||
// main_module so Cloudflare links them. Returns the body + its multipart content type.
|
||||
func buildWorkerUpload(in WorkerScriptPut) ([]byte, string, error) {
|
||||
main := strings.TrimSpace(in.MainModule)
|
||||
if main == "" {
|
||||
main = "worker.js"
|
||||
}
|
||||
if !nameRE.MatchString(main) {
|
||||
return nil, "", fmt.Errorf("mainModule is invalid")
|
||||
}
|
||||
meta := map[string]any{"main_module": main}
|
||||
if in.CompatibilityDate != "" {
|
||||
meta["compatibility_date"] = in.CompatibilityDate
|
||||
}
|
||||
if len(in.CompatibilityFlags) > 0 {
|
||||
meta["compatibility_flags"] = in.CompatibilityFlags
|
||||
}
|
||||
if len(in.Bindings) > 0 {
|
||||
meta["bindings"] = in.Bindings
|
||||
}
|
||||
metaJSON, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
|
||||
mh := make(textproto.MIMEHeader)
|
||||
mh.Set("Content-Disposition", `form-data; name="metadata"`)
|
||||
mh.Set("Content-Type", "application/json")
|
||||
mp, err := mw.CreatePart(mh)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := mp.Write(metaJSON); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
sh := make(textproto.MIMEHeader)
|
||||
sh.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, main, main))
|
||||
sh.Set("Content-Type", "application/javascript+module")
|
||||
sp, err := mw.CreatePart(sh)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := sp.Write([]byte(in.Script)); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return buf.Bytes(), mw.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
// ── workers.dev subdomain ───────────────────────────────────────────────────────
|
||||
|
||||
func workersSubdomainGet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/accounts/"+acct+"/workers/subdomain", nil)
|
||||
}
|
||||
|
||||
// workersScriptSubdomainSet enables/disables a script on the account workers.dev
|
||||
// subdomain (POST .../scripts/{script}/subdomain {enabled}).
|
||||
func workersScriptSubdomainSet(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, org, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := cl.resolveAccount(c.Context(), org, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := pathSeg(c, "script", nameRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/accounts/"+acct+"/workers/scripts/"+name+"/subdomain", map[string]bool{"enabled": in.Enabled})
|
||||
}
|
||||
|
||||
// ── zone routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func workersRouteList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authClient(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodGet, "/zones/"+zone+"/workers/routes", nil)
|
||||
}
|
||||
|
||||
func workersRouteCreate(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in WorkerRouteCreate
|
||||
if err := json.Unmarshal(c.Body(), &in); err != nil {
|
||||
return zip.ErrBadRequest("invalid request body")
|
||||
}
|
||||
pattern := strings.TrimSpace(in.Pattern)
|
||||
if pattern == "" {
|
||||
return zip.ErrBadRequest("route pattern is required")
|
||||
}
|
||||
body := map[string]string{"pattern": pattern}
|
||||
if sc := strings.TrimSpace(in.Script); sc != "" {
|
||||
body["script"] = sc
|
||||
}
|
||||
return cl.pass(c, http.MethodPost, "/zones/"+zone+"/workers/routes", body)
|
||||
}
|
||||
|
||||
func workersRouteDelete(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
cl, _, err := authWrite(s, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := pathSeg(c, "zone", idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
route, err := pathSeg(c, "route", idRE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cl.pass(c, http.MethodDelete, "/zones/"+zone+"/workers/routes/"+route, nil)
|
||||
}
|
||||
@@ -31,6 +31,11 @@ func NewDispatcher(
|
||||
CloneURL: cloneURL,
|
||||
VerifyRef: verifyRef,
|
||||
Log: log,
|
||||
// #48 route-work: enqueue a routed run on the ONE embedded tasks engine,
|
||||
// gated by the agents liveness check. Both bind to the real in-process
|
||||
// packages; a routed run with no live engine/target fails closed.
|
||||
Route: enqueueRoutedRun,
|
||||
TargetGate: agents.TargetDispatchable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +45,9 @@ type sessionAdapter struct{}
|
||||
func (sessionAdapter) Open(ctx context.Context, org, actor, agent, title string) (string, error) {
|
||||
return agents.OpenSession(ctx, org, actor, agent, title)
|
||||
}
|
||||
func (sessionAdapter) OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
|
||||
return agents.OpenSessionOn(ctx, org, actor, agent, title, target)
|
||||
}
|
||||
func (sessionAdapter) Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error {
|
||||
return agents.LogSessionEvent(ctx, org, sessionID, kind, actor, payload)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ const (
|
||||
// Sessions is the live agent-session registry seam (clients/agents in-process).
|
||||
type Sessions interface {
|
||||
Open(ctx context.Context, org, actor, agent, title string) (string, error)
|
||||
// OpenOn opens a session tagged with the run's dispatch TARGET, so a routed
|
||||
// run shows in mission-control on the machine it was sent to. An empty target
|
||||
// behaves exactly like Open.
|
||||
OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error)
|
||||
Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error
|
||||
Close(ctx context.Context, org, sessionID, status string) error
|
||||
}
|
||||
@@ -109,6 +113,11 @@ type RunResult struct {
|
||||
// Req is one coding request the trigger surface dispatches. Credential is the
|
||||
// per-org agent git secret the caller resolved from KMS fail-closed; it is
|
||||
// relayed to the sandbox and NEVER logged or placed in a session event.
|
||||
//
|
||||
// TargetID, when set, ROUTES the run to a registered machine (#48): the run is
|
||||
// enqueued as a durable task addressed to that target instead of executing in the
|
||||
// cloud-side sandbox, and the credential is NOT used (the machine authenticates
|
||||
// with its own). When empty, the local sandbox path runs unchanged.
|
||||
type Req struct {
|
||||
Org string
|
||||
UserID string // linked Hanzo subject — session attribution + X-User-Id
|
||||
@@ -120,6 +129,25 @@ type Req struct {
|
||||
CredUser string
|
||||
CredToken string
|
||||
TimeoutSeconds int
|
||||
TargetID string // when set, route to this registered machine instead of the sandbox
|
||||
}
|
||||
|
||||
// RoutedRun is the NON-SECRET spec coding hands the Route seam to enqueue on the
|
||||
// durable engine. It mirrors agents.RoutedRun so coding.go stays pure (no agents
|
||||
// import); the adapter bridges the two, exactly as PRInput/RunRequest mirror
|
||||
// their downstream types. It carries no credential by design — the executing
|
||||
// machine authenticates git + model routing with its own already-held creds.
|
||||
type RoutedRun struct {
|
||||
Org string
|
||||
TargetID string
|
||||
SessionID string
|
||||
Repo string
|
||||
Project string
|
||||
Base string
|
||||
Branch string
|
||||
Prompt string
|
||||
CloneURL string
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
// Result is the terminal outcome the trigger surface renders.
|
||||
@@ -135,6 +163,12 @@ type Result struct {
|
||||
PR PRRef
|
||||
LogTail string
|
||||
Error string
|
||||
// Routed reports that the run was ENQUEUED to a target machine rather than run
|
||||
// in the cloud sandbox. When true, OK means "accepted + queued" (not
|
||||
// "completed"): the terminal outcome flows through the session stream as the
|
||||
// machine executes. TargetID is the machine it was routed to.
|
||||
Routed bool
|
||||
TargetID string
|
||||
}
|
||||
|
||||
// Runner-facing behavioral defaults.
|
||||
@@ -155,6 +189,14 @@ type Dispatcher struct {
|
||||
// Log is an optional structured log seam for best-effort mirror failures; nil
|
||||
// is fine (mirror failures are non-fatal and simply dropped).
|
||||
Log func(msg string, kv ...any)
|
||||
// Route enqueues a routed run on the durable engine (the tasks-engine binding
|
||||
// in routed.go). Nil disables routing — a run with a TargetID then fails
|
||||
// closed rather than silently running in the sandbox.
|
||||
Route func(ctx context.Context, run RoutedRun) error
|
||||
// TargetGate is the fail-closed liveness+existence check for a routed run's
|
||||
// target (agents.TargetDispatchable): the target exists in this org, is
|
||||
// online, and has a live runner. Nil disables routing.
|
||||
TargetGate func(ctx context.Context, org, targetID string) error
|
||||
}
|
||||
|
||||
// Run executes one coding job end to end and returns its Result. It never
|
||||
@@ -179,6 +221,15 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
|
||||
if len(prompt) > maxPromptLen {
|
||||
prompt = prompt[:maxPromptLen]
|
||||
}
|
||||
|
||||
// ROUTED PATH (#48): a run with a chosen target is ENQUEUED as a durable task
|
||||
// addressed to that machine and executed THERE, never in the cloud sandbox. It
|
||||
// carries no credential (the machine uses its own), so it branches BEFORE the
|
||||
// credential gate; everything below — the local sandbox path — is unchanged.
|
||||
if strings.TrimSpace(req.TargetID) != "" {
|
||||
return d.routed(ctx, req, org, repo, prompt, res)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.CredToken) == "" {
|
||||
res.Error = "no agent credential for this org"
|
||||
return res
|
||||
@@ -296,6 +347,78 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
|
||||
return res
|
||||
}
|
||||
|
||||
// routed dispatches one run to a chosen target machine (#48). It opens the live
|
||||
// session tagged with the target (so mission-control shows it on that machine),
|
||||
// enqueues a DURABLE task addressed to the target on the tasks engine, and
|
||||
// returns immediately — the machine claims and executes it, streaming the
|
||||
// terminal outcome into the SAME session. It never runs locally: an unavailable
|
||||
// target, a disabled router, or a failed enqueue FAILS CLOSED with an honest
|
||||
// error.
|
||||
func (d Dispatcher) routed(ctx context.Context, req Req, org, repo, prompt string, res Result) Result {
|
||||
target := strings.TrimSpace(req.TargetID)
|
||||
res.Routed = true
|
||||
res.TargetID = target
|
||||
|
||||
// Routing must be wired (composition root binds both). A half-wired dispatcher
|
||||
// must not fall through to the sandbox with a target set.
|
||||
if d.Route == nil || d.TargetGate == nil {
|
||||
res.Error = "routing is not available"
|
||||
return res
|
||||
}
|
||||
|
||||
// The machine clones the org's repo with its OWN credential; we still need the
|
||||
// clone URL (non-secret) to hand it.
|
||||
cloneURL := ""
|
||||
if d.CloneURL != nil {
|
||||
cloneURL = d.CloneURL(org, repo)
|
||||
}
|
||||
if cloneURL == "" {
|
||||
res.Error = "git is not available"
|
||||
return res
|
||||
}
|
||||
|
||||
// Liveness + existence gate: only dispatch to a target that exists in this
|
||||
// org, is online, and has a live runner. Fail closed — never elsewhere.
|
||||
if err := d.TargetGate(ctx, org, target); err != nil {
|
||||
res.Error = "target " + target + " is not available: " + err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
agentRef := strings.TrimSpace(req.AgentRef)
|
||||
if agentRef == "" {
|
||||
agentRef = "hanzo"
|
||||
}
|
||||
actor := strings.TrimSpace(req.UserID)
|
||||
|
||||
sessionID, err := d.Sessions.OpenOn(ctx, org, actor, agentRef, codingTitle(repo, prompt), target)
|
||||
if err != nil {
|
||||
res.Error = "could not start a session: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.SessionID = sessionID
|
||||
branch := "agent/" + shortID(sessionID)
|
||||
res.Branch = branch
|
||||
|
||||
d.mirror(ctx, org, sessionID, actor, kindStatus, map[string]any{
|
||||
"status": "routed", "repo": repo, "branch": branch, "base": baseOr(req.Base), "target": target,
|
||||
})
|
||||
|
||||
run := RoutedRun{
|
||||
Org: org, TargetID: target, SessionID: sessionID,
|
||||
Repo: repo, Project: strings.TrimSpace(req.Project), Base: strings.TrimSpace(req.Base),
|
||||
Branch: branch, Prompt: prompt, CloneURL: cloneURL, TimeoutSeconds: timeoutOr(req.TimeoutSeconds),
|
||||
}
|
||||
// Enqueue on the durable engine. A failure fails the run closed (session
|
||||
// error) rather than leaving a zombie "running" session or running locally.
|
||||
if err := d.Route(ctx, run); err != nil {
|
||||
term := context.WithoutCancel(ctx)
|
||||
return d.fail(term, org, sessionID, actor, res, "could not queue the routed run: "+err.Error(), "")
|
||||
}
|
||||
|
||||
res.OK = true // accepted + queued; the machine drives it to terminal from here
|
||||
return res
|
||||
}
|
||||
|
||||
// fail records the error into the session, closes it error, and stamps the Result.
|
||||
func (d Dispatcher) fail(ctx context.Context, org, sessionID, actor string, res Result, msg, logTail string) Result {
|
||||
d.mirror(ctx, org, sessionID, actor, kindStatus, map[string]any{"status": "error", "error": msg})
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// ---- fakes recording every seam call for isolation + contract assertions ----
|
||||
|
||||
type openCall struct{ org, actor, agent, title string }
|
||||
type openCall struct{ org, actor, agent, title, target string }
|
||||
type eventCall struct {
|
||||
org, session, kind, actor string
|
||||
payload string
|
||||
@@ -28,7 +28,16 @@ type fakeSessions struct {
|
||||
func (f *fakeSessions) Open(_ context.Context, org, actor, agent, title string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.opened = append(f.opened, openCall{org, actor, agent, title})
|
||||
f.opened = append(f.opened, openCall{org, actor, agent, title, ""})
|
||||
if f.openErr != nil {
|
||||
return "", f.openErr
|
||||
}
|
||||
return f.id, nil
|
||||
}
|
||||
func (f *fakeSessions) OpenOn(_ context.Context, org, actor, agent, title, target string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.opened = append(f.opened, openCall{org, actor, agent, title, target})
|
||||
if f.openErr != nil {
|
||||
return "", f.openErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/agents"
|
||||
tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
|
||||
"github.com/hanzoai/tasks/pkg/sdk/temporal"
|
||||
tasksworker "github.com/hanzoai/tasks/pkg/sdk/worker"
|
||||
"github.com/hanzoai/tasks/pkg/sdk/workflow"
|
||||
)
|
||||
|
||||
// routed.go is coding's durable substrate for #48 route-work: it enqueues one
|
||||
// routed run as a workflow on the ONE embedded hanzoai/tasks engine
|
||||
// (cloud.EmbeddedTasks) — the SAME queue index-on-push and automations use, never
|
||||
// a second async system — and owns that run until an external machine reports it.
|
||||
//
|
||||
// ONE ENGINE, ONE IDIOM. Like index_on_push: dial the loopback engine once,
|
||||
// register a worker with the workflow + its single activity, and ExecuteWorkflow
|
||||
// keyed by the session id (idempotent — a re-dispatch of the same run is a no-op).
|
||||
//
|
||||
// DURABILITY VIA A BLOCKING DELIVERY ACTIVITY. RoutedRunWorkflow runs exactly one
|
||||
// activity, DeliverRoutedRunActivity, which OFFERS the run to the live mailbox
|
||||
// (clients/agents) and BLOCKS until the machine reports a result. The activity —
|
||||
// not the workflow — is what re-runs on a worker/cloud restart, so on recovery it
|
||||
// re-offers the run to the (now-empty) mailbox and the long-polling machine
|
||||
// re-claims it: durability the mailbox alone could not give. A run never claimed
|
||||
// (or a machine that dies mid-flight) trips the activity's StartToClose budget →
|
||||
// the engine retries → after the attempts are spent the workflow fails, which is
|
||||
// fail-closed by construction (the run never silently succeeds or runs elsewhere).
|
||||
|
||||
const (
|
||||
// routedQueue is this workflow family's own task queue (<service>-<purpose>).
|
||||
routedQueue = "agent-routed"
|
||||
|
||||
// claimDeadline is how long a queued run waits for a machine to CLAIM it before
|
||||
// the delivery activity's budget starts being dominated by the run itself. It
|
||||
// is folded into StartToClose; a run unclaimed for the whole budget fails closed.
|
||||
claimDeadline = 2 * time.Minute
|
||||
|
||||
// routedMaxConcurrent sizes the routed worker's activity pool. Each in-flight
|
||||
// delivery blocks one slot for the run's lifetime, so this caps concurrent
|
||||
// routed runs this cloud replica shepherds.
|
||||
routedMaxConcurrent = 256
|
||||
|
||||
// routedHeartbeat lets the engine detect a dead worker: while this cloud is
|
||||
// alive the worker auto-heartbeats at half this interval, so a crash stops the
|
||||
// heartbeats and the engine re-dispatches the delivery to a live worker.
|
||||
routedHeartbeat = 30 * time.Second
|
||||
)
|
||||
|
||||
var errEngineNotReady = errors.New("coding: tasks engine not ready")
|
||||
|
||||
// routedStartToClose is the delivery activity's total budget: the claim window
|
||||
// plus the run's own timeout. Exceeding it means no machine claimed, or one
|
||||
// claimed and never reported — either way the run is re-queued then failed closed.
|
||||
func routedStartToClose(timeoutSeconds int) time.Duration {
|
||||
return claimDeadline + time.Duration(timeoutOr(timeoutSeconds))*time.Second
|
||||
}
|
||||
|
||||
// RoutedRunWorkflow is the durable owner of one routed run. Exported for worker
|
||||
// registration; not called directly.
|
||||
func RoutedRunWorkflow(ctx workflow.Context, in agents.RoutedRun) (agents.RoutedResult, error) {
|
||||
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
|
||||
StartToCloseTimeout: routedStartToClose(in.TimeoutSeconds),
|
||||
HeartbeatTimeout: routedHeartbeat,
|
||||
RetryPolicy: &temporal.RetryPolicy{
|
||||
InitialInterval: 2 * time.Second,
|
||||
BackoffCoefficient: 2.0,
|
||||
MaximumInterval: 30 * time.Second,
|
||||
MaximumAttempts: 3,
|
||||
},
|
||||
})
|
||||
var res agents.RoutedResult
|
||||
err := workflow.ExecuteActivity(actCtx, DeliverRoutedRunActivity, in).Get(actCtx, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// DeliverRoutedRunActivity offers the run to the live mailbox and blocks until the
|
||||
// machine reports a terminal result or the budget elapses. It derives an internal
|
||||
// deadline from the same budget so the goroutine can never outlive the activity
|
||||
// even if the engine does not cancel the passed ctx exactly at StartToClose.
|
||||
// Exported for worker registration; not called directly.
|
||||
func DeliverRoutedRunActivity(ctx context.Context, in agents.RoutedRun) (agents.RoutedResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, routedStartToClose(in.TimeoutSeconds))
|
||||
defer cancel()
|
||||
off := agents.OfferRoutedRun(in)
|
||||
defer off.Close()
|
||||
res, ok := off.Await(ctx)
|
||||
if !ok {
|
||||
return agents.RoutedResult{}, fmt.Errorf("routed run %s was not completed before its deadline", in.SessionID)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var (
|
||||
routedClientMu sync.Mutex
|
||||
routedClient tasksclient.Client
|
||||
)
|
||||
|
||||
// routedEngineClient lazily dials the ONE embedded engine and registers the
|
||||
// routed worker once (process-lifetime), memoizing the client. Dialed on the
|
||||
// always-registered `default` namespace — isolation rides in the workflow input
|
||||
// (agents.RoutedRun.Org) exactly as index-on-push does. Nil engine (before
|
||||
// wireDurableIngest, or an embed failure) => errEngineNotReady, which the
|
||||
// dispatcher renders as an honest failure (a routed run NEVER falls back to
|
||||
// local execution).
|
||||
func routedEngineClient() (tasksclient.Client, error) {
|
||||
routedClientMu.Lock()
|
||||
defer routedClientMu.Unlock()
|
||||
if routedClient != nil {
|
||||
return routedClient, nil
|
||||
}
|
||||
eng := cloud.EmbeddedTasks()
|
||||
if eng == nil {
|
||||
return nil, errEngineNotReady
|
||||
}
|
||||
cli, err := tasksclient.Dial(tasksclient.Options{
|
||||
HostPort: fmt.Sprintf("127.0.0.1:%d", eng.ZAPPort()),
|
||||
Namespace: "default",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("routed dial engine: %w", err)
|
||||
}
|
||||
w := tasksworker.New(cli, routedQueue, tasksworker.Options{
|
||||
MaxConcurrentActivityExecutionSize: routedMaxConcurrent,
|
||||
})
|
||||
w.RegisterWorkflow(RoutedRunWorkflow)
|
||||
w.RegisterActivity(DeliverRoutedRunActivity)
|
||||
if err := w.Start(); err != nil {
|
||||
cli.Close()
|
||||
return nil, fmt.Errorf("routed worker start: %w", err)
|
||||
}
|
||||
routedClient = cli
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
// enqueueRoutedRun is the Route seam's production binding: it starts the durable
|
||||
// RoutedRunWorkflow keyed by the session id (idempotent) and returns immediately.
|
||||
// No local execution, no new queue.
|
||||
func enqueueRoutedRun(ctx context.Context, run RoutedRun) error {
|
||||
cli, err := routedEngineClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := agents.RoutedRun{
|
||||
Org: run.Org, TargetID: run.TargetID, SessionID: run.SessionID,
|
||||
Repo: run.Repo, Project: run.Project, Base: run.Base, Branch: run.Branch,
|
||||
Prompt: run.Prompt, CloneURL: run.CloneURL, TimeoutSeconds: run.TimeoutSeconds,
|
||||
}
|
||||
_, err = cli.ExecuteWorkflow(ctx, tasksclient.StartWorkflowOptions{
|
||||
ID: run.SessionID,
|
||||
TaskQueue: routedQueue,
|
||||
}, RoutedRunWorkflow, in)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeRouter records every routed run handed to the Route seam.
|
||||
type fakeRouter struct {
|
||||
mu sync.Mutex
|
||||
runs []RoutedRun
|
||||
err error
|
||||
called int
|
||||
}
|
||||
|
||||
func (f *fakeRouter) route(_ context.Context, run RoutedRun) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.called++
|
||||
f.runs = append(f.runs, run)
|
||||
return f.err
|
||||
}
|
||||
|
||||
// fakeGate records the (org,target) it was asked to admit and returns a verdict.
|
||||
type fakeGate struct {
|
||||
mu sync.Mutex
|
||||
seen [][2]string
|
||||
err error
|
||||
called int
|
||||
}
|
||||
|
||||
func (g *fakeGate) gate(_ context.Context, org, target string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.called++
|
||||
g.seen = append(g.seen, [2]string{org, target})
|
||||
return g.err
|
||||
}
|
||||
|
||||
// routedDispatcher builds a Dispatcher wired for routing. The Runner is present
|
||||
// but MUST NOT be called on a routed run.
|
||||
func routedDispatcher(sess *fakeSessions, run *fakeRunner, router *fakeRouter, gate *fakeGate) (Dispatcher, *[]string) {
|
||||
var cloneCalls []string
|
||||
d := Dispatcher{
|
||||
Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
|
||||
CloneURL: func(org, repo string) string {
|
||||
cloneCalls = append(cloneCalls, org+"/"+repo)
|
||||
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
|
||||
},
|
||||
Route: router.route,
|
||||
TargetGate: gate.gate,
|
||||
}
|
||||
return d, &cloneCalls
|
||||
}
|
||||
|
||||
func routedReq() Req {
|
||||
// Deliberately NO credential — a routed run must not need one.
|
||||
return Req{Org: "acme", UserID: "u-1", AgentRef: "hanzo", Repo: "api", Prompt: "add a test", TargetID: "tgt_evo"}
|
||||
}
|
||||
|
||||
// A routed run is ENQUEUED to the target and NEVER executed in the sandbox.
|
||||
func TestRun_RoutedToTarget_EnqueuesNotLocal(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_route1abc"}
|
||||
run := &fakeRunner{}
|
||||
router := &fakeRouter{}
|
||||
gate := &fakeGate{}
|
||||
d, cloneCalls := routedDispatcher(sess, run, router, gate)
|
||||
|
||||
res := d.Run(context.Background(), routedReq())
|
||||
|
||||
if !res.Routed || !res.OK || res.TargetID != "tgt_evo" {
|
||||
t.Fatalf("want routed+accepted to tgt_evo, got %+v", res)
|
||||
}
|
||||
// The local sandbox runner was NEVER invoked.
|
||||
if run.gotOrg != "" || run.gotReq.CloneURL != "" {
|
||||
t.Fatalf("the local runner must not run for a routed dispatch: %+v", run.gotReq)
|
||||
}
|
||||
// The gate was consulted for exactly this (org,target).
|
||||
if gate.called != 1 || gate.seen[0] != [2]string{"acme", "tgt_evo"} {
|
||||
t.Fatalf("liveness gate wrong: %+v", gate.seen)
|
||||
}
|
||||
// One durable enqueue, addressed to the target, org-scoped clone url, NO secret.
|
||||
if router.called != 1 {
|
||||
t.Fatalf("want exactly one enqueue, got %d", router.called)
|
||||
}
|
||||
rr := router.runs[0]
|
||||
if rr.Org != "acme" || rr.TargetID != "tgt_evo" || rr.SessionID != "sess_route1abc" {
|
||||
t.Fatalf("routed run mis-addressed: %+v", rr)
|
||||
}
|
||||
if rr.CloneURL != "https://git.test/v1/git/acme/api.git" {
|
||||
t.Fatalf("clone url must be org-scoped: %q", rr.CloneURL)
|
||||
}
|
||||
if rr.Branch != "agent/route1abc" {
|
||||
t.Fatalf("branch derived from session id: %q", rr.Branch)
|
||||
}
|
||||
// The session was opened ON the target so mission-control shows it there.
|
||||
if len(sess.opened) != 1 || sess.opened[0].target != "tgt_evo" {
|
||||
t.Fatalf("session must be opened on the target: %+v", sess.opened)
|
||||
}
|
||||
if len(*cloneCalls) != 1 || (*cloneCalls)[0] != "acme/api" {
|
||||
t.Fatalf("clone must target acme/api only: %v", *cloneCalls)
|
||||
}
|
||||
// A routed session stays live (the machine drives it to terminal) — not closed here.
|
||||
if len(sess.closes) != 0 {
|
||||
t.Fatalf("a queued routed run must not be closed at dispatch: %+v", sess.closes)
|
||||
}
|
||||
}
|
||||
|
||||
// The DEFAULT (no target) path is entirely unchanged: the local runner executes,
|
||||
// the router is never touched.
|
||||
func TestRun_NoTarget_LocalPathUnchanged(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_local1"}
|
||||
run := &fakeRunner{result: RunResult{Branch: "agent/local1", Changed: true, OK: true}}
|
||||
router := &fakeRouter{}
|
||||
gate := &fakeGate{}
|
||||
d, _ := routedDispatcher(sess, run, router, gate)
|
||||
// A local run DOES need a credential.
|
||||
req := Req{Org: "acme", UserID: "u-1", AgentRef: "hanzo", Repo: "api", Prompt: "fix it", CredToken: "hk-secret"}
|
||||
|
||||
res := d.Run(context.Background(), req)
|
||||
|
||||
if res.Routed {
|
||||
t.Fatalf("a run with no target must NOT be routed: %+v", res)
|
||||
}
|
||||
if !res.OK || run.gotOrg != "acme" {
|
||||
t.Fatalf("the local runner must execute the default path: %+v", res)
|
||||
}
|
||||
if router.called != 0 || gate.called != 0 {
|
||||
t.Fatalf("routing seams must be untouched on the local path: route=%d gate=%d", router.called, gate.called)
|
||||
}
|
||||
// The session was opened WITHOUT a target (local open path).
|
||||
if len(sess.opened) != 1 || sess.opened[0].target != "" {
|
||||
t.Fatalf("local session must open with no target: %+v", sess.opened)
|
||||
}
|
||||
}
|
||||
|
||||
// A dead / stale / unknown target fails closed at dispatch — nothing is enqueued,
|
||||
// the runner is never called, and no session is opened.
|
||||
func TestRun_RoutedDeadTarget_FailsClosed(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_x"}
|
||||
run := &fakeRunner{}
|
||||
router := &fakeRouter{}
|
||||
gate := &fakeGate{err: errors.New("target has no live runner")}
|
||||
d, _ := routedDispatcher(sess, run, router, gate)
|
||||
|
||||
res := d.Run(context.Background(), routedReq())
|
||||
|
||||
if res.OK || !strings.Contains(res.Error, "not available") {
|
||||
t.Fatalf("a dead target must fail closed, got %+v", res)
|
||||
}
|
||||
if router.called != 0 {
|
||||
t.Fatal("nothing may be enqueued to a dead target")
|
||||
}
|
||||
if run.gotOrg != "" {
|
||||
t.Fatal("the local runner must NEVER run as a fallback for a dead target")
|
||||
}
|
||||
if len(sess.opened) != 0 {
|
||||
t.Fatalf("no session should open for an unavailable target: %+v", sess.opened)
|
||||
}
|
||||
}
|
||||
|
||||
// If the durable enqueue fails, the run fails closed and the (already-open)
|
||||
// session is closed ERROR — never left a zombie and never run locally.
|
||||
func TestRun_RoutedEnqueueFails_FailsClosed_SessionErrored(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_enq"}
|
||||
run := &fakeRunner{}
|
||||
router := &fakeRouter{err: errors.New("engine not ready")}
|
||||
gate := &fakeGate{}
|
||||
d, _ := routedDispatcher(sess, run, router, gate)
|
||||
|
||||
res := d.Run(context.Background(), routedReq())
|
||||
|
||||
if res.OK || !strings.Contains(res.Error, "queue the routed run") {
|
||||
t.Fatalf("a failed enqueue must fail closed, got %+v", res)
|
||||
}
|
||||
if run.gotOrg != "" {
|
||||
t.Fatal("a failed enqueue must NOT fall back to local execution")
|
||||
}
|
||||
if len(sess.closes) != 1 || sess.closes[0].status != statusError {
|
||||
t.Fatalf("the session must be closed error, got %+v", sess.closes)
|
||||
}
|
||||
}
|
||||
|
||||
// A routed run needs NO credential (branches before the credential gate) and the
|
||||
// routed run carries no secret field at all.
|
||||
func TestRun_Routed_NeedsNoCredential_CarriesNoSecret(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_nocred"}
|
||||
router := &fakeRouter{}
|
||||
gate := &fakeGate{}
|
||||
d, _ := routedDispatcher(sess, &fakeRunner{}, router, gate)
|
||||
|
||||
req := routedReq()
|
||||
req.CredToken = "" // explicitly none
|
||||
res := d.Run(context.Background(), req)
|
||||
|
||||
if !res.OK || !res.Routed {
|
||||
t.Fatalf("routed run must succeed without a credential, got %+v", res)
|
||||
}
|
||||
// No event payload carries anything credential-shaped; the RoutedRun struct has
|
||||
// no credential field, so this is structural — assert the enqueued run + events.
|
||||
rr := router.runs[0]
|
||||
if rr.Prompt != "add a test" {
|
||||
t.Fatalf("routed run prompt wrong: %+v", rr)
|
||||
}
|
||||
for _, e := range sess.events {
|
||||
if e.org != "acme" || e.session != "sess_nocred" {
|
||||
t.Fatalf("routed event escaped tenant/session scope: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Routing that is not wired must fail closed — never fall through to the sandbox
|
||||
// with a target set.
|
||||
func TestRun_RoutedButRoutingUnwired_FailsClosed(t *testing.T) {
|
||||
sess := &fakeSessions{id: "sess_x"}
|
||||
run := &fakeRunner{}
|
||||
// No Route / TargetGate seams.
|
||||
d := Dispatcher{Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
|
||||
CloneURL: func(org, repo string) string { return "https://git.test/v1/git/" + org + "/" + repo + ".git" }}
|
||||
|
||||
res := d.Run(context.Background(), routedReq())
|
||||
if res.OK || !strings.Contains(res.Error, "routing is not available") {
|
||||
t.Fatalf("unwired routing must fail closed, got %+v", res)
|
||||
}
|
||||
if run.gotOrg != "" {
|
||||
t.Fatal("must not run locally when routing is unwired but a target was chosen")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Hanzo Dataroom — goja bundle (read-WRITE, on clients/gojabase).
|
||||
// Hanzo Dataroom — goja bundle (read-WRITE, on clients/goja).
|
||||
//
|
||||
// SELF-CONTAINED, NO ESM, NO node: imports. The complete dataroom business
|
||||
// logic (documents, data rooms, shareable links with access controls, viewers,
|
||||
@@ -8,7 +8,7 @@
|
||||
// data model becomes Base/SQLite tables (see the leaf's schema.go), the handlers
|
||||
// become the route table below. No Postgres, no Next.js.
|
||||
//
|
||||
// Host contract (clients/gojabase injects these per dispatch; each dispatch runs
|
||||
// Host contract (clients/goja injects these per dispatch; each dispatch runs
|
||||
// inside ONE per-tenant SQLite transaction that commits iff status < 400):
|
||||
// globalThis.__db.query(sql, args) -> [ {col: val, ...}, ... ]
|
||||
// globalThis.__db.exec(sql, args) -> { changes, lastId }
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
// err builds a route result carrying a non-200 status via __status. A >=400
|
||||
// status also rolls back the dispatch transaction (gojabase), so a rejected
|
||||
// status also rolls back the dispatch transaction (NewBase), so a rejected
|
||||
// request leaves the tenant DB untouched.
|
||||
function err(status, message) { return { __status: status, error: message }; }
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
// === route handlers ========================================================
|
||||
// Admin routes are org-scoped by the per-tenant DB gojabase selects; the Go
|
||||
// Admin routes are org-scoped by the per-tenant DB NewBase selects; the Go
|
||||
// leaf refuses any request without a validated principal before dispatching.
|
||||
// Viewer routes run under the org resolved from the public link id.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// business logic (documents, data rooms, shareable links with access controls,
|
||||
// viewers, per-page view analytics) is a self-contained goja bundle (bundle.js, the
|
||||
// ESM-free port of the Papermark API handlers). It runs in-process on the REUSABLE
|
||||
// clients/gojabase host — the SAME RW-Base binding captable (#97) pilots and esign
|
||||
// clients/goja host — the SAME RW-Base binding captable (#97) pilots and esign
|
||||
// (#100) reuses — which injects __db/__newId/__now and one SQLite file per tenant,
|
||||
// one transaction per request. This leaf adds only: the per-tenant Schema, the
|
||||
// object-storage seam for document bytes, a bcrypt HostFn for link passwords, and
|
||||
@@ -16,7 +16,7 @@
|
||||
//
|
||||
// dataroom bundle (bundle.js, go:embed) + per-tenant Schema + __bcrypt HostFn
|
||||
// │
|
||||
// clients/gojabase.New(...) ← __db/__newId/__now, per-tenant Base,
|
||||
// clients/goja.NewBase(...) ← __db/__newId/__now, per-tenant Base,
|
||||
// │ one transaction per request
|
||||
// /v1/dataroom/* zip routes
|
||||
//
|
||||
@@ -29,7 +29,7 @@
|
||||
// AUTH. Admin routes require a validated cloud principal (principal.Org → org);
|
||||
// public viewer routes carry no principal and resolve their org from the link index
|
||||
// (a link id → org routing table — the one cross-tenant piece). Tenant isolation is
|
||||
// the per-org SQLite file gojabase selects from that org.
|
||||
// the per-org SQLite file NewBase selects from that org.
|
||||
//
|
||||
// ACTIVATION: dataroom is NOT staged — it mounts under the mount-all default
|
||||
// (empty CLOUD_ENABLE), so the one binary serves /v1/dataroom/* from first boot.
|
||||
@@ -51,7 +51,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
hcloud "github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/gojabase"
|
||||
"github.com/hanzoai/cloud/clients/goja"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
@@ -75,7 +75,7 @@ type blobStore interface {
|
||||
// state is dataroom's own data; shared deps live in the embedded cloud.Base,
|
||||
// reached as s.Log.
|
||||
type state struct {
|
||||
host *gojabase.Host
|
||||
host *goja.BaseHost
|
||||
index *linkIndex
|
||||
blob blobStore
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func Mount(app *zip.App, deps hcloud.Deps) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"service": "dataroom", "status": "ok"})
|
||||
})
|
||||
|
||||
host, err := gojabase.New(gojabase.Config{
|
||||
host, err := goja.NewBase(goja.BaseConfig{
|
||||
Name: "dataroom",
|
||||
Bundle: bundleJS,
|
||||
Schema: schema,
|
||||
@@ -233,7 +233,7 @@ func uploadDocument(s *hcloud.Service[state], c *zip.Ctx) error {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
// ONE tenant encoding everywhere: the object-store key prefix uses the SAME
|
||||
// injective, path-safe gojabase.TenantSegment the per-tenant SQLite filename
|
||||
// injective, path-safe goja.TenantSegment the per-tenant SQLite filename
|
||||
// does — never the raw org (which could carry a '/' and traverse the key
|
||||
// namespace, and would drift from the DB's encoding).
|
||||
rk, err := randKey()
|
||||
@@ -241,7 +241,7 @@ func uploadDocument(s *hcloud.Service[state], c *zip.Ctx) error {
|
||||
s.Log.Error("dataroom: crypto/rand unavailable", "err", err)
|
||||
return zip.Errorf(http.StatusInternalServerError, "storage key generation failed")
|
||||
}
|
||||
key := "dataroom/" + gojabase.TenantSegment(org) + "/" + rk
|
||||
key := "dataroom/" + goja.TenantSegment(org) + "/" + rk
|
||||
if err := s.State.blob.Put(c.Context(), key, raw); err != nil {
|
||||
s.Log.Error("dataroom storage put failed", "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "document storage unavailable")
|
||||
@@ -259,7 +259,7 @@ func adminDownload(s *hcloud.Service[state], c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
|
||||
Route: "documents.file", Params: map[string]string{"id": c.Param("id")},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -275,7 +275,7 @@ func viewerDownload(s *hcloud.Service[state], c *zip.Ctx) error {
|
||||
if err != nil || !ok {
|
||||
return zip.ErrNotFound("link not found")
|
||||
}
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
|
||||
Route: "view.file",
|
||||
Params: map[string]string{"linkId": linkID, "documentId": c.Param("documentId")},
|
||||
Query: map[string]string{"viewId": c.Query("viewId"), "download": c.Query("download")},
|
||||
@@ -288,7 +288,7 @@ func viewerDownload(s *hcloud.Service[state], c *zip.Ctx) error {
|
||||
|
||||
// streamFile turns a {fileKey,contentType,name} bundle result into a byte stream
|
||||
// from object storage. A non-200 bundle result (404/403) passes through as JSON.
|
||||
func streamFile(s *hcloud.Service[state], c *zip.Ctx, resp *gojabase.Response) error {
|
||||
func streamFile(s *hcloud.Service[state], c *zip.Ctx, resp *goja.Response) error {
|
||||
if resp.Status != http.StatusOK {
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return c.Bytes(resp.Status, resp.Body)
|
||||
@@ -325,7 +325,7 @@ func createLink(s *hcloud.Service[state], c *zip.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{Route: "links.create", Body: body})
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{Route: "links.create", Body: body})
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "dataroom dispatch failed")
|
||||
}
|
||||
@@ -349,7 +349,7 @@ func createLink(s *hcloud.Service[state], c *zip.Ctx) error {
|
||||
// write dispatches one bundle route on the tenant's Base store (one transaction
|
||||
// per request) and writes {status, body}.
|
||||
func write(s *hcloud.Service[state], c *zip.Ctx, org, route string, params, query map[string]string, body any) error {
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, gojabase.Request{
|
||||
resp, err := s.State.host.Dispatch(c.Context(), org, goja.BaseRequest{
|
||||
Route: route, Params: params, Query: query, Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
type linkIndex struct{ db *sql.DB }
|
||||
|
||||
func openLinkIndex(dataDir string) (*linkIndex, error) {
|
||||
// Its OWN dir, distinct from gojabase's per-tenant tree ({dataDir}/dataroom/):
|
||||
// Its OWN dir, distinct from NewBase's per-tenant tree ({dataDir}/dataroom/):
|
||||
// this global routing table must never collide with a tenant's DB file.
|
||||
dir := filepath.Join(dataDir, "dataroom_index")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/gojabase"
|
||||
"github.com/hanzoai/cloud/clients/goja"
|
||||
)
|
||||
|
||||
// ingest.go is the in-process ingestion seam: it lets a sibling subsystem (Hanzo
|
||||
@@ -48,11 +48,11 @@ func Ingest(ctx context.Context, org, name, contentType string, data []byte) (st
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dataroom.Ingest: storage key: %w", err)
|
||||
}
|
||||
key := "dataroom/" + gojabase.TenantSegment(org) + "/" + rk
|
||||
key := "dataroom/" + goja.TenantSegment(org) + "/" + rk
|
||||
if err := mounted.State.blob.Put(ctx, key, data); err != nil {
|
||||
return "", fmt.Errorf("dataroom.Ingest: blob put: %w", err)
|
||||
}
|
||||
resp, err := mounted.State.host.Dispatch(ctx, org, gojabase.Request{
|
||||
resp, err := mounted.State.host.Dispatch(ctx, org, goja.BaseRequest{
|
||||
Route: "documents.create",
|
||||
Body: map[string]any{"name": name, "fileKey": key, "contentType": contentType, "fileSize": len(data)},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package dataroom
|
||||
|
||||
// schema is the per-tenant SQLite DDL — the Go host owns migrations (gojabase
|
||||
// schema is the per-tenant SQLite DDL — the Go host owns migrations (NewBase
|
||||
// runs this on every tenant DB open); the goja bundle only issues SQL against
|
||||
// these tables. Column names MUST match the SQL in bundle.js. Idempotent
|
||||
// (IF NOT EXISTS).
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// dashboard.go — the ArgoCD-UI-compatible projection API at /v1/deploy/api/*,
|
||||
// fed the App-CR projection (projection.go). NO argocd api-server, NO
|
||||
// repo-server, NO redis, NO stored Application/AppProject CRD — every response
|
||||
// is synthesized from our operator App CRs. The FRONTEND is NOT here: the
|
||||
// monochrome dashboard ships as the `hanzoai/spa`-based `cd-ui` App CR served at
|
||||
// cd.hanzo.ai/ (base-href /); this plane is only the same-origin API it calls (no /api/, no inner /v1):
|
||||
//
|
||||
// GET /v1/deploy/settings → AuthSettings (auth disabled; IAM gates at the edge)
|
||||
// GET /v1/deploy/session/userinfo → {loggedIn:true,...}
|
||||
// GET /v1/deploy/version → VersionMessage
|
||||
// GET /v1/deploy/account/can-i/* → {"value":"yes"}
|
||||
// GET /v1/deploy/applications → ApplicationList (projected)
|
||||
// GET /v1/deploy/applications/{name} → Application (projected)
|
||||
// GET /v1/deploy/applications/{name}/resource-tree → ApplicationTree
|
||||
// POST /v1/deploy/applications/{name}/{sync,rollback} → request App-CR reconcile
|
||||
//
|
||||
// Every route is SuperAdmin-gated (c.IsAdmin), fail-closed; the argocd UI's own
|
||||
// auth is disabled because IAM owns identity at the edge (the SPA is public
|
||||
// static assets, the data is gated). AppProject → IAM/Org (no argocd RBAC).
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
k8stypes "k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
// dashPrefix is the API base the monochrome dashboard SPA calls. The FE itself
|
||||
// is NOT served here — it ships as the `hanzoai/spa`-based `cd-ui` App CR served
|
||||
// at cd.hanzo.ai/ (base-href /); this cloud plane is ONLY the IAM-gated
|
||||
// projection API at cd.hanzo.ai/v1/deploy/*, same-origin with the SPA (no CORS).
|
||||
const dashPrefix = "/v1/deploy"
|
||||
|
||||
// registerDashboardRoutes wires the ArgoCD-UI-compatible API surface (no FE —
|
||||
// the SPA is a separate hanzoai/spa App). Called from routes() (deploy.go).
|
||||
func registerDashboardRoutes(app *zip.App, s *cloud.Service[state]) {
|
||||
// Bootstrap (the SPA awaits settings + userinfo before first render).
|
||||
app.Get(dashPrefix+"/settings", guard(s, cloud.Handle(s, dashSettings)))
|
||||
// userinfo is the ONE deliberately PUBLIC bootstrap route: it is how the SPA
|
||||
// asks "am I signed in?", and a 403 to that question is unanswerable — the SPA
|
||||
// is an XHR client, so the document bounce in guard() never fires for it and it
|
||||
// dead-ends with no way to reach sign-in. Anonymous callers get
|
||||
// {loggedIn:false} and the sign-in URL; nothing else. It discloses no identity,
|
||||
// no cluster state, and no configuration, and it is NOT a gate: every route
|
||||
// that returns fleet data or mutates a CR stays guard()ed.
|
||||
app.Get(dashPrefix+"/session/userinfo", cloud.Handle(s, dashUserInfo))
|
||||
app.Get(dashPrefix+"/version", guard(s, cloud.Handle(s, dashVersion)))
|
||||
app.Get(dashPrefix+"/account/can-i/*", guard(s, cloud.Handle(s, dashCanI)))
|
||||
|
||||
// Applications projection (read).
|
||||
app.Get(dashPrefix+"/applications", guard(s, cloud.Handle(s, dashAppList)))
|
||||
app.Get(dashPrefix+"/applications/:name", guard(s, cloud.Handle(s, dashApp)))
|
||||
app.Get(dashPrefix+"/applications/:name/resource-tree", guard(s, cloud.Handle(s, dashResourceTree)))
|
||||
|
||||
// Actions → App-CR reconcile ops.
|
||||
app.Post(dashPrefix+"/applications/:name/sync", guard(s, cloud.Handle(s, dashSync)))
|
||||
app.Post(dashPrefix+"/applications/:name/rollback", guard(s, cloud.Handle(s, dashSync)))
|
||||
}
|
||||
|
||||
// ── bootstrap ────────────────────────────────────────────────────────────────
|
||||
|
||||
func dashSettings(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"url": "https://cd.hanzo.ai",
|
||||
"statusBadgeEnabled": false,
|
||||
"statusBadgeRootUrl": "",
|
||||
"oidcConfig": nil,
|
||||
"dexConfig": map[string]any{"connectors": []any{}},
|
||||
"googleAnalytics": map[string]any{"trackingID": "", "anonymizeUsers": true},
|
||||
"help": map[string]any{"chatUrl": "", "chatText": "", "binaryUrls": map[string]any{}},
|
||||
"plugins": []any{},
|
||||
"userLoginsDisabled": true,
|
||||
"kustomizeVersions": []any{},
|
||||
"uiCssURL": "",
|
||||
"uiBannerContent": "",
|
||||
"execEnabled": false,
|
||||
"appsInAnyNamespaceEnabled": false,
|
||||
"hydratorEnabled": false,
|
||||
"syncWithReplaceAllowed": false,
|
||||
})
|
||||
}
|
||||
|
||||
// dashUserInfo answers "is this browser signed in, and if not where does it sign
|
||||
// in?" — the SPA's bootstrap question, and the only route on this plane that
|
||||
// answers for an anonymous caller.
|
||||
//
|
||||
// The anonymous branch carries loggedIn:false and a URL, and NOTHING else: no
|
||||
// username, no org, no groups, no issuer, no hint about who the caller might be or
|
||||
// what exists in the cluster. Answering it costs nothing (the caller already knows
|
||||
// whether it holds a cookie) and withholding it costs the whole sign-in journey.
|
||||
//
|
||||
// The predicate is c.IsAdmin() — the SAME SuperAdmin fact guard() gates on, minted
|
||||
// by SanitizeIdentity from a validated principal whose org is the reserved admin
|
||||
// org. So a validated-but-not-SuperAdmin caller is reported as NOT logged in here,
|
||||
// which is the truth as this console defines it: they cannot use it.
|
||||
func dashUserInfo(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"loggedIn": false,
|
||||
"loginUrl": loginPath,
|
||||
})
|
||||
}
|
||||
user := c.User()
|
||||
if user == "" {
|
||||
user = "admin"
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"loggedIn": true,
|
||||
"username": user,
|
||||
"iss": "argocd", // keep == argocd so the UI never triggers an SSO redirect
|
||||
"groups": []string{},
|
||||
"logoutUrl": logoutPath,
|
||||
})
|
||||
}
|
||||
|
||||
func dashVersion(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// PascalCase keys (VersionMessage wire shape).
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"Version": "hanzo-cd (projection)",
|
||||
"BuildDate": time.Now().UTC().Format(time.RFC3339),
|
||||
"GoVersion": "", "Compiler": "gc", "Platform": "linux/amd64",
|
||||
})
|
||||
}
|
||||
|
||||
func dashCanI(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// Every route is already SuperAdmin-gated; report yes so buttons enable.
|
||||
return c.JSON(http.StatusOK, map[string]any{"value": "yes"})
|
||||
}
|
||||
|
||||
// ── applications projection ──────────────────────────────────────────────────
|
||||
|
||||
func dashAppList(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
list := argoAppList{APIVersion: "argoproj.io/v1alpha1", Kind: "ApplicationList", Metadata: argoListMeta{}, Items: []argoApp{}}
|
||||
for _, ns := range scanOrder() {
|
||||
crs, err := listAppCRs(s, c.Context(), ns)
|
||||
if err != nil {
|
||||
return k8sErr(s, "list", err)
|
||||
}
|
||||
running := runningVersions(s, c.Context(), ns)
|
||||
for i := range crs {
|
||||
list.Items = append(list.Items, projectApp(&crs[i], ns, running[crs[i].GetName()]))
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func dashApp(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := resolveNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cr, _, err := getAppCR(s, c.Context(), ns, name)
|
||||
if err != nil {
|
||||
return k8sErr(s, "get", err)
|
||||
}
|
||||
running := runningVersions(s, c.Context(), ns)
|
||||
app := projectApp(cr, ns, running[name])
|
||||
// Detail view: populate status.resources from the reconciled tree.
|
||||
tree := projectTree(buildTree(s, c.Context(), ns, name, cr))
|
||||
for _, n := range tree.Nodes {
|
||||
app.Status.Resources = append(app.Status.Resources, argoResourceStatus{
|
||||
Group: n.Group, Version: n.Version, Kind: n.Kind, Namespace: n.Namespace,
|
||||
Name: n.Name, Status: app.Status.Sync.Status, Health: n.Health,
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, app)
|
||||
}
|
||||
|
||||
func dashResourceTree(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := resolveNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cr, _, err := getAppCR(s, c.Context(), ns, name)
|
||||
if err != nil {
|
||||
return k8sErr(s, "get", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, projectTree(buildTree(s, c.Context(), ns, name, cr)))
|
||||
}
|
||||
|
||||
// dashSync requests an operator reconcile of the App CR (the sync + rollback UI
|
||||
// actions both map to "reconcile this App now" — the App CR is the source of
|
||||
// truth; rollback-by-revision is the image-pin follow-on). Returns the projected
|
||||
// Application (the UI only checks for a non-error response).
|
||||
func dashSync(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if err := ready(s); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must be a DNS-1123 label")
|
||||
}
|
||||
ns, err := resolveNamespace(s, c, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cr, gvr, err := getAppCR(s, c.Context(), ns, name)
|
||||
if err != nil {
|
||||
return k8sErr(s, "get", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
patch, _ := json.Marshal(map[string]any{"metadata": map[string]any{"annotations": map[string]any{syncAnnotation: now}}})
|
||||
if _, err := s.State.dyn.Resource(gvr).Namespace(ns).Patch(c.Context(), name, k8stypes.MergePatchType, patch, metav1.PatchOptions{}); err != nil {
|
||||
return k8sErr(s, "patch", err)
|
||||
}
|
||||
s.Log.Info("dashboard sync requested", "app", name, "namespace", ns, "actor", c.User())
|
||||
return c.JSON(http.StatusOK, projectApp(cr, ns, runningVersions(s, c.Context(), ns)[name]))
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// TestDeployRoutesRequireAdmin (RED LOW-2): every mounted /v1/deploy route EXCEPT
|
||||
// the public health probe must 403 without X-User-IsAdmin=true, and must NOT 403
|
||||
// with it. This guards the guard: a future refactor that adds an /v1/deploy or
|
||||
// /v1/deploy/ui route without wrapping it in guard() breaks this test.
|
||||
func TestDeployRoutesRequireAdmin(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, fakeSvc()) // the COMPLETE surface: native + engine + dashboard
|
||||
|
||||
guarded := []struct{ method, path string }{
|
||||
// engine
|
||||
{"POST", "/v1/deploy/reconcile"},
|
||||
// projection API — clean /v1/deploy/<resource> (no /api/, no inner /v1)
|
||||
{"GET", "/v1/deploy/settings"},
|
||||
{"GET", "/v1/deploy/version"},
|
||||
{"GET", "/v1/deploy/account/can-i/applications/get/x"},
|
||||
{"GET", "/v1/deploy/applications"},
|
||||
{"GET", "/v1/deploy/applications/cloud"},
|
||||
{"GET", "/v1/deploy/applications/cloud/resource-tree"},
|
||||
{"POST", "/v1/deploy/applications/cloud/sync"},
|
||||
{"POST", "/v1/deploy/applications/cloud/rollback"},
|
||||
}
|
||||
for _, r := range guarded {
|
||||
// WITHOUT admin → 403 (the guard, fail-closed).
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest(r.method, r.path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", r.method, r.path, err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
_ = resp.Body.Close()
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("%s %s WITHOUT admin = %d, want 403 (route is not guarded!)", r.method, r.path, code)
|
||||
}
|
||||
// WITH admin → the guard passes (handler may 200/404/503, never the guard's 403).
|
||||
req := httptest.NewRequest(r.method, r.path, nil)
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
resp2, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s admin: %v", r.method, r.path, err)
|
||||
}
|
||||
code2 := resp2.StatusCode
|
||||
_ = resp2.Body.Close()
|
||||
if code2 == http.StatusForbidden {
|
||||
t.Errorf("%s %s WITH admin = 403 (guard must pass for a validated SuperAdmin)", r.method, r.path)
|
||||
}
|
||||
}
|
||||
|
||||
// The health probe is DELIBERATELY public (liveness without a JWT) — never 403.
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/health", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("health: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
t.Error("/v1/deploy/health must stay public (probe-able without a JWT)")
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Sign-in is DELIBERATELY public — these routes ARE how a browser becomes a
|
||||
// SuperAdmin, so gating them behind SuperAdmin would be circular. They grant
|
||||
// nothing: /login only redirects, /callback refuses anything but a token this
|
||||
// deployment verifies for a member of the admin org.
|
||||
for _, r := range []struct{ method, path string }{
|
||||
{"GET", "/v1/deploy/login"},
|
||||
{"GET", "/v1/deploy/callback"},
|
||||
{"POST", "/v1/deploy/logout"},
|
||||
} {
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest(r.method, r.path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", r.method, r.path, err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
_ = resp.Body.Close()
|
||||
if code == http.StatusForbidden {
|
||||
t.Errorf("%s %s = 403; sign-in cannot require the role it grants", r.method, r.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Logout must NOT be reachable as a GET: it changes state, and a cross-site
|
||||
// top-level navigation carries a SameSite=Lax cookie.
|
||||
resp, err = app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/logout", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GET logout: %v", err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
_ = resp.Body.Close()
|
||||
if code != http.StatusMethodNotAllowed && code != http.StatusNotFound {
|
||||
t.Errorf("GET /v1/deploy/logout = %d, want 404/405 (logout is POST-only)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserInfoIsPublicBootstrap: the SPA's "am I signed in?" question must be
|
||||
// answerable WITHOUT being signed in — it is an XHR client, so the document bounce
|
||||
// never fires for it and a 403 here is a dead end with no route to sign-in. The
|
||||
// anonymous answer carries the sign-in URL and NOTHING that identifies anyone.
|
||||
func TestUserInfoIsPublicBootstrap(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, fakeSvc())
|
||||
|
||||
resp, err := app.Fiber().Test(httptest.NewRequest("GET", "/v1/deploy/session/userinfo", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("userinfo: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("anonymous userinfo = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if body["loggedIn"] != false {
|
||||
t.Errorf("anonymous loggedIn = %v, want false", body["loggedIn"])
|
||||
}
|
||||
if body["loginUrl"] != "/v1/deploy/login" {
|
||||
t.Errorf("loginUrl = %v, want /v1/deploy/login", body["loginUrl"])
|
||||
}
|
||||
// No identity may leak to an anonymous caller — not even an empty placeholder.
|
||||
for _, k := range []string{"username", "iss", "groups", "email", "org", "logoutUrl"} {
|
||||
if _, present := body[k]; present {
|
||||
t.Errorf("anonymous userinfo leaked %q = %v", k, body[k])
|
||||
}
|
||||
}
|
||||
// A cookie must never be minted by a read.
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Value != "" {
|
||||
t.Errorf("userinfo minted a cookie %s=%q", ck.Name, ck.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// A SuperAdmin gets the real identity on the same route.
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/session/userinfo", nil)
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
req.Header.Set("X-User-Id", "cto")
|
||||
resp2, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("admin userinfo: %v", err)
|
||||
}
|
||||
var admin map[string]any
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&admin); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
_ = resp2.Body.Close()
|
||||
if admin["loggedIn"] != true || admin["username"] != "cto" {
|
||||
t.Errorf("admin userinfo = %v, want loggedIn:true username:cto", admin)
|
||||
}
|
||||
}
|
||||
+83
-93
@@ -2,7 +2,7 @@
|
||||
// ArgoCD-grade deploy dashboard for the operator-managed fleet, made native to
|
||||
// the cloud binary and parallel to /v1/git (the native git server).
|
||||
//
|
||||
// Each operator hanzo.ai/v1 Service CR IS a GitOps Application: the desired state
|
||||
// Each operator hanzo.ai/v1 App CR IS a GitOps Application: the desired state
|
||||
// declared for one workload, which the Hanzo operator reconciles into a
|
||||
// Deployment + Service + Ingress (+ HPA/PDB/Pods). This plane OBSERVES that
|
||||
// reconciliation the way ArgoCD observes a synced Application —
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
@@ -54,27 +55,12 @@ import (
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// The operator "Application" CR is undergoing a kind collapse: the specialized
|
||||
// services.hanzo.ai (kind Service) + siblings become ONE apps.hanzo.ai (kind App),
|
||||
// with the former kind carried as a value in spec.role. appsCRGVR is the FORWARD
|
||||
// target; servicesCRGVR is the CURRENT live kind. The operator/CRD collapse lands
|
||||
// on branches; the live cluster still serves kind Service until cutover, so this
|
||||
// plane reads BOTH — App first (forward), Service as a transition shim. Group
|
||||
// hanzo.ai disambiguates either from the core/v1 Service (a CHILD it reconciles),
|
||||
// which is why a resource ref always carries its group.
|
||||
//
|
||||
// COMPAT SHIM (removable post-cutover): drop servicesCRGVR from appCRGVRs() and the
|
||||
// "hanzo.ai/Service" registry entry once every cluster serves kind App.
|
||||
var (
|
||||
appsCRGVR = schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "apps"}
|
||||
servicesCRGVR = schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "services"}
|
||||
)
|
||||
|
||||
// appCRGVRs is the App-CR read order: the forward kind first, the transition kind
|
||||
// second. Every CR read/resolve walks this list.
|
||||
func appCRGVRs() []schema.GroupVersionResource {
|
||||
return []schema.GroupVersionResource{appsCRGVR, servicesCRGVR}
|
||||
}
|
||||
// appsCRGVR is the operator App CR (apps.hanzo.ai) — the one workload kind this
|
||||
// plane reads. Each App IS a GitOps Application: the desired state for one
|
||||
// workload, which the operator reconciles into a Deployment + Service + Ingress
|
||||
// (+ HPA/PDB/Pods). Group hanzo.ai disambiguates it from the core/v1 Service (a
|
||||
// CHILD it reconciles), which is why a resource ref always carries its group.
|
||||
var appsCRGVR = schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "apps"}
|
||||
|
||||
// childGVRs are the operator-owned workload objects the tree walks at depth 1
|
||||
// (owned by the Service CR) and their descendants (ReplicaSet → Pod). Secrets are
|
||||
@@ -96,8 +82,7 @@ var (
|
||||
// the resource endpoint can never be steered at an arbitrary cluster object.
|
||||
// Keyed by "group/Kind" (group "" for the core API group).
|
||||
var kindGVR = map[string]schema.GroupVersionResource{
|
||||
"hanzo.ai/App": appsCRGVR, // forward kind
|
||||
"hanzo.ai/Service": servicesCRGVR, // transition shim (removable post-cutover)
|
||||
"hanzo.ai/App": appsCRGVR,
|
||||
"apps/Deployment": deploymentsGVR,
|
||||
"apps/ReplicaSet": replicaSetsGVR,
|
||||
"/Pod": podsGVR,
|
||||
@@ -134,17 +119,19 @@ type state struct {
|
||||
dyn dynamic.Interface // nil when no kubeconfig resolved (fail-closed)
|
||||
clientset kubernetes.Interface
|
||||
initErr string
|
||||
oauth oauth // sign-in configuration (login.go)
|
||||
}
|
||||
|
||||
// Mount wires /v1/deploy/* onto app. Every handler gates on c.IsAdmin() first.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
return cloud.Mount(app, deps, "deploy", build, routes)
|
||||
return cloud.Mount(app, deps, "deploy",
|
||||
func(b cloud.Base) (state, error) { return build(b, newOAuth(deps)) }, routes)
|
||||
}
|
||||
|
||||
// build resolves the in-process k8s clients (fail-closed: when no kubeconfig
|
||||
// resolves the subsystem still mounts and every endpoint 503s honestly).
|
||||
func build(b cloud.Base) (state, error) {
|
||||
var st state
|
||||
func build(b cloud.Base, o oauth) (state, error) {
|
||||
st := state{oauth: o}
|
||||
dyn, cs, err := newClients()
|
||||
if err != nil {
|
||||
st.initErr = err.Error()
|
||||
@@ -152,60 +139,86 @@ func build(b cloud.Base) (state, error) {
|
||||
} else {
|
||||
st.dyn, st.clientset = dyn, cs
|
||||
}
|
||||
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
|
||||
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil,
|
||||
"brand", b.Brand, "env", b.Env, "iam", o.issuer, "client", o.clientID, "adminOrg", o.adminOrg)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// routes registers the /v1/deploy/* surface. Every observing/mutating route is
|
||||
// SuperAdmin-gated; the health probe is public (real k8s reachability).
|
||||
func routes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Get("/v1/deploy/applications", guard(s, cloud.Handle(s, listApplications)))
|
||||
// Liveness — public (probe-able without a JWT).
|
||||
app.Get("/v1/deploy/health", cloud.Handle(s, health))
|
||||
app.Get("/v1/deploy/:name/tree", guard(s, cloud.Handle(s, appTree)))
|
||||
app.Get("/v1/deploy/:name/resource/:ref", guard(s, cloud.Handle(s, appResource)))
|
||||
app.Get("/v1/deploy/:name/logs", guard(s, cloud.Handle(s, appLogs)))
|
||||
app.Post("/v1/deploy/:name/rollback", guard(s, cloud.Handle(s, rollback)))
|
||||
app.Post("/v1/deploy/:name/sync", guard(s, cloud.Handle(s, sync)))
|
||||
// Sign-in — necessarily public: these three routes ARE how a browser gets an
|
||||
// authenticated principal for this host. They grant nothing themselves; the
|
||||
// session they mint is an IAM JWT the identity boundary re-verifies on every
|
||||
// later request, and a principal outside the admin org is refused a cookie.
|
||||
// See login.go.
|
||||
app.Get(loginPath, cloud.Handle(s, login))
|
||||
app.Get(callbackPath, cloud.Handle(s, callback))
|
||||
// POST, not GET: signing out changes state, and a state-changing GET is
|
||||
// reachable by a cross-site top-level navigation that a SameSite=Lax cookie
|
||||
// still rides. See logout in login.go.
|
||||
app.Post(logoutPath, cloud.Handle(s, logout))
|
||||
// Engine (write) reconcile — the embedded gitops-engine that replaces
|
||||
// universe-crs. Gated by DEPLOY_ENGINE_ENABLED; see engine_mount.go.
|
||||
registerEngineRoutes(app, s)
|
||||
// The deploy API at /v1/deploy/<resource> (no /api/ prefix, no inner /v1) —
|
||||
// the App-CR projection the monochrome dashboard SPA (cd-ui) consumes. This
|
||||
// IS the deploy API; the FE is the separate hanzoai/spa cd-ui App.
|
||||
registerDashboardRoutes(app, s)
|
||||
}
|
||||
|
||||
// guard wraps a handler with the SuperAdmin gate (fail-closed: a non-SuperAdmin is
|
||||
// refused 403 before any cluster object is read or mutated), matching clients/paas.
|
||||
//
|
||||
// The gate itself is unchanged — c.IsAdmin() and nothing else, on the SanitizeIdentity
|
||||
// -minted header no client can forge. Only the SHAPE of the refusal is negotiated: a
|
||||
// browser NAVIGATION to a deploy URL is sent to the sign-in page (a 403 page with no
|
||||
// way to sign in is a dead end), while every API call keeps its 403. wantsDocument
|
||||
// decides, and it decides "no" unless the request positively identifies as a document
|
||||
// GET — so the API contract, and every client that depends on the 403, is untouched.
|
||||
func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
if wantsDocument(c.Method(), c.Header("Sec-Fetch-Dest"), c.Header("Sec-Fetch-Mode"),
|
||||
c.Header("Accept"), c.Header("X-Requested-With")) {
|
||||
return c.Redirect(http.StatusFound, loginPath+"?returnTo="+url.QueryEscape(currentPath(c)))
|
||||
}
|
||||
return zip.ErrForbidden("SuperAdmin required")
|
||||
}
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
// health is a REAL probe: the API server is reachable AND the Service CRD is
|
||||
// served. 200 only when both hold; 503 + the real reason otherwise. Not
|
||||
// admin-gated — liveness must be probe-able without a JWT.
|
||||
// currentPath is the path+query to return to after signing in. It is run through
|
||||
// the same open-redirect guard as a caller-supplied returnTo — the value is
|
||||
// server-derived, but there is exactly ONE rule for what a return path may be.
|
||||
func currentPath(c *zip.Ctx) string {
|
||||
p := c.Path()
|
||||
if q := string(c.Fiber().Request().URI().QueryString()); q != "" {
|
||||
p += "?" + q
|
||||
}
|
||||
return safeReturn(p)
|
||||
}
|
||||
|
||||
// health is a REAL probe: the API server is reachable AND the App CRD is served.
|
||||
// 200 only when both hold; 503 + the real reason otherwise. Not admin-gated —
|
||||
// liveness must be probe-able without a JWT.
|
||||
func health(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// This route is UNAUTHENTICATED (liveness must be probe-able without a JWT),
|
||||
// so it reports booleans only — never the raw k8s error string, which can
|
||||
// disclose the apiserver address / RBAC detail. The detail is logged
|
||||
// server-side (RED INFO-1).
|
||||
res := map[string]any{"service": "deploy", "status": "ok"}
|
||||
if s.State.dyn == nil {
|
||||
res["status"], res["k8s"], res["error"] = "degraded", false, s.State.initErr
|
||||
s.Log.Warn("deploy health: kubernetes client unavailable", "err", s.State.initErr)
|
||||
res["status"], res["k8s"] = "degraded", false
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
// The App CRD is served if EITHER the forward (apps) or transition (services)
|
||||
// kind lists without error — a NotFound on one during the collapse is not a
|
||||
// degradation as long as the other answers.
|
||||
var lastErr error
|
||||
served := false
|
||||
for _, gvr := range appCRGVRs() {
|
||||
if _, err := s.State.dyn.Resource(gvr).Namespace("hanzo").List(c.Context(), metav1.ListOptions{Limit: 1}); err == nil {
|
||||
served = true
|
||||
break
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if !served {
|
||||
if _, err := s.State.dyn.Resource(appsCRGVR).Namespace("hanzo").List(c.Context(), metav1.ListOptions{Limit: 1}); err != nil {
|
||||
s.Log.Warn("deploy health: App CRD list failed", "err", err)
|
||||
res["status"], res["k8s"], res["crd"] = "degraded", true, false
|
||||
if lastErr != nil {
|
||||
res["error"] = lastErr.Error()
|
||||
}
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
res["k8s"], res["crd"] = true, true
|
||||
@@ -239,8 +252,7 @@ func regexpLower(s string) string {
|
||||
}
|
||||
|
||||
// resolveNamespace finds the platform namespace an App CR lives in, scanning in
|
||||
// env order (main first) across both CR kinds. Returns a clean 404 when found in
|
||||
// none.
|
||||
// env order (main first). Returns a clean 404 when found in none.
|
||||
func resolveNamespace(s *cloud.Service[state], c *zip.Ctx, name string) (string, error) {
|
||||
for _, ns := range scanOrder() {
|
||||
if _, _, err := getAppCR(s, c.Context(), ns, name); err == nil {
|
||||
@@ -252,49 +264,27 @@ func resolveNamespace(s *cloud.Service[state], c *zip.Ctx, name string) (string,
|
||||
return "", zip.ErrNotFound("application " + name + " not found in the platform namespaces")
|
||||
}
|
||||
|
||||
// getAppCR gets an App CR by name from ns, trying the forward kind (apps) then the
|
||||
// transition kind (services). Returns the object and the GVR it was found under, so
|
||||
// a mutation (sync) patches the SAME kind. A miss in both is an IsNotFound error.
|
||||
// getAppCR gets an App CR by name from ns. Returns the object and its GVR so a
|
||||
// mutation (sync/rollback) patches the App CR it read. A miss is an IsNotFound
|
||||
// error.
|
||||
func getAppCR(s *cloud.Service[state], ctx context.Context, ns, name string) (*unstructured.Unstructured, schema.GroupVersionResource, error) {
|
||||
var readErr error
|
||||
for _, gvr := range appCRGVRs() {
|
||||
obj, err := s.State.dyn.Resource(gvr).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||
if err == nil {
|
||||
return obj, gvr, nil
|
||||
}
|
||||
if !apierrors.IsNotFound(err) {
|
||||
readErr = err
|
||||
}
|
||||
obj, err := s.State.dyn.Resource(appsCRGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, schema.GroupVersionResource{}, err
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, schema.GroupVersionResource{}, readErr
|
||||
}
|
||||
return nil, schema.GroupVersionResource{}, apierrors.NewNotFound(appsCRGVR.GroupResource(), name)
|
||||
return obj, appsCRGVR, nil
|
||||
}
|
||||
|
||||
// listAppCRs lists every App CR in ns across both kinds, App first, de-duplicated
|
||||
// by name (a name served by both kinds during the collapse yields the App copy).
|
||||
// listAppCRs lists every App CR in ns.
|
||||
func listAppCRs(s *cloud.Service[state], ctx context.Context, ns string) ([]unstructured.Unstructured, error) {
|
||||
seen := map[string]bool{}
|
||||
var out []unstructured.Unstructured
|
||||
for _, gvr := range appCRGVRs() {
|
||||
list, err := s.State.dyn.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for i := range list.Items {
|
||||
n := list.Items[i].GetName()
|
||||
if seen[n] {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, list.Items[i])
|
||||
list, err := s.State.dyn.Resource(appsCRGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
return list.Items, nil
|
||||
}
|
||||
|
||||
// k8sErr maps a raw API error to an honest gateway error, naming the missing RBAC
|
||||
|
||||
@@ -20,7 +20,6 @@ func fakeSvc(objs ...runtime.Object) *cloud.Service[state] {
|
||||
scheme := runtime.NewScheme()
|
||||
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
|
||||
appsCRGVR: "AppList",
|
||||
servicesCRGVR: "ServiceCRList",
|
||||
deploymentsGVR: "DeploymentList",
|
||||
replicaSetsGVR: "ReplicaSetList",
|
||||
podsGVR: "PodList",
|
||||
@@ -128,20 +127,18 @@ func TestSyncStatus(t *testing.T) {
|
||||
// ── ref parsing ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseRef(t *testing.T) {
|
||||
// forward App kind and transition Service kind both resolve.
|
||||
// The App CR resolves; the core/v1 Service (a child object) resolves distinctly.
|
||||
if _, gvr, err := parseRef("hanzo.ai:App:hanzo:iam"); err != nil || gvr != appsCRGVR {
|
||||
t.Errorf("App ref → (%v, %v), want appsCRGVR", gvr, err)
|
||||
}
|
||||
if _, gvr, err := parseRef("hanzo.ai:Service:hanzo:iam"); err != nil || gvr != servicesCRGVR {
|
||||
t.Errorf("Service ref → (%v, %v), want servicesCRGVR", gvr, err)
|
||||
}
|
||||
if _, gvr, err := parseRef("apps:Deployment:hanzo:iam"); err != nil || gvr != deploymentsGVR {
|
||||
t.Errorf("Deployment ref → (%v, %v), want deploymentsGVR", gvr, err)
|
||||
}
|
||||
if _, _, err := parseRef(":Service:hanzo:iam"); err != nil {
|
||||
t.Errorf("core Service ref err = %v, want nil", err)
|
||||
}
|
||||
bad := []string{"", "a:b:c", "unknown/Kind:hanzo:iam:x", "apps:Deployment:evil-ns:iam", "apps:Deployment:hanzo:Bad_Name"}
|
||||
// hanzo.ai:Service is not a kind this plane reads — the operator CR is App.
|
||||
bad := []string{"", "a:b:c", "hanzo.ai:Service:hanzo:iam", "unknown/Kind:hanzo:iam:x", "apps:Deployment:evil-ns:iam", "apps:Deployment:hanzo:Bad_Name"}
|
||||
for _, r := range bad {
|
||||
if _, _, err := parseRef(r); err == nil {
|
||||
t.Errorf("parseRef(%q) = nil err, want rejection", r)
|
||||
@@ -215,49 +212,37 @@ func TestObserveApplication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── CR resolution shim (App forward, Service fallback) ───────────────────────
|
||||
// ── CR resolution ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetAppCRAppFirst(t *testing.T) {
|
||||
s := fakeSvc(
|
||||
appCR("App", "hanzo", "iam", "u-app", "ghcr.io/hanzoai/iam", "v2.0.0", "Running", 1, 1),
|
||||
appCR("Service", "hanzo", "iam", "u-svc", "ghcr.io/hanzoai/iam", "v1.0.0", "Running", 1, 1),
|
||||
)
|
||||
func TestGetAppCR(t *testing.T) {
|
||||
s := fakeSvc(appCR("App", "hanzo", "iam", "u-app", "ghcr.io/hanzoai/iam", "v2.0.0", "Running", 1, 1))
|
||||
obj, gvr, err := getAppCR(s, context.Background(), "hanzo", "iam")
|
||||
if err != nil {
|
||||
t.Fatalf("getAppCR: %v", err)
|
||||
}
|
||||
if gvr != appsCRGVR {
|
||||
t.Fatalf("gvr = %v, want appsCRGVR (App must win)", gvr)
|
||||
t.Fatalf("gvr = %v, want appsCRGVR", gvr)
|
||||
}
|
||||
if tag, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "tag"); tag != "v2.0.0" {
|
||||
t.Fatalf("resolved tag = %q, want v2.0.0 (App copy)", tag)
|
||||
t.Fatalf("resolved tag = %q, want v2.0.0", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppCRServiceFallback(t *testing.T) {
|
||||
s := fakeSvc(appCR("Service", "hanzo", "iam", "u-svc", "ghcr.io/hanzoai/iam", "v1.0.0", "Running", 1, 1))
|
||||
_, gvr, err := getAppCR(s, context.Background(), "hanzo", "iam")
|
||||
if err != nil || gvr != servicesCRGVR {
|
||||
t.Fatalf("getAppCR fallback → (%v, %v), want servicesCRGVR", gvr, err)
|
||||
}
|
||||
// Missing everywhere → IsNotFound.
|
||||
// Missing → IsNotFound.
|
||||
if _, _, err := getAppCR(s, context.Background(), "hanzo", "ghost"); err == nil {
|
||||
t.Fatal("getAppCR(ghost) = nil err, want NotFound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAppCRsMergeDedup(t *testing.T) {
|
||||
func TestListAppCRs(t *testing.T) {
|
||||
s := fakeSvc(
|
||||
appCR("App", "hanzo", "iam", "u1", "r", "v2.0.0", "Running", 1, 1), // App copy of iam
|
||||
appCR("Service", "hanzo", "iam", "u2", "r", "v1.0.0", "Running", 1, 1), // stale Service copy — deduped out
|
||||
appCR("Service", "hanzo", "cloud", "u3", "r", "v1.799.0", "Running", 1, 1),
|
||||
appCR("App", "hanzo", "iam", "u1", "r", "v2.0.0", "Running", 1, 1),
|
||||
appCR("App", "hanzo", "cloud", "u3", "r", "v1.799.0", "Running", 1, 1),
|
||||
)
|
||||
crs, err := listAppCRs(s, context.Background(), "hanzo")
|
||||
if err != nil {
|
||||
t.Fatalf("listAppCRs: %v", err)
|
||||
}
|
||||
if len(crs) != 2 {
|
||||
t.Fatalf("listAppCRs len = %d, want 2 (iam deduped, cloud)", len(crs))
|
||||
t.Fatalf("listAppCRs len = %d, want 2 (iam, cloud)", len(crs))
|
||||
}
|
||||
byName := map[string]string{}
|
||||
for i := range crs {
|
||||
@@ -265,7 +250,7 @@ func TestListAppCRsMergeDedup(t *testing.T) {
|
||||
byName[crs[i].GetName()] = tag
|
||||
}
|
||||
if byName["iam"] != "v2.0.0" {
|
||||
t.Errorf("iam tag = %q, want v2.0.0 (App wins the dedupe)", byName["iam"])
|
||||
t.Errorf("iam tag = %q, want v2.0.0", byName["iam"])
|
||||
}
|
||||
if byName["cloud"] != "v1.799.0" {
|
||||
t.Errorf("cloud tag = %q", byName["cloud"])
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// engine.go embeds the argo gitops-engine (github.com/hanzoai/deploy/
|
||||
// gitops-engine, the fork's independently-importable submodule) in-process, so
|
||||
// the cloud binary reconciles git → cluster the way the retired argocd
|
||||
// application-controller did — three-way merge (server-side apply), scoped
|
||||
// prune, drift-correction, health, sync status — with NO separate argocd
|
||||
// process and NO redis. It is the write/reconcile half of /v1/deploy; the
|
||||
// existing routes are the read/visualize half.
|
||||
//
|
||||
// The apply-set is scoped by a tracking LABEL (deploy.hanzo.ai/instance): the
|
||||
// isManaged predicate that drives pruning returns true ONLY for live objects
|
||||
// carrying THIS instance's label, so a prune can never delete an App CR (or any
|
||||
// object) this plane did not create. This is the prune-safety boundary for the
|
||||
// 60+ live App CRs — the exact property universe-crs enforced with prune:false,
|
||||
// kept here and made explicit.
|
||||
//
|
||||
// Enablement is opt-in and fail-safe (DEPLOY_ENGINE_ENABLED, default off): the
|
||||
// first deploy of this binary is inert for the reconcile path, so it ships
|
||||
// dark and is turned on deliberately after the shadow proof — mirroring the
|
||||
// operator's gate discipline and the argocd shadow-then-flip cutover.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/hanzoai/deploy/gitops-engine/pkg/cache"
|
||||
"github.com/hanzoai/deploy/gitops-engine/pkg/engine"
|
||||
enginehealth "github.com/hanzoai/deploy/gitops-engine/pkg/health"
|
||||
enginesync "github.com/hanzoai/deploy/gitops-engine/pkg/sync"
|
||||
synccommon "github.com/hanzoai/deploy/gitops-engine/pkg/sync/common"
|
||||
"github.com/hanzoai/deploy/gitops-engine/pkg/utils/kube"
|
||||
)
|
||||
|
||||
// engineTrackingLabel scopes an apply-set. Every object the engine declares
|
||||
// carries deploy.hanzo.ai/instance=<instance>; pruning only ever considers live
|
||||
// objects with THIS value.
|
||||
const engineTrackingLabel = "deploy.hanzo.ai/instance"
|
||||
|
||||
// engineFieldManager is the server-side-apply manager for git-sourced applies —
|
||||
// distinct from the operator's own `hanzo-operator` manager, so a delivery apply
|
||||
// is attributable and never silently fights a per-Kind reconcile.
|
||||
const engineFieldManager = "hanzo-deploy"
|
||||
|
||||
// engineResInfo is cached per live resource; `tracked` records whether the
|
||||
// object carries THIS instance's tracking label (the prune predicate).
|
||||
type engineResInfo struct{ tracked bool }
|
||||
|
||||
// reconciler embeds the argo gitops-engine over one apply-set (one git source,
|
||||
// one tracking-label instance) across a fixed set of namespaces.
|
||||
type reconciler struct {
|
||||
instance string
|
||||
namespaces []string
|
||||
log logr.Logger
|
||||
cache cache.ClusterCache
|
||||
engine engine.GitOpsEngine
|
||||
}
|
||||
|
||||
// newReconciler builds an engine-backed reconciler. `namespaces` is the platform
|
||||
// tier the engine watches (empty = all); `instance` tags this apply-set.
|
||||
func newReconciler(cfg *rest.Config, namespaces []string, instance string, log logr.Logger) *reconciler {
|
||||
cc := cache.NewClusterCache(cfg,
|
||||
cache.SetNamespaces(namespaces),
|
||||
cache.SetLogr(log),
|
||||
cache.SetPopulateResourceInfoHandler(func(un *unstructured.Unstructured, _ bool) (any, bool) {
|
||||
v := un.GetLabels()[engineTrackingLabel]
|
||||
return &engineResInfo{tracked: v == instance}, v != ""
|
||||
}),
|
||||
)
|
||||
return &reconciler{
|
||||
instance: instance,
|
||||
namespaces: namespaces,
|
||||
log: log,
|
||||
cache: cc,
|
||||
engine: engine.NewEngine(cfg, cc, engine.WithLogr(log)),
|
||||
}
|
||||
}
|
||||
|
||||
// run starts the cluster informer cache; the returned StopFunc tears it down.
|
||||
func (r *reconciler) run() (engine.StopFunc, error) { return r.engine.Run() }
|
||||
|
||||
// stamp puts the tracking label on every desired object so an applied object is
|
||||
// cached as managed by THIS instance.
|
||||
func (r *reconciler) stamp(objs []*unstructured.Unstructured) {
|
||||
for _, o := range objs {
|
||||
l := o.GetLabels()
|
||||
if l == nil {
|
||||
l = map[string]string{}
|
||||
}
|
||||
l[engineTrackingLabel] = r.instance
|
||||
o.SetLabels(l)
|
||||
}
|
||||
}
|
||||
|
||||
// PruneFuse bounds how much a single reconcile may delete — the circuit breaker
|
||||
// against a silent empty/partial render sweeping the fleet. Both limits are
|
||||
// checked; either one trips the fuse. Zero disables that check.
|
||||
type PruneFuse struct {
|
||||
MaxDeletions int // absolute cap on objects pruned in one reconcile
|
||||
MaxRatio float64 // cap as a fraction of the managed set (0..1)
|
||||
}
|
||||
|
||||
// isProtectedKind is the data-anchor exclusion: PersistentVolumeClaim (delete =
|
||||
// irreversible data loss) and KMSSecret (kms.hanzo.ai) are NEVER prune
|
||||
// candidates, even when absent from the desired set. They are still applied
|
||||
// (target side); they are only removed from the prune decision.
|
||||
func isProtectedKind(group, kind string) bool {
|
||||
if group == "" && kind == "PersistentVolumeClaim" {
|
||||
return true
|
||||
}
|
||||
if kind == "KMSSecret" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// managed is the prune predicate: an object is a prune candidate only if it
|
||||
// carries THIS instance's tracking label AND is not a protected data anchor.
|
||||
func (r *reconciler) managed(res *cache.Resource) bool {
|
||||
ri, ok := res.Info.(*engineResInfo)
|
||||
if !ok || !ri.tracked {
|
||||
return false
|
||||
}
|
||||
k := res.ResourceKey()
|
||||
return !isProtectedKind(k.Group, k.Kind)
|
||||
}
|
||||
|
||||
// reconcile syncs `target` → cluster at `revision`. With prune, a live object
|
||||
// carrying THIS instance's tracking label but absent from `target` is deleted —
|
||||
// UNLESS it is a protected data anchor (PVC/KMSSecret) or the PruneFuse trips.
|
||||
// An UNTRACKED object is never touched.
|
||||
//
|
||||
// Prune safety (RED HIGH-1), all enforced here:
|
||||
// - refuse an EMPTY desired set (a silent target=[] would sweep everything);
|
||||
// - pre-flight DRY-RUN sizes the prune set before any deletion;
|
||||
// - the PruneFuse caps the prune set by count and by ratio;
|
||||
// - protected data anchors (PVC/KMSSecret) are excluded from prune entirely.
|
||||
func (r *reconciler) reconcile(ctx context.Context, target []*unstructured.Unstructured, revision, defaultNS string, prune bool, fuse PruneFuse) ([]synccommon.ResourceSyncResult, error) {
|
||||
// (i) Never reconcile nothing — an empty render must not delete the fleet.
|
||||
if len(target) == 0 {
|
||||
return nil, fmt.Errorf("prune fuse: refusing to reconcile an empty desired set (render produced 0 objects)")
|
||||
}
|
||||
r.stamp(target)
|
||||
|
||||
if prune {
|
||||
// (ii)/(iii) Size the prune set with a dry-run BEFORE any deletion, then
|
||||
// apply the fuse. A partial render that would prune most of the fleet is
|
||||
// refused here, before a single object is removed.
|
||||
dry, err := r.engine.Sync(ctx, target, r.managed, revision, defaultNS,
|
||||
enginesync.WithOperationSettings(true /*dryRun*/, true /*prune*/, false, false),
|
||||
enginesync.WithLogr(r.log))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prune fuse dry-run: %w", err)
|
||||
}
|
||||
pruneN, managedN := 0, 0
|
||||
for _, rr := range dry {
|
||||
managedN++
|
||||
if rr.Status == synccommon.ResultCodePruned {
|
||||
pruneN++
|
||||
}
|
||||
}
|
||||
if fuse.MaxDeletions > 0 && pruneN > fuse.MaxDeletions {
|
||||
return nil, fmt.Errorf("prune fuse tripped: reconcile would prune %d object(s) (> max %d); refusing — fix the git source or raise DEPLOY_ENGINE_PRUNE_MAX", pruneN, fuse.MaxDeletions)
|
||||
}
|
||||
if fuse.MaxRatio > 0 && managedN > 0 && float64(pruneN)/float64(managedN) > fuse.MaxRatio {
|
||||
return nil, fmt.Errorf("prune fuse tripped: reconcile would prune %d/%d managed (> %.0f%%); refusing", pruneN, managedN, fuse.MaxRatio*100)
|
||||
}
|
||||
}
|
||||
|
||||
return r.engine.Sync(ctx, target, r.managed, revision, defaultNS,
|
||||
enginesync.WithPrune(prune),
|
||||
enginesync.WithPruneConfirmed(prune), // prune only after the fuse above confirms it
|
||||
enginesync.WithServerSideApply(true),
|
||||
enginesync.WithServerSideApplyManager(engineFieldManager),
|
||||
enginesync.WithLogr(r.log),
|
||||
)
|
||||
}
|
||||
|
||||
// resourceHealth assesses one live object with the engine's built-in per-GVK
|
||||
// checks — the SAME health library the ArgoCD dashboard uses.
|
||||
func engineResourceHealth(un *unstructured.Unstructured) (*enginehealth.HealthStatus, error) {
|
||||
return enginehealth.GetResourceHealth(un, nil)
|
||||
}
|
||||
|
||||
// gitSource is the desired-state source: a shallow clone of repo@ref, from which
|
||||
// `path` is rendered into the manifest set. It shells the `git` CLI — the same
|
||||
// mechanism clients/git and the operator use — so there is ONE git strategy and
|
||||
// no vendored transport. The revision is the cloned HEAD sha.
|
||||
type gitSource struct {
|
||||
repo string // https URL or local path
|
||||
ref string // branch/tag/sha
|
||||
path string // repo-relative dir of CR manifests
|
||||
}
|
||||
|
||||
// render shallow-clones and parses the source into (objects, revision). The
|
||||
// clone dir is removed before return.
|
||||
func (g gitSource) render(ctx context.Context) ([]*unstructured.Unstructured, string, error) {
|
||||
dir, err := os.MkdirTemp("", "deploy-src-")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("workdir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
clone := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--single-branch", "--branch", g.ref, g.repo, dir)
|
||||
clone.Env = hardenedGitEnv()
|
||||
if out, err := clone.CombinedOutput(); err != nil {
|
||||
return nil, "", fmt.Errorf("git clone: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
rev := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--short", "HEAD")
|
||||
rev.Env = hardenedGitEnv()
|
||||
revBytes, err := rev.Output()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("git rev-parse: %w", err)
|
||||
}
|
||||
revision := strings.TrimSpace(string(revBytes))
|
||||
|
||||
objs, err := parseManifestDir(filepath.Join(dir, g.path))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return objs, revision, nil
|
||||
}
|
||||
|
||||
// parseManifestDir walks dir RECURSIVELY and splits every *.yaml/*.yml/*.json
|
||||
// into typed objects, skipping kustomization inputs. Recursive on purpose (RED
|
||||
// HIGH-1 (v)): a non-recursive read silently drops manifests in subdirectories,
|
||||
// which — combined with prune — would delete the objects those nested files
|
||||
// declare. Walking every subdir means the desired set is complete, so prune
|
||||
// never mistakes a nested-but-present object for a removed one.
|
||||
func parseManifestDir(dir string) ([]*unstructured.Unstructured, error) {
|
||||
var objs []*unstructured.Unstructured
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if name == "kustomization.yaml" || name == "kustomization.yml" {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if ext != ".yaml" && ext != ".yml" && ext != ".json" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
items, err := kube.SplitYAML(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
objs = append(objs, items...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walk manifest dir %s: %w", dir, err)
|
||||
}
|
||||
return objs, nil
|
||||
}
|
||||
|
||||
// hardenedGitEnv is the minimal, credential-free git environment: no interactive
|
||||
// prompt, no ambient user/system config, no inherited secrets.
|
||||
func hardenedGitEnv() []string {
|
||||
return []string{
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null",
|
||||
"GIT_CONFIG_SYSTEM=/dev/null",
|
||||
"HOME=" + os.TempDir(),
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// engine_mount.go wires the embedded gitops-engine (engine.go) into the
|
||||
// /v1/deploy surface: a SuperAdmin-gated, one-shot reconcile endpoint that
|
||||
// renders the configured git source and syncs it → cluster. This is the write
|
||||
// half of /v1/deploy that replaces the retired universe-crs Application — the
|
||||
// operator still renders each App CR into workloads (the domain half).
|
||||
//
|
||||
// Fail-safe: the whole path is gated by DEPLOY_ENGINE_ENABLED (default off), so
|
||||
// the first deploy of this binary is inert and the engine is turned on
|
||||
// deliberately after the shadow proof.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
|
||||
synccommon "github.com/hanzoai/deploy/gitops-engine/pkg/sync/common"
|
||||
)
|
||||
|
||||
// Engine config — all optional; defaults target the live universe manifest repo
|
||||
// (the exact source universe-crs syncs). Configure only what must vary.
|
||||
func engineEnabled() bool { return os.Getenv("DEPLOY_ENGINE_ENABLED") == "true" }
|
||||
func enginePrune() bool { return os.Getenv("DEPLOY_ENGINE_PRUNE") == "true" }
|
||||
|
||||
// pruneFuse bounds a single reconcile's deletions (RED HIGH-1). Conservative
|
||||
// defaults: at most 10 objects OR 20% of the managed set, whichever is smaller,
|
||||
// unless explicitly raised. A silent empty/partial render trips the fuse instead
|
||||
// of sweeping the fleet.
|
||||
func pruneFuse() PruneFuse {
|
||||
return PruneFuse{
|
||||
MaxDeletions: envInt("DEPLOY_ENGINE_PRUNE_MAX", 10),
|
||||
MaxRatio: envFloat("DEPLOY_ENGINE_PRUNE_MAX_RATIO", 0.20),
|
||||
}
|
||||
}
|
||||
|
||||
func envInt(k string, d int) int {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func envFloat(k string, d float64) float64 {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
func engineRepo() string { return envOr("DEPLOY_ENGINE_REPO", "https://github.com/hanzoai/universe") }
|
||||
func engineRef() string { return envOr("DEPLOY_ENGINE_REF", "main") }
|
||||
func enginePath() string { return envOr("DEPLOY_ENGINE_PATH", "infra/k8s/operator/crs") }
|
||||
func engineInstance() string { return envOr("DEPLOY_ENGINE_INSTANCE", "universe") }
|
||||
func engineDefaultNS() string { return envOr("DEPLOY_ENGINE_NAMESPACE", "hanzo") }
|
||||
|
||||
func envOr(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// registerEngineRoutes adds the engine (write) routes alongside the existing
|
||||
// read/visualize routes. Called from routes() in deploy.go.
|
||||
func registerEngineRoutes(app *zip.App, s *cloud.Service[state]) {
|
||||
app.Post("/v1/deploy/reconcile", guard(s, cloud.Handle(s, engineReconcile)))
|
||||
}
|
||||
|
||||
// engineReconcile is POST /v1/deploy/reconcile — a SuperAdmin-gated, one-shot
|
||||
// engine sync: render the configured git source and reconcile it → cluster via
|
||||
// the embedded gitops-engine (three-way server-side apply, scoped prune,
|
||||
// per-resource health). The write half that replaces universe-crs.
|
||||
func engineReconcile(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if !engineEnabled() {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "deploy engine disabled (set DEPLOY_ENGINE_ENABLED=true)")
|
||||
}
|
||||
cfg, err := engineRestConfig()
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "engine: kube config: %v", err)
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
rec := newReconciler(cfg, []string{engineDefaultNS()}, engineInstance(), logr.Discard())
|
||||
stop, err := rec.run()
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "engine start: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
// Let the informer cache warm before the first sync so live state is known.
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
objs, revision, err := gitSource{repo: engineRepo(), ref: engineRef(), path: enginePath()}.render(ctx)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "engine: render git source: %v", err)
|
||||
}
|
||||
|
||||
results, err := rec.reconcile(ctx, objs, revision, engineDefaultNS(), enginePrune(), pruneFuse())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "engine: sync: %v", err)
|
||||
}
|
||||
|
||||
synced, pruned, failed := 0, 0, 0
|
||||
items := make([]map[string]any, 0, len(results))
|
||||
for _, rr := range results {
|
||||
switch rr.Status {
|
||||
case synccommon.ResultCodeSynced:
|
||||
synced++
|
||||
case synccommon.ResultCodePruned:
|
||||
pruned++
|
||||
case synccommon.ResultCodeSyncFailed:
|
||||
failed++
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"resource": rr.ResourceKey.String(),
|
||||
"status": string(rr.Status),
|
||||
"message": rr.Message,
|
||||
})
|
||||
}
|
||||
s.Log.Info("deploy engine reconcile", "revision", revision, "objects", len(objs),
|
||||
"synced", synced, "pruned", pruned, "failed", failed, "prune", enginePrune())
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"revision": revision,
|
||||
"source": map[string]any{"repo": engineRepo(), "ref": engineRef(), "path": enginePath()},
|
||||
"instance": engineInstance(),
|
||||
"prune": enginePrune(),
|
||||
"declared": len(objs),
|
||||
"synced": synced,
|
||||
"pruned": pruned,
|
||||
"failed": failed,
|
||||
"results": items,
|
||||
})
|
||||
}
|
||||
|
||||
// engineRestConfig builds a rest.Config from the in-cluster service account,
|
||||
// falling back to KUBECONFIG for local/dev — the SAME construction as
|
||||
// newClients() (deploy.go), so the engine talks to the same cluster.
|
||||
func engineRestConfig() (*rest.Config, error) {
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||
clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{})
|
||||
cfg, err = cc.ClientConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cfg.UserAgent = userAgent
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// login.go — the sign-in round trip for the deploy plane at cd.hanzo.ai.
|
||||
//
|
||||
// THE PROBLEM. Every /v1/deploy route is SuperAdmin-gated on c.IsAdmin(), which
|
||||
// SanitizeIdentity mints ONLY from a validated IAM principal whose org IS the
|
||||
// reserved admin org. The dashboard SPA is served at cd.hanzo.ai/ and calls this
|
||||
// plane same-origin — but the IAM session cookie is minted host-only on hanzo.id,
|
||||
// so a session established at hanzo.id or admin.hanzo.ai is never presented to
|
||||
// cd.hanzo.ai. With no sign-in of its own the whole surface 403s and there is no
|
||||
// way in. This file IS the way in.
|
||||
//
|
||||
// GET /v1/deploy/login — start: redirect into IAM's authorize endpoint
|
||||
// GET /v1/deploy/callback — finish: exchange the code, mint the session cookie
|
||||
// GET /v1/deploy/logout — clear the session cookie
|
||||
//
|
||||
// WHAT IT MINTS — NOT A SECOND SESSION MECHANISM. The callback stores the IAM
|
||||
// access-token JWT in the `__Host-hanzo_iam_token` cookie: the FIRST name in
|
||||
// cloud's cookieTokenNames, which SanitizeIdentity already reads, independently
|
||||
// verifies (signature/issuer/audience/expiry against the IAM JWKS) and turns into
|
||||
// the same principal a Bearer would. So this adds exactly one thing — a way to PUT
|
||||
// the token in the browser for this host. The gate, the validation, and the
|
||||
// SuperAdmin predicate are untouched; a forged cookie is still just an invalid JWT,
|
||||
// and a forged X-User-IsAdmin header is still stripped on ingress.
|
||||
//
|
||||
// MINT ONLY WHAT THIS DEPLOYMENT WILL ACCEPT. The callback runs the exchanged token
|
||||
// through cloud's OWN validator (cloud.NewTokenValidator — the same JWKS, issuer set
|
||||
// and audience allowlist the boundary uses) BEFORE writing the cookie, and makes the
|
||||
// admin-org decision on those VERIFIED claims. This is not defence in depth against
|
||||
// IAM; it is the thing that makes a misconfiguration fail FAST and LOUD. The audience
|
||||
// allowlist is env-overridable (jwtAudiencesFromEnv REPLACES the baked default), so a
|
||||
// deployment whose CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES omits this
|
||||
// console's client_id would otherwise mint a cookie the boundary refuses on the very
|
||||
// next request — 403 → document-bounce to sign-in → IAM session still live → instant
|
||||
// code → mint → 403, looping until the browser gives up. Validating here turns that
|
||||
// infinite loop into one clear error naming the real reason.
|
||||
//
|
||||
// PUBLIC CLIENT, PKCE. The deploy plane holds no client secret: it drives IAM's
|
||||
// authorization-code flow with PKCE S256 (RFC 7636), which IAM accepts with an
|
||||
// empty client_secret when the code carries a challenge. A secret is still sent
|
||||
// when one is configured, for a deployment that registers a confidential client.
|
||||
//
|
||||
// CSRF. The `state` is a fresh 256-bit nonce echoed into a short-lived, HttpOnly,
|
||||
// Secure, SameSite=Lax cookie alongside the PKCE verifier and the return path. The
|
||||
// callback accepts a code ONLY when the returned state equals the cookie's nonce
|
||||
// (constant time), so a login-CSRF — an attacker completing THEIR authorization in
|
||||
// the victim's browser — is refused. The cookie is the only store, so the flow
|
||||
// survives any replica handling the callback.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
const (
|
||||
// loginPath / callbackPath / logoutPath are the three routes of the round trip.
|
||||
// /v1/deploy/<resource>, like the rest of the plane — never an /api/ prefix.
|
||||
loginPath = dashPrefix + "/login"
|
||||
callbackPath = dashPrefix + "/callback"
|
||||
logoutPath = dashPrefix + "/logout"
|
||||
|
||||
// sessionCookie is cloud's EXISTING session cookie name — cookieTokenNames[0]
|
||||
// in middleware_identity.go. Writing it here is what makes SanitizeIdentity
|
||||
// resolve a principal on the next request. Do not invent a second name.
|
||||
//
|
||||
// The __Host- prefix is a browser-enforced invariant, not decoration: a cookie
|
||||
// so named may only be set Secure, Path=/ and with NO Domain, so a sibling
|
||||
// *.hanzo.ai host cannot set a Domain=.hanzo.ai cookie of the same name to
|
||||
// shadow this console's session.
|
||||
sessionCookie = "__Host-hanzo_iam_token"
|
||||
|
||||
// flowCookie carries the one in-flight OAuth round trip (state nonce, PKCE
|
||||
// verifier, return path). It is deleted the moment the callback reads it, and
|
||||
// carries the same __Host- guarantee — a shadowed flow cookie would be a way
|
||||
// to feed this console someone else's state nonce.
|
||||
flowCookie = "__Host-hanzo_deploy_oauth"
|
||||
|
||||
// flowTTL bounds how long an unfinished sign-in stays resumable.
|
||||
flowTTL = 10 * time.Minute
|
||||
|
||||
// sessionMaxTTL caps the session cookie regardless of what the token claims.
|
||||
// An `exp` far in the future must not become a decade-long cookie; the token is
|
||||
// re-validated on every request either way, so a shorter cookie costs only a
|
||||
// re-sign-in.
|
||||
sessionMaxTTL = 24 * time.Hour
|
||||
|
||||
// defaultClientID is the IAM application whose ORGANIZATION is the admin org,
|
||||
// so a sign-in through it resolves users in `admin` — the only org whose
|
||||
// members are SuperAdmins. hanzo-cloud is deliberately NOT used: it is owned by
|
||||
// admin but its organization is `hanzo`, so it looks admin-org users up in the
|
||||
// wrong org and never finds them.
|
||||
defaultClientID = "admin-console"
|
||||
)
|
||||
|
||||
// oauth is the sign-in configuration: where IAM is, who we are to it, and which
|
||||
// org grants SuperAdmin. Resolved once at build time.
|
||||
type oauth struct {
|
||||
issuer string // IAM origin, e.g. https://hanzo.id
|
||||
clientID string // IAM application client_id (organization == adminOrg)
|
||||
clientSecret string // optional; empty ⟹ public client on PKCE alone
|
||||
adminOrg string // the reserved org whose members are SuperAdmins
|
||||
publicURL string // REQUIRED public origin of this console; "" disables sign-in
|
||||
http *http.Client
|
||||
|
||||
// verify is cloud's own token validator (NewTokenValidator(issuer).Validate).
|
||||
// It is a seam so a test can drive the round trip without a live JWKS; in the
|
||||
// binary there is exactly one implementation, and a nil verify fails closed.
|
||||
verify func(raw string) (cloud.VerifiedIdentity, error)
|
||||
}
|
||||
|
||||
// newOAuth resolves the sign-in configuration from deps + env. The issuer comes
|
||||
// from the SAME value the identity boundary validates tokens against
|
||||
// (deps.IAMIssuer), and the verifier is built from that issuer, so a token this
|
||||
// flow accepts is by construction a token cloud accepts.
|
||||
func newOAuth(deps cloud.Deps) oauth {
|
||||
issuer := strings.TrimRight(firstNonEmpty(deps.IAMIssuer, os.Getenv("IAM_ENDPOINT"), "https://hanzo.id"), "/")
|
||||
return oauth{
|
||||
issuer: issuer,
|
||||
clientID: firstNonEmpty(os.Getenv("DEPLOY_IAM_CLIENT_ID"), defaultClientID),
|
||||
clientSecret: os.Getenv("DEPLOY_IAM_CLIENT_SECRET"),
|
||||
adminOrg: firstNonEmpty(os.Getenv("IAM_ADMIN_ORG"), "admin"),
|
||||
publicURL: strings.TrimRight(firstNonEmpty(os.Getenv("DEPLOY_PUBLIC_URL"), os.Getenv("PUBLIC_ORIGIN")), "/"),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
verify: cloud.NewTokenValidator(issuer).Validate,
|
||||
}
|
||||
}
|
||||
|
||||
// oauthBase is the canonical IAM OAuth base: ${issuer}/v1/iam. IAM mounts
|
||||
// authorize/token/userinfo under /v1/iam — never at the root, never under /api/.
|
||||
func (o oauth) oauthBase() string { return o.issuer + "/v1/iam" }
|
||||
|
||||
// redirectURI is the OAuth redirect_uri, built from CONFIGURATION ONLY. It must be
|
||||
// the byte-identical string in login (authorize) and callback (token exchange) —
|
||||
// IAM compares them — and it must match a URI registered on the application.
|
||||
//
|
||||
// It is deliberately NOT derived from the request. Host and X-Forwarded-Proto are
|
||||
// caller-controlled: behind the gateway Host is the internal cluster host (which
|
||||
// IAM's allowlist rejects outright), and off-gateway a caller can set either freely.
|
||||
// Deriving an OAuth redirect from attacker-controlled input is only ever saved by
|
||||
// the registry's exact-match check — a second lock covering for a broken first one.
|
||||
// So the public origin is required, and with none configured sign-in fails CLOSED
|
||||
// with an error naming the knob, rather than guessing an origin from a header.
|
||||
func (o oauth) redirectURI() (string, error) {
|
||||
if o.publicURL == "" {
|
||||
return "", fmt.Errorf("sign-in is not configured: set DEPLOY_PUBLIC_URL (or PUBLIC_ORIGIN) " +
|
||||
"to this console's public origin, e.g. https://cd.hanzo.ai")
|
||||
}
|
||||
return o.publicURL + callbackPath, nil
|
||||
}
|
||||
|
||||
// ── the round trip ───────────────────────────────────────────────────────────
|
||||
|
||||
// flow is the one in-flight sign-in, carried in flowCookie for the duration of the
|
||||
// external hop. Nonce is echoed as `state`; Verifier is the PKCE secret that is
|
||||
// never sent to the browser's address bar; Return is the already-validated
|
||||
// same-host path to land on.
|
||||
type flow struct {
|
||||
Nonce string `json:"n"`
|
||||
Verifier string `json:"v"`
|
||||
Return string `json:"r"`
|
||||
}
|
||||
|
||||
// login starts the round trip: mint a nonce + PKCE verifier, remember them in the
|
||||
// flow cookie, and send the browser to IAM's authorize endpoint.
|
||||
func login(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
redirect, err := s.State.oauth.redirectURI()
|
||||
if err != nil {
|
||||
s.Log.Error("deploy sign-in unavailable", "err", err)
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "%v", err)
|
||||
}
|
||||
nonce, err := randomToken()
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
|
||||
}
|
||||
verifier, err := randomToken()
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
|
||||
}
|
||||
f := flow{Nonce: nonce, Verifier: verifier, Return: safeReturn(c.Query("returnTo"))}
|
||||
blob, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "login: %v", err)
|
||||
}
|
||||
setCookie(c, flowCookie, base64.RawURLEncoding.EncodeToString(blob), int(flowTTL.Seconds()))
|
||||
|
||||
o := s.State.oauth
|
||||
q := url.Values{
|
||||
"client_id": {o.clientID},
|
||||
"redirect_uri": {redirect},
|
||||
"response_type": {"code"},
|
||||
"scope": {"openid profile email"},
|
||||
"state": {nonce},
|
||||
"code_challenge": {pkceChallenge(verifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
}
|
||||
return c.Redirect(http.StatusFound, o.oauthBase()+"/oauth/authorize?"+q.Encode())
|
||||
}
|
||||
|
||||
// callback finishes the round trip: verify the state against the flow cookie,
|
||||
// exchange the code (PKCE), REFUSE a principal that is not in the admin org, then
|
||||
// write the session cookie and land on the return path.
|
||||
//
|
||||
// FAIL CLOSED, TWICE. The admin-org check here is not the authorization decision —
|
||||
// SanitizeIdentity re-derives it from the verified JWT on every subsequent request,
|
||||
// and guard() gates on that. It is here so a non-SuperAdmin is told plainly that
|
||||
// they lack the role instead of being handed a session that silently 403s
|
||||
// everything, and so no cookie is ever minted for a principal the plane will refuse.
|
||||
func callback(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
redirect, cfgErr := s.State.oauth.redirectURI()
|
||||
raw := c.Fiber().Cookies(flowCookie)
|
||||
clearCookie(c, flowCookie) // single use: consumed whether or not it validates
|
||||
if cfgErr != nil {
|
||||
s.Log.Error("deploy sign-in unavailable", "err", cfgErr)
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "%v", cfgErr)
|
||||
}
|
||||
f, err := decodeFlow(raw)
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("no sign-in is in progress; start at " + loginPath)
|
||||
}
|
||||
// CSRF: the code is only accepted for the round trip THIS browser started.
|
||||
if subtle.ConstantTimeCompare([]byte(f.Nonce), []byte(c.Query("state"))) != 1 {
|
||||
return zip.ErrBadRequest("state mismatch; start again at " + loginPath)
|
||||
}
|
||||
if e := c.Query("error"); e != "" {
|
||||
s.Log.Warn("deploy sign-in refused by IAM", "error", e)
|
||||
return zip.ErrUnauthorized("sign-in was not completed")
|
||||
}
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
return zip.ErrBadRequest("missing authorization code")
|
||||
}
|
||||
|
||||
access, err := s.State.oauth.exchange(c.Context(), code, redirect, f.Verifier)
|
||||
if err != nil {
|
||||
s.Log.Error("deploy sign-in code exchange failed", "err", err)
|
||||
return zip.ErrUnauthorized("sign-in could not be completed")
|
||||
}
|
||||
|
||||
// Verify the token the way THIS deployment's identity boundary will, before
|
||||
// handing it to a browser. A token that fails here would be refused on the very
|
||||
// next request, so minting a cookie for it would produce a sign-in loop rather
|
||||
// than a session — fail now, with the real reason.
|
||||
if s.State.oauth.verify == nil {
|
||||
s.Log.Error("deploy sign-in has no token validator configured")
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "sign-in is not configured: no identity validator")
|
||||
}
|
||||
id, err := s.State.oauth.verify(access)
|
||||
if err != nil {
|
||||
s.Log.Error("deploy sign-in token failed validation", "err", err,
|
||||
"issuer", s.State.oauth.issuer, "client", s.State.oauth.clientID)
|
||||
return zip.ErrUnauthorized("the sign-in token was refused by this deployment's identity boundary (" +
|
||||
err.Error() + "); check that " + s.State.oauth.clientID +
|
||||
" is in the JWT audience allowlist (CLOUD_JWT_AUDIENCES / GATEWAY_ALLOWED_AUDIENCES) " +
|
||||
"and that the issuer matches")
|
||||
}
|
||||
// SuperAdmin ⟺ the VERIFIED owner claim IS the reserved admin org. Not the
|
||||
// `isAdmin` bit, which only says "admin of my own org" — conflating the two
|
||||
// would be a privilege escalation.
|
||||
if id.Owner != s.State.oauth.adminOrg {
|
||||
s.Log.Warn("deploy sign-in refused: not a SuperAdmin", "user", id.User, "org", id.Owner)
|
||||
return zip.ErrForbidden("SuperAdmin required: this console is limited to members of the " +
|
||||
s.State.oauth.adminOrg + " organization")
|
||||
}
|
||||
|
||||
maxAge := sessionMaxAge(id.Expiry)
|
||||
if maxAge <= 0 {
|
||||
return zip.ErrUnauthorized("the sign-in token has already expired")
|
||||
}
|
||||
setCookie(c, sessionCookie, access, maxAge)
|
||||
s.Log.Info("deploy sign-in", "user", id.User, "org", id.Owner, "ttl", maxAge)
|
||||
return c.Redirect(http.StatusFound, f.Return)
|
||||
}
|
||||
|
||||
// logout clears the session cookie for this host. IAM's own session is untouched —
|
||||
// this ends the console session only.
|
||||
//
|
||||
// It is a POST because it CHANGES STATE. As a GET it was reachable by a cross-site
|
||||
// top-level navigation (an <img> or a link on any page), which a SameSite=Lax cookie
|
||||
// still rides, so any site could sign a SuperAdmin out at will. A nuisance rather
|
||||
// than a compromise, but a state-changing GET is a bug regardless; POST is not
|
||||
// carried cross-site by a Lax cookie, so the class is closed.
|
||||
func logout(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
clearCookie(c, sessionCookie)
|
||||
return c.JSON(http.StatusOK, map[string]any{"loggedIn": false, "loginUrl": loginPath})
|
||||
}
|
||||
|
||||
// exchange redeems the authorization code at IAM's token endpoint with the PKCE
|
||||
// verifier. The client secret is sent only when one is configured (IAM accepts an
|
||||
// empty secret for a code that carries a challenge).
|
||||
func (o oauth) exchange(ctx context.Context, code, redirect, verifier string) (string, error) {
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirect},
|
||||
"client_id": {o.clientID},
|
||||
"code_verifier": {verifier},
|
||||
}
|
||||
if o.clientSecret != "" {
|
||||
form.Set("client_secret", o.clientSecret)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.oauthBase()+"/oauth/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := o.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token endpoint: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Bound the read: a token response is small, and this body is attacker-
|
||||
// influenced only insofar as IAM is reachable — cap it regardless.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token endpoint status %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", fmt.Errorf("token response: %w", err)
|
||||
}
|
||||
if out.Error != "" {
|
||||
return "", fmt.Errorf("token endpoint: %s", out.Error)
|
||||
}
|
||||
if out.AccessToken == "" {
|
||||
return "", fmt.Errorf("token endpoint returned no access_token")
|
||||
}
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||
|
||||
// safeReturn constrains the post-sign-in landing spot to a path on THIS host: it
|
||||
// must be absolute-rooted, must not be protocol-relative ("//evil" or "/\evil",
|
||||
// which browsers resolve as another origin), and must carry no scheme or host.
|
||||
// Anything else collapses to "/". This is the open-redirect guard.
|
||||
func safeReturn(raw string) string {
|
||||
if raw == "" || raw[0] != '/' {
|
||||
return "/"
|
||||
}
|
||||
if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') {
|
||||
return "/"
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "" || u.Host != "" {
|
||||
return "/"
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
// wantsDocument reports whether a refused request is a BROWSER NAVIGATION, which
|
||||
// should be bounced to the sign-in page rather than handed a 403 the user cannot
|
||||
// act on. Everything else — every XHR, every API client, every request that does
|
||||
// not positively identify as a document GET — keeps the 403, so the API contract
|
||||
// is unchanged.
|
||||
//
|
||||
// It is deliberately conservative and ordered by trustworthiness: Sec-Fetch-Dest /
|
||||
// Sec-Fetch-Mode are set by the browser and cannot be forged from page JS, so when
|
||||
// present they DECIDE. Only when both are absent does it fall back to Accept.
|
||||
// A non-GET is never redirected: bouncing a POST would silently drop a mutation.
|
||||
func wantsDocument(method, dest, mode, accept, requestedWith string) bool {
|
||||
if method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
if dest != "" {
|
||||
return dest == "document"
|
||||
}
|
||||
if mode != "" {
|
||||
return mode == "navigate"
|
||||
}
|
||||
if strings.EqualFold(requestedWith, "XMLHttpRequest") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(accept, "application/json") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(accept, "text/html")
|
||||
}
|
||||
|
||||
// pkceChallenge is the RFC 7636 S256 challenge: base64url(sha256(verifier)),
|
||||
// unpadded — byte-identical to IAM's own pkceChallenge.
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// randomToken mints a 256-bit URL-safe secret (the state nonce and the PKCE
|
||||
// verifier). A failure of the system CSPRNG fails the sign-in — never a weaker one.
|
||||
func randomToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("entropy unavailable: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// decodeFlow parses the flow cookie. A missing, malformed, or field-empty cookie is
|
||||
// an error — the callback then has nothing to compare `state` against and refuses.
|
||||
func decodeFlow(raw string) (flow, error) {
|
||||
var f flow
|
||||
if raw == "" {
|
||||
return f, fmt.Errorf("no flow cookie")
|
||||
}
|
||||
blob, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("flow cookie: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(blob, &f); err != nil {
|
||||
return f, fmt.Errorf("flow cookie: %w", err)
|
||||
}
|
||||
if f.Nonce == "" || f.Verifier == "" {
|
||||
return f, fmt.Errorf("flow cookie is incomplete")
|
||||
}
|
||||
// Re-validate on the way out: the return path is re-checked against the same
|
||||
// open-redirect rule that admitted it, so a tampered cookie cannot bounce the
|
||||
// browser off-host.
|
||||
f.Return = safeReturn(f.Return)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// sessionMaxAge is the session cookie's lifetime, derived from the token's VERIFIED
|
||||
// expiry so the cookie dies with the credential it carries. It is bounded at both
|
||||
// ends and never guesses:
|
||||
//
|
||||
// - already expired (or no expiry proven) → 0, and the caller refuses the sign-in.
|
||||
// There is no "fall back to 8 hours" — a fallback for an expired token mints a
|
||||
// cookie that cannot work, which is precisely the mint-then-refuse loop this
|
||||
// whole path exists to prevent.
|
||||
// - absurdly far future → clamped to sessionMaxTTL, so a mis-issued exp cannot
|
||||
// become a decade-long cookie.
|
||||
func sessionMaxAge(expiry time.Time) int {
|
||||
if expiry.IsZero() {
|
||||
return 0
|
||||
}
|
||||
remaining := time.Until(expiry)
|
||||
if remaining <= 0 {
|
||||
return 0
|
||||
}
|
||||
if remaining > sessionMaxTTL {
|
||||
remaining = sessionMaxTTL
|
||||
}
|
||||
return int(remaining.Seconds())
|
||||
}
|
||||
|
||||
// ── cookies ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// setCookie writes a host-only, HttpOnly, Secure, SameSite=Lax cookie.
|
||||
//
|
||||
// HttpOnly: page JS never touches the session token. Secure: it never rides plain
|
||||
// HTTP. Lax (not Strict): the sign-in lands here via a top-level redirect FROM
|
||||
// hanzo.id, and Strict would withhold the cookie on that first cross-site
|
||||
// navigation, so the user would arrive still signed out. Lax is the correct
|
||||
// setting for an OAuth round trip and still withholds the cookie from cross-site
|
||||
// subrequests. No Domain attribute: the cookie stays host-only to this console and
|
||||
// is never broadcast to sibling *.hanzo.ai hosts.
|
||||
func setCookie(c *zip.Ctx, name, value string, maxAge int) {
|
||||
c.Fiber().Res().Cookie(&fiber.Cookie{
|
||||
Name: name, Value: value, Path: "/",
|
||||
HTTPOnly: true, Secure: true, SameSite: fiber.CookieSameSiteLaxMode,
|
||||
MaxAge: maxAge,
|
||||
})
|
||||
}
|
||||
|
||||
func clearCookie(c *zip.Ctx, name string) { setCookie(c, name, "", -1) }
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ── open redirect ────────────────────────────────────────────────────────────
|
||||
|
||||
// TestSafeReturn is the open-redirect guard: a return path may only be a path on
|
||||
// THIS host. Every off-host shape collapses to "/".
|
||||
func TestSafeReturn(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
// legitimate same-host paths survive, query included.
|
||||
{"/", "/"},
|
||||
{"/applications", "/applications"},
|
||||
{"/applications?name=cloud&view=tree", "/applications?name=cloud&view=tree"},
|
||||
// empty / relative → default.
|
||||
{"", "/"},
|
||||
{"applications", "/"},
|
||||
// protocol-relative: the browser resolves these as ANOTHER ORIGIN.
|
||||
{"//evil.example", "/"},
|
||||
{"//evil.example/path", "/"},
|
||||
{`/\evil.example`, "/"},
|
||||
{`/\/evil.example`, "/"},
|
||||
// absolute URLs, any scheme.
|
||||
{"https://evil.example/x", "/"},
|
||||
{"http://evil.example", "/"},
|
||||
{"javascript:alert(1)", "/"},
|
||||
{"data:text/html,x", "/"},
|
||||
// host-relative with userinfo tricks.
|
||||
{"https://cd.hanzo.ai@evil.example", "/"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := safeReturn(c.in); got != c.want {
|
||||
t.Errorf("safeReturn(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── content negotiation: redirect vs 403 ─────────────────────────────────────
|
||||
|
||||
// TestWantsDocument pins the ONE rule that decides whether a refusal is a
|
||||
// sign-in redirect or a 403. It must answer "no" for everything that is not
|
||||
// positively a browser document GET — the API contract depends on it.
|
||||
func TestWantsDocument(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method, dest, mode, accept, requested string
|
||||
want bool
|
||||
}{
|
||||
// Browser navigation — the only "yes" cases.
|
||||
{"navigation (Sec-Fetch-Dest)", "GET", "document", "navigate", "text/html,*/*", "", true},
|
||||
{"navigation (mode only)", "GET", "", "navigate", "text/html", "", true},
|
||||
{"legacy browser, html accept", "GET", "", "", "text/html,application/xhtml+xml", "", true},
|
||||
|
||||
// Browser subresource/XHR — Sec-Fetch-* decides, and it says no.
|
||||
{"fetch from page JS", "GET", "empty", "cors", "application/json", "", false},
|
||||
{"fetch that lies via Accept", "GET", "empty", "cors", "text/html", "", false},
|
||||
{"same-origin xhr", "GET", "empty", "same-origin", "*/*", "", false},
|
||||
|
||||
// Non-browser API clients.
|
||||
{"curl (no headers)", "GET", "", "", "", "", false},
|
||||
{"json client", "GET", "", "", "application/json", "", false},
|
||||
{"wildcard accept", "GET", "", "", "*/*", "", false},
|
||||
{"legacy xhr header", "GET", "", "", "text/html", "XMLHttpRequest", false},
|
||||
{"html+json accept prefers api", "GET", "", "", "text/html,application/json", "", false},
|
||||
|
||||
// A mutation is NEVER redirected — bouncing a POST silently drops it.
|
||||
{"POST navigation", "POST", "document", "navigate", "text/html", "", false},
|
||||
{"PUT navigation", "PUT", "document", "navigate", "text/html", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := wantsDocument(c.method, c.dest, c.mode, c.accept, c.requested); got != c.want {
|
||||
t.Errorf("wantsDocument(%q,%q,%q,%q,%q) = %v, want %v",
|
||||
c.method, c.dest, c.mode, c.accept, c.requested, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardRefusesNonAdmin drives the negotiation through the REAL router: a
|
||||
// non-SuperAdmin never reaches a handler — an API call gets 403, a browser
|
||||
// navigation gets bounced to sign-in. Neither is ever served the data.
|
||||
func TestGuardRefusesNonAdmin(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, fakeSvc())
|
||||
|
||||
// API call (no Accept, the shape every API client and the existing e2e sends).
|
||||
resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/applications", nil))
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("API call without admin = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// XHR that asks for HTML must STILL be 403 — Sec-Fetch-Dest decides.
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/applications", nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
if resp := do(t, app, req); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("XHR without admin = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Browser navigation → 302 to the sign-in page, carrying where to come back to.
|
||||
req = httptest.NewRequest("GET", "/v1/deploy/applications?env=main", nil)
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
resp = do(t, app, req)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("navigation without admin = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("Location: %v", err)
|
||||
}
|
||||
if loc.Path != loginPath {
|
||||
t.Errorf("redirect path = %q, want %q", loc.Path, loginPath)
|
||||
}
|
||||
if got := loc.Query().Get("returnTo"); got != "/v1/deploy/applications?env=main" {
|
||||
t.Errorf("returnTo = %q, want the originating path+query", got)
|
||||
}
|
||||
|
||||
// A mutation is refused with 403, never redirected.
|
||||
req = httptest.NewRequest("POST", "/v1/deploy/applications/cloud/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
if resp := do(t, app, req); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("POST without admin = %d, want 403 (never a redirect)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PKCE ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestPKCEChallenge pins the S256 transform against the RFC 7636 Appendix B
|
||||
// vector, so it stays byte-identical to IAM's own pkceChallenge.
|
||||
func TestPKCEChallenge(t *testing.T) {
|
||||
const (
|
||||
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
)
|
||||
if got := pkceChallenge(verifier); got != challenge {
|
||||
t.Errorf("pkceChallenge = %q, want the RFC 7636 vector %q", got, challenge)
|
||||
}
|
||||
// The challenge is not the verifier (the whole point of S256).
|
||||
if pkceChallenge("x") == "x" {
|
||||
t.Error("challenge must never equal the verifier")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRandomTokenIsUnique guards against a constant/predictable state nonce.
|
||||
func TestRandomTokenIsUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
tok, err := randomToken()
|
||||
if err != nil {
|
||||
t.Fatalf("randomToken: %v", err)
|
||||
}
|
||||
if len(tok) < 43 {
|
||||
t.Fatalf("randomToken = %q, want >= 256 bits of entropy", tok)
|
||||
}
|
||||
if seen[tok] {
|
||||
t.Fatal("randomToken repeated a value")
|
||||
}
|
||||
seen[tok] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── login: the authorize hop ─────────────────────────────────────────────────
|
||||
|
||||
// TestLoginRedirectsToIAM asserts the authorize URL is well formed, carries PKCE,
|
||||
// and that the state it publishes is the SAME nonce it stored in the flow cookie.
|
||||
func TestLoginRedirectsToIAM(t *testing.T) {
|
||||
app, _ := signinApp(t, "https://iam.test")
|
||||
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=/applications", nil)
|
||||
req.Host = "cd.hanzo.ai"
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
resp := do(t, app, req)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("login = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("Location: %v", err)
|
||||
}
|
||||
if want := "https://iam.test/v1/iam/oauth/authorize"; loc.Scheme+"://"+loc.Host+loc.Path != want {
|
||||
t.Errorf("authorize endpoint = %q, want %q", loc.Scheme+"://"+loc.Host+loc.Path, want)
|
||||
}
|
||||
q := loc.Query()
|
||||
if q.Get("client_id") != defaultClientID {
|
||||
t.Errorf("client_id = %q, want %q (the app whose organization is the admin org)", q.Get("client_id"), defaultClientID)
|
||||
}
|
||||
if q.Get("response_type") != "code" {
|
||||
t.Errorf("response_type = %q, want code", q.Get("response_type"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "https://cd.hanzo.ai/v1/deploy/callback" {
|
||||
t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("code_challenge_method = %q, want S256", q.Get("code_challenge_method"))
|
||||
}
|
||||
if q.Get("code_challenge") == "" {
|
||||
t.Error("no code_challenge: the flow is not PKCE-protected")
|
||||
}
|
||||
|
||||
// The flow cookie must exist, be HttpOnly, and its nonce must be the state.
|
||||
f, cookie := flowFrom(t, resp)
|
||||
if !cookie.HttpOnly || !cookie.Secure {
|
||||
t.Errorf("flow cookie HttpOnly=%v Secure=%v, want both true", cookie.HttpOnly, cookie.Secure)
|
||||
}
|
||||
if f.Nonce != q.Get("state") {
|
||||
t.Errorf("state %q != flow cookie nonce %q", q.Get("state"), f.Nonce)
|
||||
}
|
||||
if pkceChallenge(f.Verifier) != q.Get("code_challenge") {
|
||||
t.Error("published code_challenge does not derive from the stored verifier")
|
||||
}
|
||||
if f.Return != "/applications" {
|
||||
t.Errorf("stored return = %q, want /applications", f.Return)
|
||||
}
|
||||
// The verifier must never be published to the address bar.
|
||||
if strings.Contains(resp.Header.Get("Location"), f.Verifier) {
|
||||
t.Error("PKCE verifier leaked into the authorize URL")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginRejectsOffHostReturnTo: an attacker-supplied returnTo cannot make the
|
||||
// completed sign-in land off-host.
|
||||
func TestLoginRejectsOffHostReturnTo(t *testing.T) {
|
||||
app, _ := signinApp(t, "https://iam.test")
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=https://evil.example/steal", nil)
|
||||
req.Host = "cd.hanzo.ai"
|
||||
resp := do(t, app, req)
|
||||
f, _ := flowFrom(t, resp)
|
||||
if f.Return != "/" {
|
||||
t.Errorf("stored return = %q, want / (off-host returnTo must be dropped)", f.Return)
|
||||
}
|
||||
}
|
||||
|
||||
// ── callback: the exchange ───────────────────────────────────────────────────
|
||||
|
||||
// TestCallbackRequiresMatchingState is the login-CSRF guard: without the flow
|
||||
// cookie this browser started, no code is redeemed and no session is minted.
|
||||
func TestCallbackRequiresMatchingState(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
|
||||
// No flow cookie at all.
|
||||
resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=s", nil))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback with no flow cookie = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
|
||||
// Flow cookie present, but the returned state is someone else's.
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=attacker-state", nil)
|
||||
req.AddCookie(&http.Cookie{Name: flowCookie, Value: encodeFlow(flow{Nonce: "real-nonce", Verifier: "v", Return: "/"})})
|
||||
resp = do(t, app, req)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback with mismatched state = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
|
||||
// A garbage cookie is not a flow.
|
||||
req = httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=x", nil)
|
||||
req.AddCookie(&http.Cookie{Name: flowCookie, Value: "!!!not-base64!!!"})
|
||||
if resp := do(t, app, req); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback with corrupt flow cookie = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
if iam.calls != 0 {
|
||||
t.Errorf("IAM token endpoint was called %d times for refused callbacks, want 0", iam.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackRefusesNonAdminOrg: a VALID sign-in by a user outside the admin org
|
||||
// mints NO session. The console refuses the role plainly rather than handing out a
|
||||
// cookie that 403s everything.
|
||||
func TestCallbackRefusesNonAdminOrg(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.token = fakeJWT("hanzo", "someone", time.Hour) // a real user, wrong org
|
||||
iam.verified = cloud.VerifiedIdentity{Owner: "hanzo", User: "someone", Expiry: time.Now().Add(time.Hour)}
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("callback for a non-admin-org principal = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// TestCallbackRefusesUnverifiableToken is the MED-1 loop-breaker: a token this
|
||||
// deployment's identity boundary will NOT accept (audience allowlist that omits the
|
||||
// console's client_id, wrong issuer, expired) must fail HERE, once, with the real
|
||||
// reason — never become a cookie that is refused on the next request, bounced back
|
||||
// to sign-in, and re-minted forever.
|
||||
func TestCallbackRefusesUnverifiableToken(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
// The exact shape of the misconfiguration: aud=admin-console, allowlist without it.
|
||||
iam.verifyErr = errors.New(`claims: square/go-jose/jwt: validation failed, invalid audience claim (aud)`)
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback with an unverifiable token = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
|
||||
// The error must NAME the knob, or an operator has nothing to act on.
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
for _, want := range []string{"audience", defaultClientID} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("error body does not mention %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackRefusesAlreadyExpiredToken: an expired credential must not become an
|
||||
// 8-hour cookie. There is no fallback lifetime — a cookie that cannot work is the
|
||||
// loop, not a mitigation.
|
||||
func TestCallbackRefusesAlreadyExpiredToken(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.verified = cloud.VerifiedIdentity{Owner: "admin", User: "cto", Expiry: time.Now().Add(-time.Minute)}
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback with an expired token = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// TestSessionMaxAge pins the clamp at both ends (RED LOW-2).
|
||||
func TestSessionMaxAge(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
expiry time.Time
|
||||
want func(int) bool
|
||||
desc string
|
||||
}{
|
||||
{"no expiry proven", time.Time{}, func(n int) bool { return n == 0 }, "0"},
|
||||
{"already expired", time.Now().Add(-time.Hour), func(n int) bool { return n == 0 }, "0"},
|
||||
{"expired by a second", time.Now().Add(-time.Second), func(n int) bool { return n == 0 }, "0"},
|
||||
{"normal hour", time.Now().Add(time.Hour), func(n int) bool { return n > 3500 && n <= 3600 }, "~3600"},
|
||||
{"absurd future", time.Now().Add(292 * 365 * 24 * time.Hour), func(n int) bool { return n == int(sessionMaxTTL.Seconds()) }, "clamped to sessionMaxTTL"},
|
||||
{"just over the cap", time.Now().Add(sessionMaxTTL + time.Hour), func(n int) bool { return n == int(sessionMaxTTL.Seconds()) }, "clamped to sessionMaxTTL"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := sessionMaxAge(c.expiry); !c.want(got) {
|
||||
t.Errorf("sessionMaxAge = %d, want %s", got, c.desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignInFailsClosedWithoutPublicOrigin (RED MED-3): with no configured public
|
||||
// origin the OAuth hop refuses rather than deriving a redirect_uri from the
|
||||
// caller-controlled Host / X-Forwarded-Proto headers.
|
||||
func TestSignInFailsClosedWithoutPublicOrigin(t *testing.T) {
|
||||
svc := fakeSvc()
|
||||
svc.State.oauth = oauth{
|
||||
issuer: "https://iam.test", clientID: defaultClientID, adminOrg: "admin",
|
||||
http: &http.Client{Timeout: time.Second},
|
||||
verify: func(string) (cloud.VerifiedIdentity, error) { return cloud.VerifiedIdentity{}, nil },
|
||||
} // publicURL deliberately empty
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, svc)
|
||||
|
||||
// A forged Host must NOT be adopted as the origin.
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/login", nil)
|
||||
req.Host = "evil.example"
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
resp := do(t, app, req)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("login with no configured origin = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); strings.Contains(loc, "evil.example") {
|
||||
t.Errorf("redirect adopted the forged Host: %q", loc)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "DEPLOY_PUBLIC_URL") {
|
||||
t.Errorf("error must name the knob to set, got: %s", body)
|
||||
}
|
||||
|
||||
// The callback refuses for the same reason, and mints nothing.
|
||||
cb := httptest.NewRequest("GET", "/v1/deploy/callback?code=c&state=s", nil)
|
||||
cb.Host = "evil.example"
|
||||
resp = do(t, app, cb)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("callback with no configured origin = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// TestLogoutClearsSession: POST clears the cookie; the route is not a GET.
|
||||
func TestLogoutClearsSession(t *testing.T) {
|
||||
app, _ := signinApp(t, "https://iam.test")
|
||||
|
||||
resp := do(t, app, httptest.NewRequest("POST", "/v1/deploy/logout", nil))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("logout = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var cleared bool
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == sessionCookie && ck.Value == "" && ck.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
t.Error("logout did not clear the session cookie")
|
||||
}
|
||||
|
||||
// A cross-site top-level navigation must not be able to sign anyone out.
|
||||
if resp := do(t, app, httptest.NewRequest("GET", "/v1/deploy/logout", nil)); resp.StatusCode == http.StatusOK {
|
||||
t.Error("GET /v1/deploy/logout succeeded; logout must be POST-only")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookiesCarryHostPrefix (RED LOW-3): both cookies use the __Host- prefix, which
|
||||
// the browser will only honour for Secure, Path=/, Domain-less cookies — so a
|
||||
// sibling *.hanzo.ai host cannot shadow them with a Domain=.hanzo.ai cookie.
|
||||
func TestCookiesCarryHostPrefix(t *testing.T) {
|
||||
if !strings.HasPrefix(sessionCookie, "__Host-") {
|
||||
t.Errorf("session cookie %q lacks the __Host- prefix", sessionCookie)
|
||||
}
|
||||
if !strings.HasPrefix(flowCookie, "__Host-") {
|
||||
t.Errorf("flow cookie %q lacks the __Host- prefix", flowCookie)
|
||||
}
|
||||
// The session name must be one cloud's identity boundary actually reads.
|
||||
if sessionCookie != "__Host-hanzo_iam_token" {
|
||||
t.Errorf("session cookie %q is not in cloud's cookieTokenNames", sessionCookie)
|
||||
}
|
||||
|
||||
app, _ := signinApp(t, "")
|
||||
resp := completeSignin(t, app)
|
||||
for _, ck := range resp.Cookies() {
|
||||
if !strings.HasPrefix(ck.Name, "__Host-") {
|
||||
continue
|
||||
}
|
||||
// The __Host- contract, enforced here so a future edit cannot silently
|
||||
// break it (a browser would then reject the cookie outright).
|
||||
if !ck.Secure || ck.Path != "/" || ck.Domain != "" {
|
||||
t.Errorf("%s violates the __Host- contract: Secure=%v Path=%q Domain=%q",
|
||||
ck.Name, ck.Secure, ck.Path, ck.Domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackMintsSessionForSuperAdmin is the happy path: the session cookie is
|
||||
// the token, under cloud's EXISTING cookie name, hardened, and the browser lands
|
||||
// on the remembered path.
|
||||
func TestCallbackMintsSessionForSuperAdmin(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.token = fakeJWT("admin", "cto", 2*time.Hour)
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("callback = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Location"); got != "/applications" {
|
||||
t.Errorf("landed on %q, want the remembered /applications", got)
|
||||
}
|
||||
|
||||
var sess *http.Cookie
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == sessionCookie {
|
||||
sess = ck
|
||||
}
|
||||
if ck.Name == flowCookie && ck.MaxAge >= 0 {
|
||||
t.Error("flow cookie must be cleared once consumed")
|
||||
}
|
||||
}
|
||||
if sess == nil {
|
||||
t.Fatalf("no %s cookie minted", sessionCookie)
|
||||
}
|
||||
if sess.Value != iam.token {
|
||||
t.Error("session cookie must carry the IAM access token verbatim (cloud re-validates it)")
|
||||
}
|
||||
if !sess.HttpOnly {
|
||||
t.Error("session cookie must be HttpOnly (page JS must never read the token)")
|
||||
}
|
||||
if !sess.Secure {
|
||||
t.Error("session cookie must be Secure")
|
||||
}
|
||||
if sess.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("session cookie SameSite = %v, want Lax (Strict breaks the OAuth return hop)", sess.SameSite)
|
||||
}
|
||||
if sess.Domain != "" {
|
||||
t.Errorf("session cookie Domain = %q, want host-only (never shared with sibling hosts)", sess.Domain)
|
||||
}
|
||||
if sess.MaxAge <= 0 || sess.MaxAge > int((2*time.Hour).Seconds()) {
|
||||
t.Errorf("session MaxAge = %d, want bounded by the token's own expiry", sess.MaxAge)
|
||||
}
|
||||
|
||||
// The exchange used PKCE and the code, with no secret configured.
|
||||
if iam.form.Get("code_verifier") == "" {
|
||||
t.Error("token exchange sent no code_verifier")
|
||||
}
|
||||
if iam.form.Get("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type = %q", iam.form.Get("grant_type"))
|
||||
}
|
||||
if iam.form.Get("redirect_uri") != "https://cd.hanzo.ai/v1/deploy/callback" {
|
||||
t.Errorf("exchange redirect_uri = %q, must match the authorize one byte for byte", iam.form.Get("redirect_uri"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackFailsClosedOnExchangeError: IAM refusing the code mints no session.
|
||||
func TestCallbackFailsClosedOnExchangeError(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.status = http.StatusBadRequest
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("callback with a rejected code = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// ── flow cookie ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDecodeFlow(t *testing.T) {
|
||||
// Round trip.
|
||||
f, err := decodeFlow(encodeFlow(flow{Nonce: "n", Verifier: "v", Return: "/x"}))
|
||||
if err != nil || f.Nonce != "n" || f.Verifier != "v" || f.Return != "/x" {
|
||||
t.Fatalf("round trip = (%+v, %v)", f, err)
|
||||
}
|
||||
// A tampered return path is re-checked, not trusted.
|
||||
f, err = decodeFlow(encodeFlow(flow{Nonce: "n", Verifier: "v", Return: "https://evil.example"}))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeFlow: %v", err)
|
||||
}
|
||||
if f.Return != "/" {
|
||||
t.Errorf("tampered return = %q, want / (re-validated on read)", f.Return)
|
||||
}
|
||||
// Incomplete / malformed cookies are not flows.
|
||||
for _, bad := range []string{"", "%%%", encodeFlow(flow{Nonce: "n"}), encodeFlow(flow{Verifier: "v"})} {
|
||||
if _, err := decodeFlow(bad); err == nil {
|
||||
t.Errorf("decodeFlow(%q) = nil error, want rejection", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifiedClaimsDecide: the admin-org decision is made on what the VALIDATOR
|
||||
// proved, never on what the token says about itself. A token whose unverified body
|
||||
// claims owner=admin is refused when validation reports a different owner — the
|
||||
// unverified decode is gone, and this pins that it stays gone.
|
||||
func TestVerifiedClaimsDecide(t *testing.T) {
|
||||
app, iam := signinApp(t, "")
|
||||
iam.token = fakeJWT("admin", "cto", time.Hour) // body SAYS admin
|
||||
iam.verified = cloud.VerifiedIdentity{Owner: "hanzo", User: "someone", Expiry: time.Now().Add(time.Hour)}
|
||||
|
||||
resp := completeSignin(t, app)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("callback = %d, want 403: the verified owner must win over the token body", resp.StatusCode)
|
||||
}
|
||||
assertNoSession(t, resp)
|
||||
}
|
||||
|
||||
// ── harness ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// fakeIAM is a stand-in for IAM's token endpoint, recording what the exchange sent.
|
||||
// verified is what cloud's validator will PROVE about the token it hands back;
|
||||
// verifyErr makes validation fail, which is how a real deployment behaves when its
|
||||
// audience allowlist or issuer does not admit the token.
|
||||
type fakeIAM struct {
|
||||
token string
|
||||
status int
|
||||
calls int
|
||||
form url.Values
|
||||
verified cloud.VerifiedIdentity
|
||||
verifyErr error
|
||||
}
|
||||
|
||||
// signinApp builds the real router over a state whose IAM is a local fake (or the
|
||||
// literal issuer when one is given, for the no-network authorize assertions).
|
||||
//
|
||||
// The token verifier is the ONE seam a test replaces: cloud's real validator needs
|
||||
// a live JWKS and an RS256-signed token, which would test go-jose rather than this
|
||||
// flow. The contract it stands in for — verified claims decide, a validation
|
||||
// failure refuses — is exercised in both directions below.
|
||||
func signinApp(t *testing.T, issuer string) (*zip.App, *fakeIAM) {
|
||||
t.Helper()
|
||||
iam := &fakeIAM{
|
||||
token: fakeJWT("admin", "cto", time.Hour),
|
||||
status: http.StatusOK,
|
||||
verified: cloud.VerifiedIdentity{Owner: "admin", User: "cto", Expiry: time.Now().Add(time.Hour)},
|
||||
}
|
||||
if issuer == "" {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/iam/oauth/token" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
iam.calls++
|
||||
_ = r.ParseForm()
|
||||
iam.form = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(iam.status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": iam.token})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
issuer = srv.URL
|
||||
}
|
||||
svc := fakeSvc()
|
||||
svc.State.oauth = oauth{
|
||||
issuer: issuer, clientID: defaultClientID, adminOrg: "admin",
|
||||
publicURL: "https://cd.hanzo.ai", http: &http.Client{Timeout: 5 * time.Second},
|
||||
verify: func(raw string) (cloud.VerifiedIdentity, error) {
|
||||
if iam.verifyErr != nil {
|
||||
return cloud.VerifiedIdentity{}, iam.verifyErr
|
||||
}
|
||||
return iam.verified, nil
|
||||
},
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
routes(app, svc)
|
||||
return app, iam
|
||||
}
|
||||
|
||||
// completeSignin drives login → callback with the flow cookie the login handed
|
||||
// back, i.e. exactly what a browser does.
|
||||
func completeSignin(t *testing.T, app *zip.App) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", "/v1/deploy/login?returnTo=/applications", nil)
|
||||
req.Host = "cd.hanzo.ai"
|
||||
start := do(t, app, req)
|
||||
_, cookie := flowFrom(t, start)
|
||||
|
||||
loc, _ := url.Parse(start.Header.Get("Location"))
|
||||
cb := httptest.NewRequest("GET", "/v1/deploy/callback?code=the-code&state="+url.QueryEscape(loc.Query().Get("state")), nil)
|
||||
cb.Host = "cd.hanzo.ai"
|
||||
cb.AddCookie(&http.Cookie{Name: flowCookie, Value: cookie.Value})
|
||||
return do(t, app, cb)
|
||||
}
|
||||
|
||||
func do(t *testing.T, app *zip.App, req *http.Request) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", req.Method, req.URL, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
return resp
|
||||
}
|
||||
|
||||
func flowFrom(t *testing.T, resp *http.Response) (flow, *http.Cookie) {
|
||||
t.Helper()
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == flowCookie && ck.Value != "" {
|
||||
f, err := decodeFlow(ck.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("flow cookie: %v", err)
|
||||
}
|
||||
return f, ck
|
||||
}
|
||||
}
|
||||
t.Fatal("no flow cookie was set")
|
||||
return flow{}, nil
|
||||
}
|
||||
|
||||
func assertNoSession(t *testing.T, resp *http.Response) {
|
||||
t.Helper()
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == sessionCookie && ck.Value != "" {
|
||||
t.Fatalf("a session cookie was minted on a refused sign-in: %q", ck.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func encodeFlow(f flow) string {
|
||||
blob, _ := json.Marshal(f)
|
||||
return base64.RawURLEncoding.EncodeToString(blob)
|
||||
}
|
||||
|
||||
// fakeJWT builds a structurally valid, UNSIGNED JWT. That is exactly the point of
|
||||
// the test: nothing in this package trusts the signature — cloud's identity
|
||||
// boundary re-verifies the token on every later request, and this only exercises
|
||||
// the claim read that decides whether a cookie is worth minting.
|
||||
func fakeJWT(owner, name string, ttl time.Duration) string {
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
return enc(map[string]any{"alg": "RS256", "typ": "JWT"}) + "." +
|
||||
enc(map[string]any{"owner": owner, "name": name, "exp": time.Now().Add(ttl).Unix()}) + "." +
|
||||
"not-a-real-signature"
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// projection.go — the App-CR → ArgoCD `Application` READ PROJECTION.
|
||||
//
|
||||
// The CTO decision (have-both): serve the full ArgoCD React UI, but feed it a
|
||||
// projection of our operator `App` CRs shaped as ArgoCD `Application`s. There is
|
||||
// NO stored Application/AppProject CRD — each App CR IS projected on the fly, its
|
||||
// resource tree + health synthesized from the SAME readers the native
|
||||
// /v1/deploy routes use (listAppCRs/getAppCR/buildTree/resourceHealth), with no
|
||||
// repo-server and no redis. App CRs stay the single source of truth; the
|
||||
// Application shape exists only at this API layer.
|
||||
//
|
||||
// These `argo*` types are the MINIMAL ArgoCD v1alpha1 JSON the React app renders
|
||||
// (list + detail + tree). Distinct from the native `Application` (applications.go)
|
||||
// which backs the native /v1/deploy/applications surface — this backs the
|
||||
// ArgoCD-UI-compatible /v1/deploy/api/v1/* surface.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
// ── ArgoCD v1alpha1 JSON (minimal, UI-render-complete) ───────────────────────
|
||||
|
||||
type argoListMeta struct {
|
||||
ResourceVersion string `json:"resourceVersion"`
|
||||
}
|
||||
|
||||
type argoMeta struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
CreationTimestamp string `json:"creationTimestamp,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type argoSource struct {
|
||||
RepoURL string `json:"repoURL"`
|
||||
Path string `json:"path"`
|
||||
TargetRevision string `json:"targetRevision"`
|
||||
}
|
||||
|
||||
type argoDestination struct {
|
||||
Server string `json:"server"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
type argoSpec struct {
|
||||
Source argoSource `json:"source"`
|
||||
Destination argoDestination `json:"destination"`
|
||||
Project string `json:"project"`
|
||||
}
|
||||
|
||||
type argoHealth struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type argoSyncStatus struct {
|
||||
Status string `json:"status"`
|
||||
Revision string `json:"revision,omitempty"`
|
||||
}
|
||||
|
||||
type argoResourceStatus struct {
|
||||
Group string `json:"group,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Health *argoHealth `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
type argoSummary struct {
|
||||
Images []string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type argoStatus struct {
|
||||
Sync argoSyncStatus `json:"sync"`
|
||||
Health argoHealth `json:"health"`
|
||||
Resources []argoResourceStatus `json:"resources"`
|
||||
Summary argoSummary `json:"summary"`
|
||||
ReconciledAt string `json:"reconciledAt,omitempty"`
|
||||
}
|
||||
|
||||
type argoApp struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Metadata argoMeta `json:"metadata"`
|
||||
Spec argoSpec `json:"spec"`
|
||||
Status argoStatus `json:"status"`
|
||||
}
|
||||
|
||||
type argoAppList struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Metadata argoListMeta `json:"metadata"`
|
||||
Items []argoApp `json:"items"`
|
||||
}
|
||||
|
||||
// ── resource tree (ArgoCD ApplicationTree) ───────────────────────────────────
|
||||
|
||||
type argoResourceRef struct {
|
||||
Group string `json:"group,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Name string `json:"name"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
}
|
||||
|
||||
type argoInfoItem struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type argoNode struct {
|
||||
argoResourceRef
|
||||
ParentRefs []argoResourceRef `json:"parentRefs,omitempty"`
|
||||
Info []argoInfoItem `json:"info,omitempty"`
|
||||
Health *argoHealth `json:"health,omitempty"`
|
||||
ResourceVersion string `json:"resourceVersion,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type argoTree struct {
|
||||
Nodes []argoNode `json:"nodes"`
|
||||
OrphanedNodes []argoNode `json:"orphanedNodes"`
|
||||
Hosts []any `json:"hosts"`
|
||||
}
|
||||
|
||||
// ── projection ───────────────────────────────────────────────────────────────
|
||||
|
||||
// argoHealthFrom maps the native lowercase health vocab (resourceHealth) to the
|
||||
// Capitalized ArgoCD vocab the UI renders.
|
||||
func argoHealthFrom(native string) string {
|
||||
switch native {
|
||||
case HealthHealthy:
|
||||
return "Healthy"
|
||||
case HealthProgressing:
|
||||
return "Progressing"
|
||||
case HealthDegraded:
|
||||
return "Degraded"
|
||||
case HealthSuspended:
|
||||
return "Suspended"
|
||||
case HealthMissing:
|
||||
return "Missing"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// argoSyncFrom maps the native sync verdict to the Capitalized ArgoCD vocab.
|
||||
func argoSyncFrom(native string) string {
|
||||
switch native {
|
||||
case SyncSynced:
|
||||
return "Synced"
|
||||
case SyncOutOfSync:
|
||||
return "OutOfSync"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// deployManifestRepo is the desired-state source the projection reports as the
|
||||
// Application's git source — the manifest repo the engine syncs. Display-only.
|
||||
const deployManifestRepo = "https://git.hanzo.ai/hanzoai/universe"
|
||||
|
||||
// projectApp maps ONE operator App CR (+ its running image tag) to an ArgoCD
|
||||
// Application. Reuses observeApplication's native derivation, then reshapes to
|
||||
// the v1alpha1 JSON — one source of truth (the App CR), two shapes.
|
||||
func projectApp(cr *unstructured.Unstructured, ns, runningTag string) argoApp {
|
||||
native := observeApplication(cr, ns, runningTag)
|
||||
repository, _, _ := unstructured.NestedString(cr.Object, "spec", "image", "repository")
|
||||
tag := native.Version
|
||||
image := repository
|
||||
if tag != "" {
|
||||
image = repository + ":" + tag
|
||||
}
|
||||
return argoApp{
|
||||
APIVersion: "argoproj.io/v1alpha1",
|
||||
Kind: "Application",
|
||||
Metadata: argoMeta{
|
||||
Name: native.Name,
|
||||
Namespace: ns,
|
||||
UID: string(cr.GetUID()),
|
||||
CreationTimestamp: cr.GetCreationTimestamp().Format("2006-01-02T15:04:05Z07:00"),
|
||||
Labels: map[string]string{"argocd.argoproj.io/instance": native.Name, "hanzo.ai/env": native.Env},
|
||||
},
|
||||
Spec: argoSpec{
|
||||
Source: argoSource{RepoURL: deployManifestRepo, Path: "infra/k8s/operator/crs", TargetRevision: "main"},
|
||||
Destination: argoDestination{Server: "https://kubernetes.default.svc", Namespace: ns},
|
||||
Project: "default",
|
||||
},
|
||||
Status: argoStatus{
|
||||
Sync: argoSyncStatus{Status: argoSyncFrom(native.Sync), Revision: native.Version},
|
||||
Health: argoHealth{Status: argoHealthFrom(native.Health), Message: native.HealthMessage},
|
||||
Resources: []argoResourceStatus{},
|
||||
Summary: argoSummary{Images: nonEmpty(image)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// projectTree reshapes the native buildTree []Node into an ArgoCD ApplicationTree.
|
||||
func projectTree(nodes []Node) argoTree {
|
||||
out := argoTree{Nodes: make([]argoNode, 0, len(nodes)), OrphanedNodes: []argoNode{}, Hosts: []any{}}
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
an := argoNode{
|
||||
argoResourceRef: argoResourceRef{
|
||||
Group: n.Group, Version: n.Version, Kind: n.Kind,
|
||||
Namespace: n.Namespace, Name: n.Name, UID: n.UID,
|
||||
},
|
||||
ResourceVersion: "",
|
||||
CreatedAt: n.CreatedAt,
|
||||
Health: &argoHealth{Status: argoHealthFrom(n.Health), Message: n.HealthMessage},
|
||||
}
|
||||
for _, p := range n.ParentRefs {
|
||||
an.ParentRefs = append(an.ParentRefs, argoResourceRef{
|
||||
Group: p.Group, Version: p.Version, Kind: p.Kind, Namespace: p.Namespace, Name: p.Name,
|
||||
})
|
||||
}
|
||||
if n.Version != "" {
|
||||
an.Info = append(an.Info, argoInfoItem{Name: "Image Tag", Value: n.Version})
|
||||
}
|
||||
out.Nodes = append(out.Nodes, an)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nonEmpty(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{s}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func projFixture(name, ns, repo, tag, phase string, replicas, ready int64) *unstructured.Unstructured {
|
||||
return &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "hanzo.ai/v1", "kind": "App",
|
||||
"metadata": map[string]any{"name": name, "namespace": ns, "uid": "uid-" + name},
|
||||
"spec": map[string]any{"role": "service", "image": map[string]any{"repository": repo, "tag": tag}},
|
||||
"status": map[string]any{"phase": phase, "replicas": replicas, "readyReplicas": ready, "availableReplicas": ready},
|
||||
}}
|
||||
}
|
||||
|
||||
// TestProjectApp_ShapeIsUIRenderable asserts the projection produces the exact
|
||||
// minimal ArgoCD Application JSON the React UI needs (per the api-server
|
||||
// contract): the fields the list tiles + detail header destructure must all be
|
||||
// present and correctly typed, or the SPA throws.
|
||||
func TestProjectApp_ShapeIsUIRenderable(t *testing.T) {
|
||||
app := projectApp(projFixture("cloud", "hanzo", "ghcr.io/hanzoai/cloud", "v1.2.3", "Running", 1, 1), "hanzo", "v1.2.3")
|
||||
|
||||
if app.APIVersion != "argoproj.io/v1alpha1" || app.Kind != "Application" {
|
||||
t.Fatalf("wrong TypeMeta: %s/%s", app.APIVersion, app.Kind)
|
||||
}
|
||||
if app.Metadata.Name != "cloud" || app.Metadata.Namespace != "hanzo" {
|
||||
t.Fatalf("wrong metadata: %+v", app.Metadata)
|
||||
}
|
||||
if app.Spec.Source.RepoURL == "" || app.Spec.Destination.Server == "" {
|
||||
t.Fatal("spec.source.repoURL and spec.destination.server are required by the UI")
|
||||
}
|
||||
// Running + declared==running ⇒ Healthy + Synced (Capitalized ArgoCD vocab).
|
||||
if app.Status.Health.Status != "Healthy" {
|
||||
t.Fatalf("health = %q, want Healthy", app.Status.Health.Status)
|
||||
}
|
||||
if app.Status.Sync.Status != "Synced" {
|
||||
t.Fatalf("sync = %q, want Synced", app.Status.Sync.Status)
|
||||
}
|
||||
|
||||
// The UI's parseAppFields requires status.resources to be an array and
|
||||
// status.summary to be an object — assert they marshal as such.
|
||||
b, err := json.Marshal(app)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
status := m["status"].(map[string]any)
|
||||
if _, ok := status["resources"].([]any); !ok {
|
||||
t.Fatalf("status.resources must be a JSON array, got %T", status["resources"])
|
||||
}
|
||||
if _, ok := status["summary"].(map[string]any); !ok {
|
||||
t.Fatalf("status.summary must be a JSON object, got %T", status["summary"])
|
||||
}
|
||||
if m["spec"].(map[string]any)["source"].(map[string]any)["repoURL"] == "" {
|
||||
t.Fatal("spec.source.repoURL empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectApp_HealthAndSyncVocab covers the native→ArgoCD vocab mapping.
|
||||
func TestProjectApp_HealthAndSyncVocab(t *testing.T) {
|
||||
// 1 desired / 0 ready ⇒ Degraded; declared != running ⇒ OutOfSync.
|
||||
app := projectApp(projFixture("chat", "hanzo", "ghcr.io/hanzoai/chat", "v2", "Creating", 1, 0), "hanzo", "v1")
|
||||
if app.Status.Health.Status != "Degraded" {
|
||||
t.Fatalf("health = %q, want Degraded", app.Status.Health.Status)
|
||||
}
|
||||
if app.Status.Sync.Status != "OutOfSync" {
|
||||
t.Fatalf("sync = %q, want OutOfSync", app.Status.Sync.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectTree_ShapeIsUIRenderable asserts the ApplicationTree projection.
|
||||
func TestProjectTree_ShapeIsUIRenderable(t *testing.T) {
|
||||
nodes := []Node{
|
||||
{ResourceRef: ResourceRef{Group: "apps", Version: "v1", Kind: "Deployment", Namespace: "hanzo", Name: "cloud"}, UID: "d1", Health: HealthHealthy, Version: "v1.2.3"},
|
||||
{ResourceRef: ResourceRef{Version: "v1", Kind: "Pod", Namespace: "hanzo", Name: "cloud-xyz"}, UID: "p1", Health: HealthHealthy, ParentRefs: []ResourceRef{{Group: "apps", Version: "v1", Kind: "ReplicaSet", Namespace: "hanzo", Name: "cloud-abc"}}},
|
||||
}
|
||||
tree := projectTree(nodes)
|
||||
if len(tree.Nodes) != 2 {
|
||||
t.Fatalf("nodes = %d, want 2", len(tree.Nodes))
|
||||
}
|
||||
b, _ := json.Marshal(tree)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["nodes"].([]any); !ok {
|
||||
t.Fatal("tree.nodes must be a JSON array")
|
||||
}
|
||||
if _, ok := m["orphanedNodes"]; !ok {
|
||||
t.Fatal("tree.orphanedNodes must be present")
|
||||
}
|
||||
// Node health mapped to Capitalized vocab; image surfaced as an info item.
|
||||
n0 := m["nodes"].([]any)[0].(map[string]any)
|
||||
if n0["health"].(map[string]any)["status"] != "Healthy" {
|
||||
t.Fatalf("node health = %v", n0["health"])
|
||||
}
|
||||
if n0["kind"] != "Deployment" || n0["name"] != "cloud" {
|
||||
t.Fatalf("node ref wrong: %v", n0)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user