Compare commits
173
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a9440bd93 | ||
|
|
aa17a509a8 | ||
|
|
7956370e6a | ||
|
|
dac1d89d45 | ||
|
|
35a6d263d3 | ||
|
|
05461f1410 | ||
|
|
82a4b80bdc | ||
|
|
d783a2439a | ||
|
|
bde698f196 | ||
|
|
d5170feb43 | ||
|
|
e8b9fcac58 | ||
|
|
efa6e0495a | ||
|
|
f8ba0247db | ||
|
|
63fb2e8bc1 | ||
|
|
9d4775705f | ||
|
|
5600e6ee22 | ||
|
|
8487c226c6 | ||
|
|
ba43e6f741 | ||
|
|
fe1f13fbca | ||
|
|
399345b258 | ||
|
|
5b60922b6c | ||
|
|
a31f92b085 | ||
|
|
0566edf43a | ||
|
|
98a2109a4d | ||
|
|
513be0c3ba | ||
|
|
1abe897819 | ||
|
|
a2834b5e09 | ||
|
|
c6c7399fd4 | ||
|
|
fddaeb1469 | ||
|
|
ce516f7396 | ||
|
|
ddeeda1c4a | ||
|
|
3bbc8b8028 | ||
|
|
7bae43dae0 | ||
|
|
332f0048c4 | ||
|
|
2158a98c37 | ||
|
|
5fa3647a3b | ||
|
|
9a62bb857b | ||
|
|
294d24325a | ||
|
|
9971bd6b31 | ||
|
|
f4f7857bb1 | ||
|
|
35fc828993 | ||
|
|
0c28bc1854 | ||
|
|
b06a406a98 | ||
|
|
27602c9d46 | ||
|
|
9f83e8a60c | ||
|
|
9e83fca39b | ||
|
|
e585f5ad14 | ||
|
|
b27d8714d0 | ||
|
|
568965606f | ||
|
|
e3cf1ba847 | ||
|
|
ef8d35b6ab | ||
|
|
79d9d8d27d | ||
|
|
75ee439ecb | ||
|
|
18a5c9d6bd | ||
|
|
09c6e9d707 | ||
|
|
1c3c1660f3 | ||
|
|
6fa1f67c52 | ||
|
|
479001b48c | ||
|
|
0772c3fcc3 | ||
|
|
121ce6ca1e | ||
|
|
b2a9debb85 | ||
|
|
51aa19e2df | ||
|
|
5f8642ebdf | ||
|
|
a3a7d2aaa4 | ||
|
|
c8f6470c8e | ||
|
|
bc913b1743 | ||
|
|
c97cb9261a | ||
|
|
ac39bda1cd | ||
|
|
5d2d04a015 | ||
|
|
0880ca0b93 | ||
|
|
5a5a26c21c | ||
|
|
7af2a3e91d | ||
|
|
0a88f7d136 | ||
|
|
e319746c40 | ||
|
|
681323d717 | ||
|
|
3db80f9349 | ||
|
|
ca9759e220 | ||
|
|
13652c3f64 | ||
|
|
d6171fc880 | ||
|
|
6f18a75609 | ||
|
|
c3bef4be05 | ||
|
|
57d4997d52 | ||
|
|
2d3aa6196e | ||
|
|
b4a0efa4c0 | ||
|
|
826b97997c | ||
|
|
7dad0a199d | ||
|
|
b5e7949dec | ||
|
|
5050cde331 | ||
|
|
69cbdf38a2 | ||
|
|
ceb1b833d1 | ||
|
|
11427c91f7 | ||
|
|
5bb821bc27 | ||
|
|
5f62b3ca7d | ||
|
|
93ecccd79c | ||
|
|
ec69460a7f | ||
|
|
196b0335f8 | ||
|
|
6c71cc97ab | ||
|
|
ab2fbbeace | ||
|
|
db4def384e | ||
|
|
ee8472f840 | ||
|
|
1477789492 | ||
|
|
76aaff2f2b | ||
|
|
b8216a4c60 | ||
|
|
e72df59aef | ||
|
|
f0c8289d95 | ||
|
|
5d0363b8c5 | ||
|
|
e4dd5333c4 | ||
|
|
bc53ac131d | ||
|
|
2cf17d5f2b | ||
|
|
7560c8f905 | ||
|
|
34dd6243a8 | ||
|
|
69dc1f58af | ||
|
|
7284a73ee2 | ||
|
|
af02ac395d | ||
|
|
f88d1f3ab0 | ||
|
|
3e226ba24e | ||
|
|
00555efff8 | ||
|
|
b36457653c | ||
|
|
ab9e937523 | ||
|
|
585d187a9f | ||
|
|
844b17b53a | ||
|
|
b9d6b62e4d | ||
|
|
34a8fc08f2 | ||
|
|
b816c9d6de | ||
|
|
df82515442 | ||
|
|
fdd5665c0a | ||
|
|
56d03b2807 | ||
|
|
c3be201aaf | ||
|
|
05c6d9bb9e | ||
|
|
85ee68b39b | ||
|
|
661b8e666d | ||
|
|
5a8479c516 | ||
|
|
73ee157b58 | ||
|
|
a01bed7e7f | ||
|
|
62821d2e40 | ||
|
|
5347d4d51c | ||
|
|
a81fb3e9fe | ||
|
|
d6a1f2892d | ||
|
|
39866320b6 | ||
|
|
4c3925a38d | ||
|
|
a83c6b638a | ||
|
|
c8142cbc68 | ||
|
|
2de3651a74 | ||
|
|
551da2bdd4 | ||
|
|
8d66ddaf17 | ||
|
|
747f27f89c | ||
|
|
c7c4a042b4 | ||
|
|
3fe4337777 | ||
|
|
572f804381 | ||
|
|
744c64abcd | ||
|
|
2d7ca54dec | ||
|
|
c3d43d3581 | ||
|
|
f4c0a76e40 | ||
|
|
dec8c6bd51 | ||
|
|
be97059da5 | ||
|
|
e7dd8a07ba | ||
|
|
1470957ef9 | ||
|
|
f12766c0f1 | ||
|
|
a21496f5de | ||
|
|
28888e7f57 | ||
|
|
e89c32ec9d | ||
|
|
2e933b9cbc | ||
|
|
273876ffe9 | ||
|
|
6dbb0a57a0 | ||
|
|
ee044be2bc | ||
|
|
3e9c130af1 | ||
|
|
098489930d | ||
|
|
d28bc0de82 | ||
|
|
9a2433f053 | ||
|
|
a02e336fba | ||
|
|
5546d5a492 | ||
|
|
2c34068c98 | ||
|
|
51c9163d41 |
@@ -0,0 +1,41 @@
|
||||
# VCS
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# CI / repo metadata not needed inside the build
|
||||
.github/
|
||||
|
||||
# Docs (image runs the binary; readers visit GitHub)
|
||||
*.md
|
||||
LICENSE
|
||||
SECURITY.md
|
||||
|
||||
# Already-built binary at repo root (matches .gitignore)
|
||||
/cloud
|
||||
|
||||
# Environment files (never bake secrets into images)
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE / editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Tests stay out of the runtime image
|
||||
*_test.go
|
||||
|
||||
# Local build outputs
|
||||
/dist/
|
||||
/build/
|
||||
/bin/
|
||||
|
||||
# The Dockerfile itself doesn't need to be in the context it builds
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="cloud">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">cloud</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hanzo Cloud — unified Go binary that imports every Hanzo-native…</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,95 @@
|
||||
name: release
|
||||
|
||||
# Builds and pushes ghcr.io/hanzoai/cloud:<tag> on tag (v*) / main / dispatch.
|
||||
#
|
||||
# Self-contained on the self-hosted arcd amd64 scale set — NEVER GitHub-hosted
|
||||
# runners (this org's GitHub-hosted Actions are billing-frozen: jobs fail in ~5s
|
||||
# with "recent account payments have failed").
|
||||
#
|
||||
# Why not the shared hanzoai/.github docker-build.yml: that workflow logs into
|
||||
# GHCR with the repo's GITHUB_TOKEN, but the ghcr.io/hanzoai/cloud package is
|
||||
# linked to a DIFFERENT repo (hanzoai/ai, from the cloud->ai module rename), so
|
||||
# this repo's GITHUB_TOKEN is denied write to it (permission_denied:
|
||||
# write_package). We log in with GH_PAT instead (admin:org + write:packages →
|
||||
# writes any hanzoai package regardless of package-repo linkage), and pass it as
|
||||
# the BuildKit gh_token the Dockerfile uses to fetch private cross-org Go
|
||||
# modules. amd64-only: the cluster is amd64; pinning one platform completes on
|
||||
# the live scale set without waiting on the arm64 pool.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
# ARC ephemeral runners match jobs targeting the scale-set NAME as a label.
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log in to ghcr.io (GH_PAT — writes the cloud package despite its ai-repo linkage)
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: hanzo-dev
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/hanzoai/cloud
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# gh_token: BuildKit secret the Dockerfile consumes to fetch private
|
||||
# cross-org Go modules (hanzoai/*, luxfi/*) over authenticated git.
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GH_PAT }}
|
||||
|
||||
# Notify universe so the GitOps pipeline rolls the new image to prod —
|
||||
# same image-update contract every service uses (gateway, iam, …).
|
||||
notify-universe:
|
||||
needs: build-amd64
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Repository dispatch (image-update)
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.UNIVERSE_DISPATCH_TOKEN }}
|
||||
repository: hanzoai/universe
|
||||
event-type: image-update
|
||||
client-payload: |
|
||||
{
|
||||
"service": "cloud",
|
||||
"image": "ghcr.io/hanzoai/cloud:${{ github.ref_name }}",
|
||||
"sha": "${{ github.sha }}",
|
||||
"env": "all"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Built binaries (Dockerfile output + `go build ./cmd/hanzo`)
|
||||
/cloud
|
||||
/hanzo
|
||||
|
||||
# Local build directories
|
||||
/dist/
|
||||
/build/
|
||||
/bin/
|
||||
|
||||
# Go test + coverage artifacts
|
||||
*.test
|
||||
*.out
|
||||
coverage.txt
|
||||
coverage.html
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE / editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
.shots/
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# hanzoai/cloud — the ONE unified Hanzo Cloud binary (HIP-0106).
|
||||
#
|
||||
# This image is a SINGLE artifact that serves BOTH the /v1 API AND the console
|
||||
# UI from one process: the console is compiled into the Go binary via
|
||||
# //go:embed (see webui.go). The pipeline is:
|
||||
#
|
||||
# 1. console stage → build the hanzoai/console2 static bundle
|
||||
# 2. (copied) → into webui/dist/ of the Go build context
|
||||
# 3. build stage → `go build` bakes webui/dist into the binary (go:embed)
|
||||
#
|
||||
# so the final `/cloud` binary already carries the UI. No separate console
|
||||
# Service, no second origin — the embedded console calls /v1 on its own host.
|
||||
#
|
||||
# ── console UI stage ─────────────────────────────────────────────────────────
|
||||
# Builds the console2 SPA and emits a STATIC bundle at /out. console2 is fetched
|
||||
# at a pinned ref (CONSOLE2_REF) using the same gh_token BuildKit secret the Go
|
||||
# build uses for private modules.
|
||||
#
|
||||
# NOTE ON THE HONEST CURRENT STATE: console2 today ships 15 Next server route
|
||||
# handlers (app/**/route.ts) that hold KMS-sourced service tokens and mint
|
||||
# short-lived user tokens — so `next build` emits a Node server bundle, not a
|
||||
# static export, and `output: export` would fail. Until console2 exposes a
|
||||
# static-export target (npm run build:embed → out/) — or those server routes
|
||||
# land in cloud as native /v1 endpoints — this stage produces no /out and the Go
|
||||
# build embeds the committed fallback shell (webui/dist/index.html), which is a
|
||||
# real, same-origin /v1 bootstrap. The moment console2 emits out/, this stage
|
||||
# copies it and the SAME image serves the full @hanzo/gui console with zero Go
|
||||
# changes. The stage never fails the image: a missing static target degrades to
|
||||
# the shell, it does not error.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS console
|
||||
ARG CONSOLE2_REPO=https://github.com/hanzoai/console2.git
|
||||
ARG CONSOLE2_REF=main
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /console
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
|
||||
RUN --mount=type=secret,id=gh_token \
|
||||
if [ -s /run/secrets/gh_token ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi && \
|
||||
git clone --depth 1 --branch "${CONSOLE2_REF}" "${CONSOLE2_REPO}" . && \
|
||||
npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
# Always emit /out. When console2 exposes a static-embed target it holds the real
|
||||
# bundle; otherwise /out stays EMPTY so the Go build keeps the committed fallback
|
||||
# shell. Never fail the image (a missing static target is a degrade, not an error).
|
||||
RUN mkdir -p /out && \
|
||||
if npm run 2>/dev/null | grep -q ' build:embed'; then \
|
||||
echo ">> console2 build:embed → static bundle"; \
|
||||
npm run build:embed && cp -r out/. /out/; \
|
||||
else \
|
||||
echo ">> console2 has no static-embed target yet; cloud embeds the fallback shell"; \
|
||||
fi
|
||||
|
||||
# ── Go build stage ───────────────────────────────────────────────────────────
|
||||
# ECR Public mirror of the Docker library image — Docker Hub's unauthenticated
|
||||
# pull rate-limit (429 toomanyrequests) fails the build on shared CI runners.
|
||||
FROM public.ecr.aws/docker/library/golang:1.26-alpine AS build
|
||||
RUN apk add --no-cache ca-certificates tzdata git
|
||||
RUN addgroup -g 65532 -S nonroot && adduser -u 65532 -S nonroot -G nonroot
|
||||
WORKDIR /src
|
||||
# hanzoai/* and luxfi/* are PUBLIC and resolve via the IMMUTABLE public proxy +
|
||||
# sumdb — go.sum pins those canonical hashes, so a force-re-pointed tag can never
|
||||
# break the build. Routing them DIRECT (the old GOPRIVATE approach) re-fetches a
|
||||
# re-tagged tree (e.g. luxfi/age@v1.5.0) whose hash differs from go.sum's proxy
|
||||
# hash → "checksum mismatch / SECURITY ERROR". This matches the drop-GOPRIVATE
|
||||
# fix already shipped in hanzoai/iam + luxfi/kms. Only zap-proto/* stays first-
|
||||
# party-direct (kept in GOPRIVATE) — authenticated git via gh_token. GOPROXY
|
||||
# still routes nested-path monorepo tags (e.g. tencentcloud-sdk-go) through the
|
||||
# proxy. The committed go.sum is the single source of truth.
|
||||
ENV GOPRIVATE=github.com/zap-proto/* \
|
||||
GONOSUMDB=github.com/zap-proto/* \
|
||||
GOSUMDB=off \
|
||||
GOPROXY=https://proxy.golang.org,direct \
|
||||
GOFLAGS=-mod=mod
|
||||
COPY go.mod go.sum ./
|
||||
# With go.sum recorded against live tag content and our orgs routed direct, this
|
||||
# verifies cleanly — no runtime go.sum regeneration. (The old `rm -f go.sum`
|
||||
# self-heal masked a stale go.sum and silently re-recorded unverified hashes on
|
||||
# ANY transient error; removed in favor of a correct, committed go.sum.)
|
||||
RUN --mount=type=secret,id=gh_token \
|
||||
if [ -s /run/secrets/gh_token ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi && \
|
||||
go mod download
|
||||
COPY . .
|
||||
# Drop the console static bundle into the embed path BEFORE `go build`, so
|
||||
# //go:embed all:webui/dist bakes it into the binary. /out from the console stage
|
||||
# is either the real static build (then it overlays the committed fallback shell)
|
||||
# or empty (then webui/dist keeps the shell that `COPY . .` already brought). The
|
||||
# committed assets/.gitkeep keeps the embed's assets/ dir present either way.
|
||||
COPY --from=console /out/ /src/webui/dist/
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /cloud ./cmd/cloud
|
||||
|
||||
# ── final image ──────────────────────────────────────────────────────────────
|
||||
FROM scratch
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
COPY --from=build /etc/passwd /etc/passwd
|
||||
COPY --from=build /etc/group /etc/group
|
||||
COPY --from=build /cloud /cloud
|
||||
EXPOSE 8080 9090 9653
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/cloud"]
|
||||
@@ -0,0 +1,203 @@
|
||||
Copyright (c) 2026 Hanzo AI Inc.
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) 2026 Hanzo AI Inc.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# hanzoai/cloud — developer ergonomics for the unified Hanzo Cloud binary (HIP-0106).
|
||||
# Targets are intentionally minimal; deploy artifacts (compose, helm) live in deploy/ and helm/.
|
||||
|
||||
GO ?= go
|
||||
BIN ?= cloud
|
||||
PKG ?= ./cmd/cloud
|
||||
DOCKER_IMAGE ?= ghcr.io/hanzoai/cloud
|
||||
DOCKER_TAG ?= dev
|
||||
LDFLAGS ?= -s -w
|
||||
|
||||
.PHONY: help build run smoke test 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%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
build: ## Build the unified cloud binary into ./bin/cloud.
|
||||
@mkdir -p bin
|
||||
$(GO) build -ldflags="$(LDFLAGS)" -o bin/$(BIN) $(PKG)
|
||||
|
||||
run: build ## Run with iam,base,kms,gateway,o11y enabled (matches README quickstart).
|
||||
./bin/$(BIN) --enable=iam,base,kms,gateway,o11y --brand=hanzo --domain=api.hanzo.ai
|
||||
|
||||
smoke: ## Build and run cmd/cloud-smoke (mount-time integration check).
|
||||
$(GO) run ./cmd/cloud-smoke
|
||||
|
||||
test: ## Run unit + integration tests.
|
||||
$(GO) test ./...
|
||||
|
||||
vet: ## go vet across the module.
|
||||
$(GO) vet ./...
|
||||
|
||||
tidy: ## go mod tidy + verify go.sum.
|
||||
$(GO) mod tidy
|
||||
$(GO) mod verify
|
||||
|
||||
docker: ## Build the Docker image (uses repo Dockerfile, scratch final stage).
|
||||
docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) .
|
||||
|
||||
docker-push: docker ## Push the Docker image to ghcr.io. Requires docker login.
|
||||
docker push $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
|
||||
clean: ## Remove built artifacts.
|
||||
rm -rf bin
|
||||
@@ -1,3 +1,83 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="cloud" width="880"></p>
|
||||
|
||||
# cloud
|
||||
|
||||
Unified Go control plane and binary for the Hanzo platform (HIP-0106).
|
||||
|
||||
[]()
|
||||
[]()
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 ghcr.io/hanzoai/cloud:latest
|
||||
```
|
||||
|
||||
## What this is
|
||||
|
||||
`hanzoai/cloud` is one Go binary that mounts every Hanzo subsystem (iam, kms, base, gateway, ai, commerce, vfs, mq, dns, amqp, mcp, o11y, ...) into a single multi-tenant process. Same artifact serves `api.hanzo.ai`, `api.osage.cloud`, `api.lux.cloud`, `api.zoo.cloud`, and every white-label reseller. Brand, enabled subsystems, and tenant scope are deployment configuration.
|
||||
|
||||
## `hanzo` — cloud control CLI
|
||||
|
||||
The same binary is also a gcloud/doctl-class CLI. The first token selects the mode:
|
||||
|
||||
- `hanzo <subsystem>` — **server mode**: serve a subsystem (`hanzo iam`, `hanzo cloud`, …).
|
||||
- `hanzo <verb>` — **client mode**: control the live estate. A thin client over
|
||||
Hanzo IAM (`hanzo.id`), the platform control plane (`platform.hanzo.ai/v1`),
|
||||
and the cloud `/v1` API — it invents no parallel API.
|
||||
|
||||
```bash
|
||||
hanzo login # IAM password grant against hanzo.id → token in ~/.hanzo (0600)
|
||||
hanzo whoami # identity from the stored token (--verify hits IAM userinfo)
|
||||
hanzo apps list # platform apps board: declared/running/latest tag + drift + health
|
||||
hanzo apps get <org>/<app>/<env> # one app row
|
||||
hanzo deploy <container> --project <p> --env <e> # rolling, zero-downtime redeploy
|
||||
hanzo clusters list|get|create|select|target # dedicated DOKS cluster lifecycle
|
||||
hanzo build <repo> --sha <sha> --image <img> # platform-native (arcd/Kaniko) build, no GitHub builders
|
||||
hanzo k8s target # the org's resolved deploy target (kubeconfig never returned)
|
||||
hanzo config set <k> <v> # ~/.hanzo/config preferences
|
||||
```
|
||||
|
||||
Global flags: `--org`, `-o/--output table|json`, `--platform-url`, `--iam-issuer`,
|
||||
`--platform-token`. Tokens resolve from flag → env → `~/.hanzo` (never hardcoded):
|
||||
the IAM user token is the identity; the platform control plane is service-token
|
||||
authed (it cannot validate user tokens), so `apps`/`deploy`/`clusters` use
|
||||
`--platform-token` / `HANZO_PLATFORM_TOKEN` / `PLATFORM_SERVICE_TOKEN`, and
|
||||
`build` uses `HANZO_BUILD_TOKEN` / `PLATFORM_BUILD_CALLBACK_TOKEN`.
|
||||
|
||||
Install: `go install github.com/hanzoai/cloud/cmd/hanzo@latest`, or `brew install hanzoai/tap/hanzo`.
|
||||
|
||||
## Specs
|
||||
|
||||
Implements:
|
||||
- HIP-0014 Application Deployment
|
||||
- HIP-0026 IAM
|
||||
- HIP-0027 KMS
|
||||
- HIP-0037 AI Cloud Platform
|
||||
- HIP-0105 In-Process Extension Runtime
|
||||
- HIP-0106 Unified Cloud Binary
|
||||
- HIP-0302 Encrypted SQLite + ZapDB Durability
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
api.{tenant}.{brand}
|
||||
|
|
||||
hanzoai/cloud (one Go binary)
|
||||
|
|
||||
+----------+----------+----------+----------+----------+
|
||||
| iam | base | kms | ai | gateway | ...
|
||||
| Mount() | Mount() | Mount() | Mount() | Mount() |
|
||||
+----------+----------+----------+----------+----------+
|
||||
per-tenant SQLite (HIP-0302) | Hanzo IAM JWKS (HIP-0026)
|
||||
replicate -> S3 (HIP-0107) | ZAP inter-subsystem RPC
|
||||
```
|
||||
|
||||
Every subsystem exposes `func Mount(app *zip.App, deps cloud.Deps) error`. White-label fork pattern: customers fork this repo to launch their own ecosystem.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Hanzo Cloud
|
||||
|
||||
The unified Go binary that imports every Hanzo-native subsystem and dispatches
|
||||
@@ -40,6 +120,48 @@ deployment configuration.
|
||||
[hanzoai/zip](https://github.com/hanzoai/zip) — Sinatra-style Go web framework
|
||||
built on Fiber v3. The ONE Go web framework. No `.Fast` escape hatch.
|
||||
|
||||
## Console UI — embedded in the ONE binary
|
||||
|
||||
The same `hanzoai/cloud` binary serves the [console](https://github.com/hanzoai/console2)
|
||||
(`@hanzo/gui`) UI at the web root AND the `/v1` API from one process — one
|
||||
artifact, one origin, no separate console Service. The UI is compiled in via
|
||||
`//go:embed` (see `webui.go`).
|
||||
|
||||
Pipeline (in the `Dockerfile`, before `go build`):
|
||||
|
||||
```
|
||||
console stage → build console2 static bundle → /out
|
||||
COPY --from=console /out/ → src/webui/dist/ (overlays the fallback shell)
|
||||
build stage → go build → //go:embed all:webui/dist bakes it into /cloud
|
||||
```
|
||||
|
||||
Serving (`webui.go`, registered LAST in `Serve` so it never shadows the API):
|
||||
|
||||
- `GET /` and any client-side route (`/orgs`, `/models`, …) → the SPA shell
|
||||
(`index.html`) with `Cache-Control: no-cache`; fingerprinted assets under
|
||||
`assets/`/`_next/` are served `immutable` for a year, with brotli/gzip
|
||||
precompressed negotiation when the build emits `.br`/`.gz` siblings.
|
||||
- `GET /v1/*` (and `/zap`, `/healthz`, …) → the API. Real subsystem routes are
|
||||
registered before the console catch-all, so they always win; an **unmatched**
|
||||
path under an API prefix returns a real 404 (JSON namespace), never HTML.
|
||||
- Same-origin: the embedded console calls `/v1` on its own host, so the session
|
||||
cookie is first-party — no second origin, no CORS.
|
||||
|
||||
`webui/dist/index.html` is a committed **fallback shell** (a real same-origin
|
||||
`/v1` bootstrap) so `go build` always compiles and the binary always serves a UI
|
||||
even without the Node toolchain. The image build overwrites `webui/dist` with the
|
||||
real console bundle. See `webui_test.go` for the boot-and-assert tests
|
||||
(`/` → shell, deep link → shell 200, `/v1/*` → API, unmatched `/v1` → 404).
|
||||
|
||||
> Honest current state: console2 ships 15 Next server route handlers
|
||||
> (`app/**/route.ts`) that hold KMS-sourced service tokens and mint short-lived
|
||||
> user tokens, so it emits a Node server bundle, not a static export
|
||||
> (`output: export` would fail). Until console2 exposes a `build:embed` static
|
||||
> target — or those handlers land here as native `/v1` endpoints — the image
|
||||
> embeds the fallback shell, and the separate console2 Service stays up. The Go
|
||||
> embed/serve plumbing is complete and needs no further change to light up the
|
||||
> full console the moment the static bundle exists.
|
||||
|
||||
## Status
|
||||
|
||||
Scaffold. The Mount(app, deps) integration for each subsystem lands per
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Email security@hanzo.ai with details. Encrypt with our PGP key (fingerprint TBD).
|
||||
|
||||
We respond within 48 hours. Critical issues receive same-day acknowledgment.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers code in this repository. For the broader Hanzo platform threat model, see [hanzoai/HIPs](https://github.com/hanzoai/HIPs).
|
||||
|
||||
## Sandbox boundary
|
||||
|
||||
`cloud` is the unified Hanzo Go binary that hosts multiple subsystems as in-process Go packages. Tenant isolation is enforced at the request boundary (JWT-validated `X-Org-Id`) and at the storage layer (per-tenant SQLite/ZapDB files with per-org KMS-derived DEKs); user-supplied extension code runs only inside the HIP-0105 in-process runtimes.
|
||||
|
||||
For runtime sandbox guarantees, see HIP-0105 (in-process extension runtimes).
|
||||
@@ -0,0 +1,623 @@
|
||||
package audit
|
||||
|
||||
// Tests for the tamper-evident audit chain. They exercise the REAL SQLite store
|
||||
// (an on-disk temp db, not a mock) so the append-only write path, the hash-chain
|
||||
// math, the verifier, redaction, and the filtered query are all proven
|
||||
// end-to-end. The headline test is tamper-detection: a record edited directly in
|
||||
// the database is DETECTED as breaking the chain.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// openTemp opens a Recorder backed by a fresh on-disk SQLite file (not :memory:,
|
||||
// because tamper tests re-open the same file via a second connection to edit it
|
||||
// out-of-band — exactly what an attacker with DB access would do).
|
||||
func openTemp(t *testing.T) (*Recorder, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
return rec, path
|
||||
}
|
||||
|
||||
// sampleRecord is a representative security event (a global-admin org deletion).
|
||||
func sampleRecord(action string) Record {
|
||||
return Record{
|
||||
Time: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC),
|
||||
Actor: Actor{Org: "admin", Sub: "z@hanzo.ai", Email: "z@hanzo.ai"},
|
||||
Action: action,
|
||||
Resource: Resource{Type: "org", ID: "acme"},
|
||||
Auth: AuthContext{Method: "jwt", IsAdmin: true},
|
||||
Outcome: Outcome{Result: "success", Status: 200},
|
||||
SourceIP: "203.0.113.7",
|
||||
UserAgent: "console2",
|
||||
RequestID: "req-123",
|
||||
Method: "DELETE",
|
||||
Path: "/v1/admin/orgs/acme",
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_AppendSealsAndLinks proves each appended record gets a monotonic seq,
|
||||
// links its PrevHash to the previous record's Hash, and starts from the genesis
|
||||
// anchor.
|
||||
func TestChain_AppendSealsAndLinks(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
r0, err := rec.Append(ctx, sampleRecord("DELETE /v1/admin/orgs"))
|
||||
if err != nil {
|
||||
t.Fatalf("append 0: %v", err)
|
||||
}
|
||||
if r0.Seq != 0 {
|
||||
t.Fatalf("first seq = %d, want 0", r0.Seq)
|
||||
}
|
||||
if r0.PrevHash != genesisPrevHash {
|
||||
t.Fatalf("genesis prev = %q, want %q", r0.PrevHash, genesisPrevHash)
|
||||
}
|
||||
if r0.Hash == "" || r0.Hash == genesisPrevHash {
|
||||
t.Fatalf("hash not computed: %q", r0.Hash)
|
||||
}
|
||||
|
||||
r1, err := rec.Append(ctx, sampleRecord("POST /v1/admin/roles"))
|
||||
if err != nil {
|
||||
t.Fatalf("append 1: %v", err)
|
||||
}
|
||||
if r1.Seq != 1 {
|
||||
t.Fatalf("second seq = %d, want 1", r1.Seq)
|
||||
}
|
||||
if r1.PrevHash != r0.Hash {
|
||||
t.Fatalf("link broken: r1.prev=%q, r0.hash=%q", r1.PrevHash, r0.Hash)
|
||||
}
|
||||
if r1.Hash == r0.Hash {
|
||||
t.Fatal("distinct records must have distinct hashes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_PassesOnUntamperedChain proves a well-formed chain verifies OK.
|
||||
func TestVerify_PassesOnUntamperedChain(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 25; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
integrity, err := rec.Verify(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if !integrity.OK {
|
||||
t.Fatalf("chain not OK: broken at %d (%s)", integrity.BrokenAt, integrity.Reason)
|
||||
}
|
||||
if integrity.Count != 25 {
|
||||
t.Fatalf("count = %d, want 25", integrity.Count)
|
||||
}
|
||||
if integrity.BrokenAt != -1 {
|
||||
t.Fatalf("brokenAt = %d, want -1 on a good chain", integrity.BrokenAt)
|
||||
}
|
||||
// Head must equal the last record's hash.
|
||||
count, head := rec.Head()
|
||||
if count != 25 || head != integrity.HeadHash {
|
||||
t.Fatalf("head mismatch: (%d,%q) vs verify (%d,%q)", count, head, integrity.Count, integrity.HeadHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_DetectsFieldTamper is the headline: an attacker with direct DB
|
||||
// access edits a record's content (flips a denied outcome to success, or changes
|
||||
// the actor). The stored hash no longer matches the recomputed hash, so Verify
|
||||
// reports the exact seq where the chain breaks. THIS is the tamper-evidence
|
||||
// property — an audit trail that can be silently forged is worse than none.
|
||||
func TestVerify_DetectsFieldTamper(t *testing.T) {
|
||||
rec, path := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("DELETE /v1/admin/orgs")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Sanity: clean chain verifies.
|
||||
if iv, _ := rec.Verify(ctx); !iv.OK {
|
||||
t.Fatalf("precondition: clean chain should verify, broke at %d", iv.BrokenAt)
|
||||
}
|
||||
|
||||
// Tamper OUT OF BAND — a second connection issues an UPDATE the application
|
||||
// never would. This models an attacker who owns the file / a rogue DBA.
|
||||
tamperOutOfBand(t, path, `UPDATE audit_log SET actor_sub='attacker', result='success' WHERE seq=4`)
|
||||
|
||||
iv, err := rec.Verify(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify after tamper: %v", err)
|
||||
}
|
||||
if iv.OK {
|
||||
t.Fatal("TAMPER NOT DETECTED — a modified record verified as OK; the chain is forgeable")
|
||||
}
|
||||
if iv.BrokenAt != 4 {
|
||||
t.Fatalf("brokenAt = %d, want 4 (the edited record)", iv.BrokenAt)
|
||||
}
|
||||
if !strings.Contains(iv.Reason, "hash mismatch") {
|
||||
t.Fatalf("reason = %q, want a hash-mismatch explanation", iv.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_DetectsDeletion proves deleting a record (or a contiguous run) breaks
|
||||
// the chain: the record after the hole has a PrevHash that no longer matches the
|
||||
// now-preceding record, and the seq sequence gaps. Either way Verify flags it.
|
||||
func TestVerify_DetectsDeletion(t *testing.T) {
|
||||
rec, path := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/roles")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Delete a MIDDLE record — the classic "cover your tracks" edit.
|
||||
tamperOutOfBand(t, path, `DELETE FROM audit_log WHERE seq=5`)
|
||||
|
||||
iv, err := rec.Verify(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify after delete: %v", err)
|
||||
}
|
||||
if iv.OK {
|
||||
t.Fatal("DELETION NOT DETECTED — a removed record left the chain verifying OK")
|
||||
}
|
||||
// The break is observed at seq 6 (the record whose predecessor vanished): its
|
||||
// seq no longer follows the running counter (5 is missing), so the gap check
|
||||
// fires first at 6.
|
||||
if iv.BrokenAt != 6 {
|
||||
t.Fatalf("brokenAt = %d, want 6 (record after the hole)", iv.BrokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_DetectsReorder proves swapping two records' positions (an attacker
|
||||
// trying to reorder events) breaks the prev-hash linkage.
|
||||
func TestVerify_DetectsReorder(t *testing.T) {
|
||||
rec, path := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 6; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/kms/secrets")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Swap the hashes of seq 2 and seq 3 (content stays, linkage corrupts). Any
|
||||
// out-of-band shuffle that doesn't recompute the WHOLE suffix is detectable.
|
||||
tamperOutOfBand(t, path, `
|
||||
UPDATE audit_log SET hash = (SELECT hash FROM audit_log WHERE seq=3) WHERE seq=2;`)
|
||||
|
||||
iv, _ := rec.Verify(ctx)
|
||||
if iv.OK {
|
||||
t.Fatal("REORDER/HASH-SWAP NOT DETECTED")
|
||||
}
|
||||
if iv.BrokenAt < 0 {
|
||||
t.Fatalf("expected a break, got brokenAt=%d", iv.BrokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_RestartContinues proves a re-opened store continues the SAME chain
|
||||
// (recovers seq + head) rather than forking — so a pod restart cannot silently
|
||||
// reset the trail.
|
||||
func TestChain_RestartContinues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
ctx := context.Background()
|
||||
|
||||
rec1, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open 1: %v", err)
|
||||
}
|
||||
var lastHash string
|
||||
for i := 0; i < 5; i++ {
|
||||
r, err := rec1.Append(ctx, sampleRecord("POST /v1/admin/sync"))
|
||||
if err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
lastHash = r.Hash
|
||||
}
|
||||
_ = rec1.Close()
|
||||
|
||||
rec2, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open 2: %v", err)
|
||||
}
|
||||
defer func() { _ = rec2.Close() }()
|
||||
|
||||
count, head := rec2.Head()
|
||||
if count != 5 {
|
||||
t.Fatalf("recovered count = %d, want 5", count)
|
||||
}
|
||||
if head != lastHash {
|
||||
t.Fatalf("recovered head = %q, want %q", head, lastHash)
|
||||
}
|
||||
// The next append must chain onto the recovered head at seq 5.
|
||||
r5, err := rec2.Append(ctx, sampleRecord("DELETE /v1/admin/orgs"))
|
||||
if err != nil {
|
||||
t.Fatalf("append after restart: %v", err)
|
||||
}
|
||||
if r5.Seq != 5 || r5.PrevHash != lastHash {
|
||||
t.Fatalf("chain did not continue: seq=%d prev=%q (want seq 5 prev %q)", r5.Seq, r5.PrevHash, lastHash)
|
||||
}
|
||||
// And the whole continued chain still verifies.
|
||||
if iv, _ := rec2.Verify(ctx); !iv.OK {
|
||||
t.Fatalf("continued chain broke at %d (%s)", iv.BrokenAt, iv.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedact_StripsSecrets proves the redactor removes credential-bearing fields
|
||||
// (by key name, recursively) while keeping non-secret structure — so an explicit
|
||||
// emit point's before/after can never carry a password/token/key.
|
||||
func TestRedact_StripsSecrets(t *testing.T) {
|
||||
in := json.RawMessage(`{
|
||||
"name": "acme",
|
||||
"password": "hunter2",
|
||||
"apiKey": "sk-live-abc123",
|
||||
"passphrase": "correct horse",
|
||||
"wgPrivKey": "PRIVKEYBYTES",
|
||||
"recoveryPhrase": "twelve words here",
|
||||
"socialSecurityNumber": "078-05-1120",
|
||||
"config": {
|
||||
"clientSecret": "shh",
|
||||
"endpoint": "https://api.example.com",
|
||||
"nested": {"private_key": "-----BEGIN-----", "region": "sfo3"}
|
||||
},
|
||||
"tokens": ["t1", "t2"],
|
||||
"roles": ["admin", "viewer"]
|
||||
}`)
|
||||
out := Redact(in)
|
||||
|
||||
s := string(out)
|
||||
// Secrets gone (incl. the edge-case key names: passphrase, privkey, phrase, ssn).
|
||||
for _, leak := range []string{"hunter2", "sk-live-abc123", "shh", "BEGIN",
|
||||
"correct horse", "PRIVKEYBYTES", "twelve words here", "078-05-1120"} {
|
||||
if strings.Contains(s, leak) {
|
||||
t.Fatalf("secret leaked through redaction: %q still present in %s", leak, s)
|
||||
}
|
||||
}
|
||||
// Non-secret structure preserved.
|
||||
for _, keep := range []string{"acme", "https://api.example.com", "sfo3", "viewer"} {
|
||||
if !strings.Contains(s, keep) {
|
||||
t.Fatalf("redaction dropped a non-secret value %q: %s", keep, s)
|
||||
}
|
||||
}
|
||||
// The redaction marker appears where secrets were.
|
||||
if !strings.Contains(s, redactedMarker) {
|
||||
t.Fatalf("no redaction marker in output: %s", s)
|
||||
}
|
||||
// "tokens" is a secret key → the whole array is redacted (not its elements).
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(out, &decoded); err != nil {
|
||||
t.Fatalf("redacted output is not valid JSON: %v", err)
|
||||
}
|
||||
if decoded["tokens"] != redactedMarker {
|
||||
t.Fatalf("secret-keyed array not redacted whole: %v", decoded["tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedact_FailsClosedOnBadJSON proves unparseable input is never echoed back.
|
||||
func TestRedact_FailsClosedOnBadJSON(t *testing.T) {
|
||||
out := Redact(json.RawMessage(`{not valid json, password=hunter2`))
|
||||
if strings.Contains(string(out), "hunter2") {
|
||||
t.Fatalf("bad JSON echoed a secret: %s", out)
|
||||
}
|
||||
if !strings.Contains(string(out), redactedMarker) {
|
||||
t.Fatalf("bad JSON should redact to a marker, got %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuery_Filters proves the filtered read returns the right subset by actor,
|
||||
// action, resource, and result, newest-first, with an accurate total.
|
||||
func TestQuery_Filters(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mk := func(org, action, res, result string) Record {
|
||||
r := sampleRecord(action)
|
||||
r.Actor.Org = org
|
||||
r.Resource.Type = res
|
||||
r.Outcome.Result = result
|
||||
return r
|
||||
}
|
||||
// A mixed set.
|
||||
seed := []Record{
|
||||
mk("admin", "DELETE /v1/admin/orgs", "org", "success"),
|
||||
mk("acme", "POST /v1/base/records", "records", "success"),
|
||||
mk("admin", "POST /v1/admin/roles", "roles", "deny"),
|
||||
mk("admin", "DELETE /v1/admin/orgs", "org", "success"),
|
||||
mk("acme", "POST /v1/kms/secrets", "secrets", "error"),
|
||||
}
|
||||
for i, r := range seed {
|
||||
if _, err := rec.Append(ctx, r); err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by org=admin → 3 rows.
|
||||
rows, total, err := rec.Query(ctx, Filter{Org: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("query org: %v", err)
|
||||
}
|
||||
if total != 3 || len(rows) != 3 {
|
||||
t.Fatalf("org=admin: got %d rows, total %d, want 3/3", len(rows), total)
|
||||
}
|
||||
// Newest first: the last-appended admin row (seq 3) comes before seq 2, 0.
|
||||
if rows[0].Seq < rows[len(rows)-1].Seq {
|
||||
t.Fatalf("not newest-first: %d..%d", rows[0].Seq, rows[len(rows)-1].Seq)
|
||||
}
|
||||
|
||||
// Filter by result=deny → 1 row (the 403-style role change).
|
||||
denies, dtotal, err := rec.Query(ctx, Filter{Result: "deny"})
|
||||
if err != nil {
|
||||
t.Fatalf("query deny: %v", err)
|
||||
}
|
||||
if dtotal != 1 || len(denies) != 1 || denies[0].Action != "POST /v1/admin/roles" {
|
||||
t.Fatalf("result=deny: got %d (%+v), want 1 role-change", dtotal, denies)
|
||||
}
|
||||
|
||||
// Filter by resource=secrets → 1 row.
|
||||
secs, stotal, err := rec.Query(ctx, Filter{Resource: "secrets"})
|
||||
if err != nil {
|
||||
t.Fatalf("query resource: %v", err)
|
||||
}
|
||||
if stotal != 1 || len(secs) != 1 {
|
||||
t.Fatalf("resource=secrets: got %d, want 1", stotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuery_SQLInjectionInFilterIsInert proves a malicious filter value is a
|
||||
// parameter, never SQL: it simply matches nothing and cannot drop the table.
|
||||
func TestQuery_SQLInjectionInFilterIsInert(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
inject := Filter{Org: "admin'; DROP TABLE audit_log;--"}
|
||||
rows, total, err := rec.Query(ctx, inject)
|
||||
if err != nil {
|
||||
t.Fatalf("query should not error on injection attempt: %v", err)
|
||||
}
|
||||
if total != 0 || len(rows) != 0 {
|
||||
t.Fatalf("injection value matched %d rows, want 0", total)
|
||||
}
|
||||
// The table survived — a normal query still returns the seeded rows.
|
||||
if _, all, err := rec.Query(ctx, Filter{}); err != nil || all != 3 {
|
||||
t.Fatalf("table damaged by injection attempt: all=%d err=%v", all, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_ConcurrentAppendsStayGapless proves the serialized writer keeps the
|
||||
// chain a true, gapless total order under CONCURRENT appends: many goroutines
|
||||
// append at once, and the resulting chain must have every seq 0..N-1 exactly once
|
||||
// AND verify. A race in the head/seq handoff would surface as a duplicate seq (a
|
||||
// PRIMARY KEY error), a gap, or a broken link — all of which this catches.
|
||||
func TestChain_ConcurrentAppendsStayGapless(t *testing.T) {
|
||||
rec, _ := openTemp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const goroutines, per = 16, 20
|
||||
total := goroutines * per
|
||||
errCh := make(chan error, total)
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < per; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Fatalf("concurrent append failed (race in seq/head handoff?): %v", err)
|
||||
}
|
||||
|
||||
// The chain must verify and contain exactly `total` gapless records.
|
||||
iv, err := rec.Verify(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !iv.OK {
|
||||
t.Fatalf("concurrent chain broke at %d (%s)", iv.BrokenAt, iv.Reason)
|
||||
}
|
||||
if iv.Count != uint64(total) {
|
||||
t.Fatalf("recorded %d records, want %d (a lost/duplicated append)", iv.Count, total)
|
||||
}
|
||||
}
|
||||
|
||||
// checkpointMirror is a Mirror that also captures checkpoints (implements
|
||||
// CheckpointSink) so the test can assert the head digest reaches an independent
|
||||
// sink.
|
||||
type checkpointMirror struct {
|
||||
mu sync.Mutex
|
||||
cps []Checkpoint
|
||||
}
|
||||
|
||||
func (m *checkpointMirror) Append(context.Context, Record) error { return nil }
|
||||
func (m *checkpointMirror) Checkpoint(_ context.Context, cp Checkpoint) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cps = append(m.cps, cp)
|
||||
return nil
|
||||
}
|
||||
func (m *checkpointMirror) last() (Checkpoint, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if len(m.cps) == 0 {
|
||||
return Checkpoint{}, false
|
||||
}
|
||||
return m.cps[len(m.cps)-1], true
|
||||
}
|
||||
|
||||
// TestCheckpoint_EmitsHeadDigest proves the AU-9 anchor: the periodic checkpoint
|
||||
// emits the current (count, head) to the log function AND, when the mirror is a
|
||||
// CheckpointSink, to the independent digest store — and a final checkpoint fires
|
||||
// on Close. This is what an external monitor compares to detect tail-truncation.
|
||||
func TestCheckpoint_EmitsHeadDigest(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
mirror := &checkpointMirror{}
|
||||
rec, err := Open(path, mirror)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
var logged []Checkpoint
|
||||
var lmu sync.Mutex
|
||||
// every=0 → no ticker; we drive checkpoints via Close (final) + a manual tick.
|
||||
rec.StartCheckpoints(0, func(cp Checkpoint) {
|
||||
lmu.Lock()
|
||||
logged = append(logged, cp)
|
||||
lmu.Unlock()
|
||||
})
|
||||
|
||||
for i := 0; i < 7; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Close emits the FINAL checkpoint (count=7, head=chain head).
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
lmu.Lock()
|
||||
n := len(logged)
|
||||
var lastLogged Checkpoint
|
||||
if n > 0 {
|
||||
lastLogged = logged[n-1]
|
||||
}
|
||||
lmu.Unlock()
|
||||
if n == 0 {
|
||||
t.Fatal("no checkpoint logged (Close should emit a final head digest)")
|
||||
}
|
||||
if lastLogged.Count != 7 {
|
||||
t.Errorf("final checkpoint count = %d, want 7", lastLogged.Count)
|
||||
}
|
||||
if lastLogged.Head == "" || lastLogged.Head == genesisPrevHash {
|
||||
t.Errorf("final checkpoint head not set: %q", lastLogged.Head)
|
||||
}
|
||||
// The independent sink also received the final digest.
|
||||
if cp, ok := mirror.last(); !ok || cp.Count != 7 {
|
||||
t.Errorf("checkpoint sink final = %+v (ok=%v), want count 7", cp, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpoint_DoubleStartIsSafe proves a second StartCheckpoints call is
|
||||
// ignored (no re-arm, no field/WaitGroup race) — the Red-review robustness fix.
|
||||
// Run under -race to catch a regression.
|
||||
func TestCheckpoint_DoubleStartIsSafe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
rec.StartCheckpoints(time.Hour, func(Checkpoint) {})
|
||||
rec.StartCheckpoints(time.Hour, func(Checkpoint) {}) // second call must be a no-op.
|
||||
// Append + close must not race or hang.
|
||||
if _, err := rec.Append(context.Background(), sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpoint_CloseSyncsToSink proves the FINAL checkpoint on Close reaches the
|
||||
// independent sink SYNCHRONOUSLY (the Red-review durability fix) — the sink has
|
||||
// the final count before Close returns, not on a detached goroutine that might
|
||||
// not run before process exit.
|
||||
func TestCheckpoint_CloseSyncsToSink(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
mirror := &checkpointMirror{}
|
||||
rec, err := Open(path, mirror)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
rec.StartCheckpoints(0, func(Checkpoint) {}) // no ticker; only the on-close checkpoint.
|
||||
for i := 0; i < 4; i++ {
|
||||
if _, err := rec.Append(context.Background(), sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
// Immediately after Close returns (no sleep), the sink MUST already have the
|
||||
// final digest — proving the Close-path write was synchronous.
|
||||
cp, ok := mirror.last()
|
||||
if !ok || cp.Count != 4 {
|
||||
t.Fatalf("sink final checkpoint = %+v (ok=%v), want count 4 synchronously on Close", cp, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpoint_CountMonotonicDetectsTruncation demonstrates the DETECTION an
|
||||
// external monitor performs: consecutive checkpoints have non-decreasing Count;
|
||||
// after a tail truncation the head reported by Head() drops below a prior
|
||||
// checkpoint — the signal the o11y alert fires on.
|
||||
func TestCheckpoint_CountMonotonicDetectsTruncation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := rec.Append(ctx, sampleRecord("POST /v1/admin/sync")); err != nil {
|
||||
t.Fatalf("append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
before, _ := rec.Head() // the monitor's last pinned checkpoint count.
|
||||
if before != 10 {
|
||||
t.Fatalf("pre-truncation count = %d, want 10", before)
|
||||
}
|
||||
_ = rec.Close()
|
||||
|
||||
// Attacker truncates the tail (deletes the last 4 records) out of band.
|
||||
tamperOutOfBand(t, path, `DELETE FROM audit_log WHERE seq >= 6`)
|
||||
|
||||
rec2, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer func() { _ = rec2.Close() }()
|
||||
after, _ := rec2.Head()
|
||||
// The internal chain still verifies (a truncated prefix is self-consistent)…
|
||||
if iv, _ := rec2.Verify(ctx); !iv.OK {
|
||||
t.Fatalf("truncated prefix should self-verify, broke at %d", iv.BrokenAt)
|
||||
}
|
||||
// …but the count REGRESSED vs the pinned checkpoint — the truncation signal.
|
||||
if after >= before {
|
||||
t.Fatalf("count did not regress after truncation: before=%d after=%d", before, after)
|
||||
}
|
||||
t.Logf("truncation detected by count regression: %d → %d (chain-internal verify is OK; external anchor catches it)", before, after)
|
||||
}
|
||||
|
||||
// tamperOutOfBand opens the SAME sqlite file on a SEPARATE connection and runs a
|
||||
// mutating statement the audit application itself never issues — modeling an
|
||||
// attacker with direct database/file access. The Recorder's own connection is
|
||||
// unaffected; Verify then re-reads and must catch the damage.
|
||||
func tamperOutOfBand(t *testing.T, path, stmt string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("tamper open: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
t.Fatalf("tamper exec %q: %v", stmt, err)
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
package audit
|
||||
|
||||
// The read paths: filtered Query (for /v1/admin/audit) and Verify (the
|
||||
// tamper-evidence walk for /v1/admin/audit/verify). Both are read-only — they
|
||||
// issue SELECT only, never mutate — so exposing them can never weaken the
|
||||
// append-only property.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Filter narrows a Query. Zero-value fields are ignored (no constraint), so an
|
||||
// empty Filter returns the most-recent Limit records. Time bounds are inclusive
|
||||
// and compared against the RFC3339Nano ts column lexicographically (RFC3339 is
|
||||
// order-preserving as text, so a string range is a correct time range).
|
||||
type Filter struct {
|
||||
Org string // actor_org exact match (tenant scope)
|
||||
Sub string // actor_sub exact match (a specific user)
|
||||
Action string // action exact match
|
||||
Resource string // res_type exact match
|
||||
Result string // outcome result: success|deny|error
|
||||
Since time.Time // ts >= Since (UTC)
|
||||
Until time.Time // ts <= Until (UTC)
|
||||
Limit int // max rows (default 100, cap 1000)
|
||||
Offset int // pagination offset
|
||||
}
|
||||
|
||||
// Query returns records matching f, newest first, and the total count matching
|
||||
// the same predicate (ignoring Limit/Offset) for pagination. All predicates are
|
||||
// parameterized — never string-interpolated — so a filter value can never inject
|
||||
// SQL. Column names in the WHERE come from a fixed allowlist below, not caller
|
||||
// input.
|
||||
func (r *Recorder) Query(ctx context.Context, f Filter) (rows []Record, total int, err error) {
|
||||
where, args := f.build()
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
offset := f.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
countQ := `SELECT COUNT(*) FROM audit_log` + where
|
||||
if err = r.db.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: count: %w", err)
|
||||
}
|
||||
|
||||
listQ := `SELECT ` + selectCols + ` FROM audit_log` + where +
|
||||
` ORDER BY seq DESC LIMIT ? OFFSET ?`
|
||||
listArgs := append(append([]any{}, args...), limit, offset)
|
||||
rs, err := r.db.QueryContext(ctx, listQ, listArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: query: %w", err)
|
||||
}
|
||||
defer func() { _ = rs.Close() }()
|
||||
for rs.Next() {
|
||||
rec, scanErr := scanRecord(rs)
|
||||
if scanErr != nil {
|
||||
return nil, 0, fmt.Errorf("audit: scan: %w", scanErr)
|
||||
}
|
||||
rows = append(rows, rec)
|
||||
}
|
||||
return rows, total, rs.Err()
|
||||
}
|
||||
|
||||
// build assembles the parameterized WHERE clause from the non-zero filter
|
||||
// fields. Each fragment uses a fixed column name and a ? placeholder, so no
|
||||
// caller value ever reaches the SQL text.
|
||||
func (f Filter) build() (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
add := func(frag string, val any) {
|
||||
conds = append(conds, frag)
|
||||
args = append(args, val)
|
||||
}
|
||||
if f.Org != "" {
|
||||
add("actor_org = ?", f.Org)
|
||||
}
|
||||
if f.Sub != "" {
|
||||
add("actor_sub = ?", f.Sub)
|
||||
}
|
||||
if f.Action != "" {
|
||||
add("action = ?", f.Action)
|
||||
}
|
||||
if f.Resource != "" {
|
||||
add("res_type = ?", f.Resource)
|
||||
}
|
||||
if f.Result != "" {
|
||||
add("result = ?", f.Result)
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
add("ts >= ?", f.Since.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
add("ts <= ?", f.Until.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
const selectCols = `seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
|
||||
auth_method, is_admin, result, status, reason, source_ip, user_agent,
|
||||
request_id, method, path, before, after, prev_hash, hash`
|
||||
|
||||
// scanRecord reconstructs a Record from a row of selectCols.
|
||||
func scanRecord(sc interface{ Scan(...any) error }) (Record, error) {
|
||||
var (
|
||||
rec Record
|
||||
ts string
|
||||
isAdmin int
|
||||
before, after string
|
||||
)
|
||||
if err := sc.Scan(
|
||||
&rec.Seq, &ts, &rec.Actor.Org, &rec.Actor.Sub, &rec.Actor.Email,
|
||||
&rec.Action, &rec.Resource.Type, &rec.Resource.ID,
|
||||
&rec.Auth.Method, &isAdmin, &rec.Outcome.Result, &rec.Outcome.Status, &rec.Outcome.Reason,
|
||||
&rec.SourceIP, &rec.UserAgent, &rec.RequestID, &rec.Method, &rec.Path,
|
||||
&before, &after, &rec.PrevHash, &rec.Hash,
|
||||
); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
|
||||
rec.Time = t
|
||||
}
|
||||
rec.Auth.IsAdmin = isAdmin != 0
|
||||
if before != "" {
|
||||
rec.Before = json.RawMessage(before)
|
||||
}
|
||||
if after != "" {
|
||||
rec.After = json.RawMessage(after)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Integrity is the result of a Verify walk — the AU-9 evidence that the trail has
|
||||
// not been tampered with.
|
||||
type Integrity struct {
|
||||
// OK is true iff every record's stored hash equals the recomputed hash AND the
|
||||
// chain links are continuous (each PrevHash == the prior record's Hash, seqs
|
||||
// gapless from 0).
|
||||
OK bool `json:"ok"`
|
||||
// Count is the number of records walked.
|
||||
Count uint64 `json:"count"`
|
||||
// HeadHash is the hash of the last record (or the genesis anchor for an empty
|
||||
// chain). Pin this externally over time to detect tail-truncation.
|
||||
HeadHash string `json:"headHash"`
|
||||
// BrokenAt is the seq of the FIRST record that failed verification, or -1 when
|
||||
// OK. Reason describes the break (recomputed-hash mismatch, prev-hash
|
||||
// discontinuity, or a seq gap).
|
||||
BrokenAt int64 `json:"brokenAt"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Verify walks the entire chain in seq order, recomputing each record's hash from
|
||||
// its content + the running prev-hash and checking continuity. It is the
|
||||
// tamper-detector: any modification (a changed field re-hashes differently), any
|
||||
// deletion or reordering (a seq gap or a broken prev-hash link), or a forged row
|
||||
// (its recomputed hash won't match unless the attacker also recomputed the entire
|
||||
// suffix — which they cannot do without re-inserting every subsequent record) is
|
||||
// reported with the exact seq where the chain first breaks.
|
||||
//
|
||||
// Complexity is O(n) over the records; for very large trails this streams row by
|
||||
// row (no full materialization). At cloud's audit volume this is fine; if a trail
|
||||
// grows past what an on-demand full walk should touch, verify a seq WINDOW
|
||||
// (Verify is easily extended with a bound) or rely on the externally-pinned head.
|
||||
func (r *Recorder) Verify(ctx context.Context) (Integrity, error) {
|
||||
rs, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM audit_log ORDER BY seq ASC`)
|
||||
if err != nil {
|
||||
return Integrity{}, fmt.Errorf("audit: verify query: %w", err)
|
||||
}
|
||||
defer func() { _ = rs.Close() }()
|
||||
|
||||
prevHash := genesisPrevHash
|
||||
var expectSeq uint64
|
||||
var count uint64
|
||||
headHash := genesisPrevHash
|
||||
|
||||
for rs.Next() {
|
||||
rec, scanErr := scanRecord(rs)
|
||||
if scanErr != nil {
|
||||
return Integrity{}, fmt.Errorf("audit: verify scan: %w", scanErr)
|
||||
}
|
||||
// Gapless, 0-based ordering.
|
||||
if rec.Seq != expectSeq {
|
||||
return Integrity{
|
||||
OK: false, Count: count, HeadHash: headHash,
|
||||
BrokenAt: int64(rec.Seq),
|
||||
Reason: fmt.Sprintf("seq gap: expected %d, got %d", expectSeq, rec.Seq),
|
||||
}, nil
|
||||
}
|
||||
// Link continuity: this record must chain to the previous record's hash.
|
||||
if rec.PrevHash != prevHash {
|
||||
return Integrity{
|
||||
OK: false, Count: count, HeadHash: headHash,
|
||||
BrokenAt: int64(rec.Seq),
|
||||
Reason: "prev_hash discontinuity (a record was deleted, reordered, or altered)",
|
||||
}, nil
|
||||
}
|
||||
// Content integrity: recompute the hash from the record's own fields.
|
||||
want, hErr := computeHash(rec, rec.PrevHash)
|
||||
if hErr != nil {
|
||||
return Integrity{}, fmt.Errorf("audit: verify hash: %w", hErr)
|
||||
}
|
||||
if want != rec.Hash {
|
||||
return Integrity{
|
||||
OK: false, Count: count, HeadHash: headHash,
|
||||
BrokenAt: int64(rec.Seq),
|
||||
Reason: "hash mismatch (record content was modified after it was written)",
|
||||
}, nil
|
||||
}
|
||||
prevHash = rec.Hash
|
||||
headHash = rec.Hash
|
||||
expectSeq = rec.Seq + 1
|
||||
count++
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
return Integrity{}, fmt.Errorf("audit: verify rows: %w", err)
|
||||
}
|
||||
return Integrity{OK: true, Count: count, HeadHash: headHash, BrokenAt: -1}, nil
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
// Package audit is the unified cloud binary's compliance-grade audit trail —
|
||||
// tamper-evident, append-only, and complete over the security-relevant request
|
||||
// surface (FedRAMP AU-* / SOC 2 CC-* controls).
|
||||
//
|
||||
// THE CONTROL, IN ONE SENTENCE. Every security-relevant action against this
|
||||
// binary is captured as a structured Record, hash-chained to its predecessor so
|
||||
// any later deletion or modification is detectable, and written INLINE (never
|
||||
// dropped) to an append-only store the application can only INSERT into.
|
||||
//
|
||||
// THREE PIECES, EACH IN ITS LANE (orthogonal, per the Zen of Hanzo):
|
||||
// - record.go — the event model + the hash-chain math (what a record IS and
|
||||
// how it links to the one before it). Pure, no I/O.
|
||||
// - store.go — the append-only sink (SQLite primary, INSERT-only; a
|
||||
// best-effort OLAP mirror) and the serialized Recorder that
|
||||
// owns the chain head. All persistence.
|
||||
// - redact.go — the secret-stripping allowlist/denylist for any structured
|
||||
// before/after an explicit emit point supplies. No secret ever
|
||||
// reaches a record.
|
||||
//
|
||||
// The HTTP middleware (Middleware, in the cloud package) and the query/verify
|
||||
// endpoints (in clients/admin) are thin callers of this package. This package
|
||||
// holds the security logic; it has zero knowledge of routes.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Actor identifies WHO performed the action. It is populated ONLY from a
|
||||
// validated principal (the sanitized X-User-* headers SanitizeIdentity mints
|
||||
// from a verified IAM JWT), never from a raw client header — so an actor can
|
||||
// never be forged by the request that is being audited. A service principal
|
||||
// (M2M / no user sub) records Org with an empty Sub.
|
||||
type Actor struct {
|
||||
// Org is the tenant (IAM `owner`). Empty for an unauthenticated request.
|
||||
Org string `json:"org"`
|
||||
// Sub is the user id (IAM `sub`/`preferred_username`). Empty for a service
|
||||
// principal or an anonymous request.
|
||||
Sub string `json:"sub"`
|
||||
// Email is the validated user email, when present.
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// Resource identifies WHAT was acted upon: a type (e.g. "org", "role",
|
||||
// "secret", "provider-config", "credit") and its id. For a plain HTTP mutation
|
||||
// with no finer resource semantics, Type is the route family and ID is empty —
|
||||
// the Action verb + path already pin the object.
|
||||
type Resource struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// AuthContext records HOW the actor authenticated and what authority they held
|
||||
// at decision time — the AC-* evidence (was this a global admin? by what
|
||||
// credential?). Method is "jwt" | "api-key" | "none". IsAdmin is the VALIDATED
|
||||
// global-admin bit (owner == AdminOrg), never a raw X-User-IsAdmin.
|
||||
type AuthContext struct {
|
||||
Method string `json:"method"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// Outcome is the result of the action: whether it was allowed and what
|
||||
// happened. Result is "success" | "deny" | "error". Status is the HTTP status.
|
||||
// Reason is a short, non-sensitive explanation for a deny/error (e.g.
|
||||
// "global admin required", "insufficient_balance") — never a secret, never a
|
||||
// raw upstream error body.
|
||||
type Outcome struct {
|
||||
Result string `json:"result"`
|
||||
Status int `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Record is one audit event. The JSON tags ARE the on-disk and on-wire contract.
|
||||
//
|
||||
// Field order in the struct is deliberate but IRRELEVANT to the hash: the chain
|
||||
// hashes the CANONICAL (sorted-key) JSON of the record with Hash/PrevHash zeroed
|
||||
// (see canonicalBytes), so re-ordering fields or adding an omitempty field can
|
||||
// never change an existing record's hash.
|
||||
type Record struct {
|
||||
// Seq is the strictly-increasing chain position (0-based). It is assigned by
|
||||
// the Recorder under its lock, so it is a true total order with no gaps.
|
||||
Seq uint64 `json:"seq"`
|
||||
|
||||
// Time is the UTC event timestamp (RFC3339Nano).
|
||||
Time time.Time `json:"time"`
|
||||
|
||||
// Actor / Action / Resource / Auth / Outcome — the AU-3 "content of audit
|
||||
// records" core: who, what, on what, how-authenticated, with what result.
|
||||
Actor Actor `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Resource Resource `json:"resource"`
|
||||
Auth AuthContext `json:"auth"`
|
||||
Outcome Outcome `json:"outcome"`
|
||||
|
||||
// SourceIP + UserAgent — the AU-3 "source of the event" fields.
|
||||
SourceIP string `json:"sourceIp,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
|
||||
// RequestID correlates the record to the request-line log and any downstream
|
||||
// trace (the X-Request-Id the pipeline mints).
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
|
||||
// Method + Path are the HTTP verb and route for a request-sourced event.
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// Before / After capture a mutation's prior and resulting state for the
|
||||
// AU-required "before/after" on config-affecting changes. They are populated
|
||||
// ONLY by explicit emit points and ONLY after Redact has stripped secrets —
|
||||
// the HTTP middleware never sets them (it never reads bodies), so a secret in
|
||||
// a request body can never leak here. Raw JSON so any shape round-trips.
|
||||
Before json.RawMessage `json:"before,omitempty"`
|
||||
After json.RawMessage `json:"after,omitempty"`
|
||||
|
||||
// PrevHash is the hash of record Seq-1 (hex). For the genesis record (Seq 0)
|
||||
// it is genesisPrevHash. Hash is this record's hash. Neither participates in
|
||||
// its own hash computation (both are zeroed in canonicalBytes).
|
||||
PrevHash string `json:"prevHash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// genesisPrevHash is the PrevHash of the first record in a fresh chain: 32 zero
|
||||
// bytes, hex-encoded. A non-empty, fixed anchor so the genesis record's hash is
|
||||
// still a function of a known constant (not the empty string, which would be
|
||||
// indistinguishable from "field omitted").
|
||||
const genesisPrevHash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// canonicalBytes returns the deterministic byte string a record hashes over: the
|
||||
// record with Hash AND PrevHash zeroed, marshaled by encoding/json (which sorts
|
||||
// struct fields in declaration order and, critically, is stable for a given
|
||||
// struct — the SAME bytes on every machine and every run). Zeroing PrevHash here
|
||||
// means the hash covers only the record's OWN content; the link to the previous
|
||||
// record is added explicitly in computeHash by appending prevHash. This keeps
|
||||
// the two concerns separable and the math obvious.
|
||||
//
|
||||
// We marshal a copy with the two hash fields cleared rather than a parallel
|
||||
// struct so there is exactly ONE definition of a record's fields (DRY): add a
|
||||
// field to Record and it is covered by the hash automatically.
|
||||
func canonicalBytes(r Record) ([]byte, error) {
|
||||
r.Hash = ""
|
||||
r.PrevHash = ""
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
// computeHash returns the hex SHA-256 of (canonical(record) || prevHash-bytes).
|
||||
// The prevHash is folded in as its RAW hex string bytes — the exact value stored
|
||||
// in the record's PrevHash field — so the verifier reproduces it byte-for-byte
|
||||
// from stored data alone. Any change to the record's content OR to which record
|
||||
// precedes it changes this output, which is the whole tamper-evidence property.
|
||||
func computeHash(r Record, prevHash string) (string, error) {
|
||||
body, err := canonicalBytes(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := sha256.New()
|
||||
h.Write(body)
|
||||
h.Write([]byte(prevHash))
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// seal finalizes a record into position seq linked to prevHash: it stamps Seq
|
||||
// and PrevHash, computes Hash, and returns the sealed record ready to append.
|
||||
// The Recorder calls this under its lock so seq/prevHash reflect the true head.
|
||||
func seal(r Record, seq uint64, prevHash string) (Record, error) {
|
||||
r.Seq = seq
|
||||
r.PrevHash = prevHash
|
||||
hash, err := computeHash(r, prevHash)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
r.Hash = hash
|
||||
return r, nil
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package audit
|
||||
|
||||
// Secret redaction for the before/after captured on a mutation.
|
||||
//
|
||||
// THE RULE. An audit record must NEVER contain a credential — no password,
|
||||
// token, API key, private key, card number, or session secret. Two layers
|
||||
// enforce this:
|
||||
//
|
||||
// 1. The HTTP middleware captures METADATA ONLY (actor/action/resource/outcome).
|
||||
// It NEVER reads a request or response body, so a secret in a POST body can
|
||||
// never reach a record through the automatic path. This is the primary
|
||||
// guarantee: the code that can't see a secret can't leak one.
|
||||
//
|
||||
// 2. An EXPLICIT emit point that supplies structured before/after (e.g. a config
|
||||
// change diff) runs it through Redact first. Redact walks the JSON and
|
||||
// replaces the VALUE of any key whose name matches the secret denylist with a
|
||||
// fixed marker, recursively. It is deny-by-key-name — the same allowlist
|
||||
// PATTERN cloud already uses for user-secret redaction — chosen because a
|
||||
// mutation diff has arbitrary shape and key-name matching is the robust,
|
||||
// well-understood control (vs. trying to detect "secret-looking" values).
|
||||
//
|
||||
// Redact is conservative: on any structural surprise it returns the redaction
|
||||
// marker rather than the input, so a parser edge case fails CLOSED (no raw
|
||||
// passthrough).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// redactedMarker replaces every redacted value. A constant so tests and the
|
||||
// query UI recognize it unambiguously.
|
||||
const redactedMarker = "[REDACTED]"
|
||||
|
||||
// secretKeyParts are substrings that, when contained (case-insensitively) in a
|
||||
// JSON object key, mark that key's value as secret. Kept as a small, auditable
|
||||
// denylist of the credential-bearing field names that actually occur across the
|
||||
// Hanzo surface (IAM, KMS, commerce, provider config). Matching is substring so
|
||||
// "clientSecret", "api_key", "PRIVATE_KEY", "accessToken" all match.
|
||||
var secretKeyParts = []string{
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"api-key",
|
||||
"authorization",
|
||||
"auth_token",
|
||||
"private_key",
|
||||
"privatekey",
|
||||
"privkey", // privkey, wgPrivKey
|
||||
"passphrase",
|
||||
"client_secret",
|
||||
"credential",
|
||||
"session",
|
||||
"cookie",
|
||||
"card", // card_number, cardNumber
|
||||
"cvv",
|
||||
"cvc",
|
||||
"pin",
|
||||
"ssn",
|
||||
"social_security", // socialSecurityNumber (lower-cased match covers camelCase)
|
||||
"socialsecurity",
|
||||
"otp",
|
||||
"mnemonic",
|
||||
"phrase", // seed_phrase, seedPhrase, recoveryPhrase
|
||||
"access_key",
|
||||
"secret_key",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"bearer",
|
||||
"signing_key",
|
||||
"encryption_key",
|
||||
}
|
||||
|
||||
// isSecretKey reports whether a JSON key names a credential-bearing field.
|
||||
func isSecretKey(key string) bool {
|
||||
k := strings.ToLower(key)
|
||||
for _, part := range secretKeyParts {
|
||||
if strings.Contains(k, part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Redact returns a copy of the JSON value with every secret-keyed value replaced
|
||||
// by the redaction marker, recursively through objects and arrays. Non-JSON or
|
||||
// empty input yields nil (nothing to record). On a JSON parse error the input is
|
||||
// dropped (returns the marker as a JSON string) rather than passed through —
|
||||
// fail closed.
|
||||
//
|
||||
// Use it at any explicit emit point that supplies before/after:
|
||||
//
|
||||
// audit.Emit(ctx, rec.WithChange(audit.Redact(before), audit.Redact(after)))
|
||||
func Redact(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
// Unparseable — never echo it back verbatim; record a marker instead.
|
||||
b, _ := json.Marshal(redactedMarker)
|
||||
return b
|
||||
}
|
||||
cleaned := redactValue("", v)
|
||||
out, err := json.Marshal(cleaned)
|
||||
if err != nil {
|
||||
b, _ := json.Marshal(redactedMarker)
|
||||
return b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redactValue walks a decoded JSON value. key is the object key under which v
|
||||
// sits (empty at the root and for array elements); when key is a secret key, the
|
||||
// ENTIRE value v is replaced (whether it is a scalar, object, or array — a secret
|
||||
// nested object is redacted whole). Otherwise objects/arrays are recursed.
|
||||
func redactValue(key string, v any) any {
|
||||
if key != "" && isSecretKey(key) {
|
||||
return redactedMarker
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = redactValue(k, val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, val := range t {
|
||||
out[i] = redactValue("", val) // array elements inherit no key
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package audit
|
||||
|
||||
// The append-only sink + the serialized Recorder that owns the hash-chain head.
|
||||
//
|
||||
// WHY SQLITE IS THE PRIMARY, DURABLE STORE (not ClickHouse). The chain is only
|
||||
// tamper-EVIDENT if records are appended in a strict, gapless total order and
|
||||
// each record's PrevHash is the immediately-preceding record's Hash. That demands
|
||||
// a single serializing writer with a synchronous, read-your-write head. cloud's
|
||||
// canonical store is embedded SQLite (one store per the storagelock lockdown;
|
||||
// pricing/provisioning already persist to {DataDir}/*.db). A local SQLite table
|
||||
// the application can only INSERT into gives us: (a) a real total order under one
|
||||
// connection, (b) synchronous durability so NO record is ever lost on the request
|
||||
// path (unlike a fire-and-forget mirror), and (c) an append-only surface — the
|
||||
// app issues no UPDATE/DELETE, and the hash-chain detects any out-of-band edit to
|
||||
// the file. That is the compliance-grade primary control.
|
||||
//
|
||||
// THE CLICKHOUSE MIRROR IS A PROJECTION, NOT THE SOURCE OF TRUTH. The datastore
|
||||
// (ClickHouse MergeTree — insert-only, mutation-rejected at parse time) is the
|
||||
// fleet-wide OLAP mirror for long-retention, cross-deployment query. It is
|
||||
// best-effort and asynchronous: a mirror outage must never block or fail an
|
||||
// audited request, and the local chain remains the authority the verifier walks.
|
||||
// Losing a mirror row is a query-completeness issue, not an integrity one.
|
||||
//
|
||||
// FAIL MODE. Append is INLINE and its error is RETURNED to the middleware, which
|
||||
// fails the request CLOSED (a security-relevant action that cannot be recorded is
|
||||
// not permitted to silently succeed). This is the AU-5 "response to audit logging
|
||||
// process failure": deny rather than act-unlogged.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
// modernc.org/sqlite is the pure-Go SQLite driver already in the cloud dep
|
||||
// graph (see clients/pricing, clients/provisioning). Blank import registers
|
||||
// the "sqlite" driver name.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Mirror is the optional OLAP projection sink (the datastore/ClickHouse). It is
|
||||
// deliberately a tiny interface, not a concrete client, so the Recorder has no
|
||||
// compile-time dependency on ClickHouse and tests can supply a fake. Append is
|
||||
// called best-effort, asynchronously, off the request path.
|
||||
type Mirror interface {
|
||||
// Append writes one sealed record to the projection. A returned error is
|
||||
// logged and dropped by the Recorder — the mirror never gates a request.
|
||||
Append(ctx context.Context, r Record) error
|
||||
}
|
||||
|
||||
// Checkpoint is a periodic, tamper-EVIDENCE digest of the chain head: the record
|
||||
// count and the head hash at a moment in time. It is the AU-9 anchor for
|
||||
// TAIL-TRUNCATION detection — an internal chain walk cannot notice that the last
|
||||
// K records were deleted (the surviving prefix still verifies), but a durable,
|
||||
// INDEPENDENT series of head checkpoints can: Count is monotonic, so any decrease
|
||||
// between two consecutive checkpoints is deletion, and an attacker cannot forge a
|
||||
// higher count without appending records whose hashes the chain walk would reject.
|
||||
type Checkpoint struct {
|
||||
Time time.Time `json:"time"`
|
||||
Count uint64 `json:"count"`
|
||||
Head string `json:"head"`
|
||||
}
|
||||
|
||||
// CheckpointSink is an optional capability a Mirror may implement to persist the
|
||||
// head digest series to an INDEPENDENT store (so truncating the local SQLite
|
||||
// cannot also rewrite the anchor history). A Mirror that does not implement it
|
||||
// still gets its records; checkpoints then flow only to the structured log.
|
||||
type CheckpointSink interface {
|
||||
Checkpoint(ctx context.Context, cp Checkpoint) error
|
||||
}
|
||||
|
||||
// Recorder is the single serialized writer that owns the audit chain head and
|
||||
// the append-only store. Every Record flows through Append, which under one lock
|
||||
// assigns the next Seq, links PrevHash to the current head, seals (hashes), and
|
||||
// synchronously persists to SQLite before returning. Concurrency is serialized
|
||||
// by mu AND by the single-connection SQLite pool, so the on-disk order equals
|
||||
// the chain order with no gaps.
|
||||
type Recorder struct {
|
||||
db *sql.DB
|
||||
mirror Mirror // nil when no OLAP mirror is configured.
|
||||
|
||||
mu sync.Mutex // guards nextSeq/headHash and serializes appends.
|
||||
nextSeq uint64 // Seq to assign to the next record.
|
||||
headHash string // Hash of the last-appended record (PrevHash for the next).
|
||||
|
||||
// Checkpoint emission (AU-9 tail-truncation anchor). logCheckpoint, when set,
|
||||
// receives each head digest so it lands in the append-only observability log;
|
||||
// stopCh/wg manage the periodic emitter goroutine's lifecycle; started guards
|
||||
// against a second StartCheckpoints call (the field write + WaitGroup use are
|
||||
// not safe to race). All three are set once, before any concurrent Append.
|
||||
logCheckpoint func(cp Checkpoint)
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
started bool
|
||||
}
|
||||
|
||||
// CheckpointFunc receives a head digest for the structured (o11y) log. It is a
|
||||
// plain func so the pure audit package stays free of any concrete logger type;
|
||||
// the cloud wiring adapts luxlog to it.
|
||||
type CheckpointFunc func(cp Checkpoint)
|
||||
|
||||
// Open opens (creating if needed) the append-only audit DB at path and recovers
|
||||
// the chain head from it, so a restart continues the SAME chain rather than
|
||||
// forking a new one. path may be ":memory:" for tests. mirror may be nil.
|
||||
//
|
||||
// modernc's "sqlite" driver; MaxOpenConns(1) serializes every statement against
|
||||
// the file lock — the same single-writer discipline pricing/provisioning use,
|
||||
// here doubling as the chain's serialization guarantee.
|
||||
func Open(path string, mirror Mirror) (*Recorder, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit: open sqlite %q: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("audit: pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
r := &Recorder{db: db, mirror: mirror}
|
||||
if err := r.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := r.recoverHead(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// migrate creates the append-only audit table. It is INSERT-only by application
|
||||
// discipline: this package issues no UPDATE or DELETE against it, and seq is the
|
||||
// PRIMARY KEY so a replayed/duplicated seq is rejected by the engine. The hash
|
||||
// columns make any out-of-band row edit detectable by Verify regardless of the
|
||||
// storage layer's own guarantees.
|
||||
func (r *Recorder) migrate() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
seq INTEGER PRIMARY KEY, -- chain position; gapless, assigned under lock
|
||||
ts TEXT NOT NULL, -- RFC3339Nano UTC event time
|
||||
actor_org TEXT NOT NULL DEFAULT '',
|
||||
actor_sub TEXT NOT NULL DEFAULT '',
|
||||
actor_email TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
res_type TEXT NOT NULL DEFAULT '',
|
||||
res_id TEXT NOT NULL DEFAULT '',
|
||||
auth_method TEXT NOT NULL DEFAULT '',
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
result TEXT NOT NULL, -- success|deny|error
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
source_ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
method TEXT NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL DEFAULT '',
|
||||
before TEXT NOT NULL DEFAULT '', -- redacted JSON (explicit emit only)
|
||||
after TEXT NOT NULL DEFAULT '', -- redacted JSON (explicit emit only)
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
-- Query indexes for the /v1/admin/audit filters (actor/action/resource/time).
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_org_seq ON audit_log(actor_org, seq);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_action_seq ON audit_log(action, seq);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_result_seq ON audit_log(result, seq);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_ts ON audit_log(ts);
|
||||
`
|
||||
if _, err := r.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("audit: migrate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recoverHead loads the highest-seq record so a restarted process continues the
|
||||
// existing chain (nextSeq = maxSeq+1, headHash = its hash). An empty table starts
|
||||
// the genesis chain (nextSeq 0, headHash = genesisPrevHash).
|
||||
func (r *Recorder) recoverHead() error {
|
||||
var (
|
||||
maxSeq sql.NullInt64
|
||||
hash sql.NullString
|
||||
)
|
||||
row := r.db.QueryRow(`SELECT seq, hash FROM audit_log WHERE seq = (SELECT MAX(seq) FROM audit_log)`)
|
||||
if err := row.Scan(&maxSeq, &hash); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
r.nextSeq = 0
|
||||
r.headHash = genesisPrevHash
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("audit: recover head: %w", err)
|
||||
}
|
||||
if !maxSeq.Valid { // empty table (MAX over zero rows is NULL)
|
||||
r.nextSeq = 0
|
||||
r.headHash = genesisPrevHash
|
||||
return nil
|
||||
}
|
||||
r.nextSeq = uint64(maxSeq.Int64) + 1
|
||||
r.headHash = hash.String
|
||||
return nil
|
||||
}
|
||||
|
||||
// Append seals r into the next chain position and persists it. It fills Seq,
|
||||
// PrevHash, and Hash (the caller sets everything else), advances the in-memory
|
||||
// head only AFTER the durable INSERT succeeds, and mirrors best-effort. A
|
||||
// persistence error is returned so the caller can fail the request CLOSED — the
|
||||
// head is NOT advanced on failure, so the chain never gaps.
|
||||
//
|
||||
// The whole critical section (assign seq → seal → INSERT → advance head) holds
|
||||
// mu, so two concurrent requests can never claim the same seq or race the head.
|
||||
func (r *Recorder) Append(ctx context.Context, rec Record) (Record, error) {
|
||||
if rec.Time.IsZero() {
|
||||
rec.Time = time.Now().UTC()
|
||||
} else {
|
||||
rec.Time = rec.Time.UTC()
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
sealed, err := seal(rec, r.nextSeq, r.headHash)
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("audit: seal: %w", err)
|
||||
}
|
||||
if err := r.insert(ctx, sealed); err != nil {
|
||||
// Head not advanced; the next append reuses this seq. Fail closed upstream.
|
||||
return Record{}, fmt.Errorf("audit: persist: %w", err)
|
||||
}
|
||||
// Durable — advance the chain head.
|
||||
r.nextSeq = sealed.Seq + 1
|
||||
r.headHash = sealed.Hash
|
||||
|
||||
// Best-effort OLAP mirror, detached so a slow/failed mirror never blocks the
|
||||
// request or corrupts the reply. The local chain is already durable and is the
|
||||
// authority; a lost mirror row is a query-completeness gap, not an integrity
|
||||
// one. Copy by value: the record is immutable and safe to hand to a goroutine.
|
||||
if r.mirror != nil {
|
||||
m, out := r.mirror, sealed
|
||||
go func() { _ = m.Append(context.Background(), out) }()
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// insert writes one sealed record. INSERT-only — the sole write statement in this
|
||||
// package. A duplicate seq (PRIMARY KEY) fails here, which is the desired
|
||||
// invariant: the chain never overwrites a position.
|
||||
func (r *Recorder) insert(ctx context.Context, rec Record) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_log (
|
||||
seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
|
||||
auth_method, is_admin, result, status, reason, source_ip, user_agent,
|
||||
request_id, method, path, before, after, prev_hash, hash
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
rec.Seq, rec.Time.Format(time.RFC3339Nano),
|
||||
rec.Actor.Org, rec.Actor.Sub, rec.Actor.Email,
|
||||
rec.Action, rec.Resource.Type, rec.Resource.ID,
|
||||
rec.Auth.Method, boolToInt(rec.Auth.IsAdmin),
|
||||
rec.Outcome.Result, rec.Outcome.Status, rec.Outcome.Reason,
|
||||
rec.SourceIP, rec.UserAgent, rec.RequestID, rec.Method, rec.Path,
|
||||
string(rec.Before), string(rec.After), rec.PrevHash, rec.Hash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// checkpointCloseTimeout bounds the final (synchronous) checkpoint write to the
|
||||
// independent sink at shutdown, so Close cannot hang on an unreachable datastore.
|
||||
const checkpointCloseTimeout = 5 * time.Second
|
||||
|
||||
// Close stops the periodic checkpoint emitter, emits a FINAL checkpoint
|
||||
// SYNCHRONOUSLY (so the head at shutdown reaches both the o11y log and the
|
||||
// independent digest store before the process exits — the AU-9 anchor must be
|
||||
// current exactly when an attacker might trigger shutdown then truncate), and
|
||||
// closes the underlying database.
|
||||
func (r *Recorder) Close() error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
if r.stopCh != nil {
|
||||
close(r.stopCh)
|
||||
r.wg.Wait()
|
||||
r.stopCh = nil
|
||||
}
|
||||
// Anchor the final head before the DB closes — independent of whether the
|
||||
// periodic ticker was running (every<=0 still gets a shutdown checkpoint).
|
||||
// Synchronous to the sink (bounded), so the independent store's last count is
|
||||
// as fresh as the local chain at the moment of shutdown.
|
||||
r.emitCheckpoint(true)
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
// StartCheckpoints begins periodic head-digest emission every `every` (no ticker
|
||||
// if every<=0; the on-Close checkpoint still fires). logFn, when non-nil,
|
||||
// receives each digest for the append-only observability log (o11y), and a mirror
|
||||
// implementing CheckpointSink also gets it persisted to an INDEPENDENT store —
|
||||
// together the AU-9 anchor an external monitor compares to detect tail-truncation
|
||||
// (count regression). MUST be called at most once, before any concurrent Append
|
||||
// (a second call is ignored); the emitter stops on Close.
|
||||
func (r *Recorder) StartCheckpoints(every time.Duration, logFn CheckpointFunc) {
|
||||
// Guard the check-and-set under mu so a (mis)use that calls this concurrently
|
||||
// is race-free, not just the single-call production path.
|
||||
r.mu.Lock()
|
||||
if r.started {
|
||||
r.mu.Unlock()
|
||||
return // already started — do not re-arm (avoids a field/WaitGroup race).
|
||||
}
|
||||
r.started = true
|
||||
r.logCheckpoint = logFn
|
||||
r.mu.Unlock()
|
||||
if every <= 0 {
|
||||
return
|
||||
}
|
||||
r.stopCh = make(chan struct{})
|
||||
stop := r.stopCh
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
r.emitCheckpoint(false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// emitCheckpoint snapshots the head and emits it to the log and (if the mirror is
|
||||
// a CheckpointSink) the independent digest store. sync controls the sink write:
|
||||
// on the periodic path (sync=false) it is detached so a slow sink never delays
|
||||
// the ticker; on the Close path (sync=true) it BLOCKS on a bounded context so the
|
||||
// final anchor is durable before shutdown. The log emission is always synchronous
|
||||
// (it is the primary anchor and o11y ingests it append-only).
|
||||
func (r *Recorder) emitCheckpoint(sync bool) {
|
||||
count, head := r.Head()
|
||||
cp := Checkpoint{Time: time.Now().UTC(), Count: count, Head: head}
|
||||
if r.logCheckpoint != nil {
|
||||
r.logCheckpoint(cp)
|
||||
}
|
||||
cs, ok := r.mirror.(CheckpointSink)
|
||||
if !ok || cs == nil {
|
||||
return
|
||||
}
|
||||
if sync {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), checkpointCloseTimeout)
|
||||
defer cancel()
|
||||
_ = cs.Checkpoint(ctx, cp)
|
||||
return
|
||||
}
|
||||
go func() { _ = cs.Checkpoint(context.Background(), cp) }()
|
||||
}
|
||||
|
||||
// Head returns the current chain head (count of records, and the head hash). A
|
||||
// count of 0 means the genesis (empty) chain, headHash == genesisPrevHash. An
|
||||
// external monitor can pin (count, headHash) over time to detect tail-truncation
|
||||
// — which an internal chain walk alone cannot catch (a truncated prefix still
|
||||
// verifies). This is the anchor point for AU-9 protection against deletion of the
|
||||
// most-recent records.
|
||||
func (r *Recorder) Head() (count uint64, headHash string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.nextSeq, r.headHash
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
package cloud
|
||||
|
||||
// The audit middleware — the ONE place every security-relevant request is
|
||||
// recorded to the tamper-evident trail (decomplected: one function, every route).
|
||||
//
|
||||
// PLACEMENT (why it sits exactly where serve.go puts it). The pipeline is
|
||||
// Recover → RequestID → Logger → SanitizeIdentity → AuditTrail → BillingGate →
|
||||
// subsystems. AuditTrail runs:
|
||||
// - AFTER SanitizeIdentity, so the actor/isAdmin it records come from a
|
||||
// VALIDATED IAM principal (the sanitized X-User-* headers), never a raw
|
||||
// client header. The request being audited cannot forge its own actor.
|
||||
// - BEFORE BillingGate and every subsystem, so it WRAPS the whole handler
|
||||
// chain and observes the FINAL outcome — including a 402/503 billing denial
|
||||
// and a 403 admin-guard denial (both security-relevant) — via the response
|
||||
// status after Continue(), exactly like the Logger middleware reads it.
|
||||
//
|
||||
// WHAT IT CAPTURES: metadata only — actor, action (method+route family),
|
||||
// resource, source ip, user agent, request id, auth context, and the outcome
|
||||
// (result/status/reason). It NEVER reads the request or response BODY, so a
|
||||
// secret in a POST body can never reach a record through this path. before/after
|
||||
// diffs are the job of explicit emit points (audit.Recorder.Append with a
|
||||
// redacted diff), not this middleware.
|
||||
//
|
||||
// WHAT IT RECORDS (the coverage predicate, auditable in one place — see
|
||||
// isSecurityRelevant): every mutating request (POST/PUT/PATCH/DELETE), every
|
||||
// /v1/admin/* request (read or write — admin reads are AC-relevant), and every
|
||||
// auth-failure outcome (401/403) on ANY method (a denied GET is an access-control
|
||||
// event). Safe, unauthenticated reads (a 200 GET on a public route) are NOT
|
||||
// audited — that is request-log noise, not a security event, and auditing it
|
||||
// would bury the signal and balloon the trail.
|
||||
//
|
||||
// FAIL MODE (AU-5): if the trail write fails on a request we decided to audit,
|
||||
// the CLIENT gets a fail-closed 503 rather than a success it can rely on — the
|
||||
// AU-5 "response to an audit logging process failure" is to interrupt, not to
|
||||
// operate silently unlogged. A write to local SQLite is sub-millisecond, so this
|
||||
// is a real integrity stance, not a latency tax. When no Recorder is configured
|
||||
// the middleware is a no-op passthrough (an unconfigured deployment is never
|
||||
// blocked), exactly like BillingGate.
|
||||
//
|
||||
// PRECISE SEMANTIC (do not over-read the 503). This is POST-RESPONSE audit: the
|
||||
// handler has already run when the record is written, so a 503 here means "this
|
||||
// event could not be RECORDED", NOT "the action did not execute". PREVENTION is
|
||||
// the access-control layer's job and runs BEFORE the action — SanitizeIdentity
|
||||
// (identity can't be forged) + the per-route admin guard both execute inside
|
||||
// c.Next() ahead of any side effect. The audit trail's job is DETECTION and
|
||||
// ACCOUNTABILITY (tamper-evident record of what happened), which it does. On a
|
||||
// persistent audit-store outage every mutation returns 503 (loud, logged), so
|
||||
// the system degrades to read-only rather than mutating unaudited — the intended
|
||||
// compliance posture.
|
||||
//
|
||||
// PANIC BOUND. If a handler PANICS, the outermost middleware.Recover catches it
|
||||
// and renders 500 with a full stack trace (loud, never silent); the panic unwinds
|
||||
// PAST this middleware's post-c.Next() code, so a panicking request is not written
|
||||
// to the trail. This is an accepted bound, not an evasion: an attacker cannot turn
|
||||
// a panic into a SUCCESSFUL-but-unaudited mutation (a panic yields 500, not a
|
||||
// completed action), and every panic is already captured by Recover's logging.
|
||||
// Normal outcomes — including billing 402/503 and admin 403 denials, which return
|
||||
// through c.Next() rather than panicking — are always audited.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// AuditTrail returns the audit middleware bound to rec. A nil rec makes it a
|
||||
// no-op passthrough so callers always Use() it unconditionally.
|
||||
func AuditTrail(rec *audit.Recorder) zip.Handler {
|
||||
if rec == nil {
|
||||
return func(c *zip.Ctx) error { return c.Next() }
|
||||
}
|
||||
return func(c *zip.Ctx) error {
|
||||
// Capture the pre-decision inputs BEFORE running the chain: the request
|
||||
// context is recycled by Fiber after the handler returns, so identity and
|
||||
// request fields must be read now (mirrors BillingGate capturing usage by
|
||||
// value pre-Record).
|
||||
method := c.Method()
|
||||
path := c.Path()
|
||||
|
||||
err := c.Next()
|
||||
|
||||
// Resolve the EFFECTIVE status. A handler may set it on the response
|
||||
// directly (c.Status(...).JSON(...)) OR return a *zip.HTTPError that the
|
||||
// framework's error handler renders AFTER this middleware unwinds — in the
|
||||
// latter case the response still reads 200 here, so the returned error is
|
||||
// the authoritative source of a 401/403. Prefer the error's status when it
|
||||
// carries one; this is what makes admin-guard denials (which return
|
||||
// ErrForbidden) get audited as 403.
|
||||
status := effectiveStatus(c.Fiber().Response().StatusCode(), err)
|
||||
if !isSecurityRelevant(method, path, status) {
|
||||
return err // not an audited event; pass the handler result through.
|
||||
}
|
||||
|
||||
record := audit.Record{
|
||||
Actor: actorFromCtx(c),
|
||||
Action: method + " " + routeFamily(path),
|
||||
Resource: resourceFromPath(path),
|
||||
Auth: authFromCtx(c),
|
||||
Outcome: outcomeOf(status, err),
|
||||
SourceIP: ClientIP(c),
|
||||
// User-Agent is client-controlled free text; a misconfigured/malicious
|
||||
// client could embed a bearer token in it. Scrub credential-shaped runs
|
||||
// (and cap length) so the UA can never carry a secret into the record.
|
||||
UserAgent: scrubFreeText(c.Header("User-Agent")),
|
||||
RequestID: c.RequestID(),
|
||||
Method: method,
|
||||
// Path is scrubbed of any credential-shaped segment: Hanzo routes use
|
||||
// identifiers (:name/:slug/:id), not secrets, but a token that ever
|
||||
// rides in the path (an hk-/sk-/pk-/fw_/hz_ key) must never be recorded
|
||||
// verbatim. resourceFromPath applies the same scrub to the resource id.
|
||||
Path: scrubCredentialSegments(path),
|
||||
}
|
||||
|
||||
if _, aerr := rec.Append(c.Context(), record); aerr != nil {
|
||||
// AU-5: could not record a security-relevant event — fail the request
|
||||
// closed. Do NOT leak the audit error to the client; log it loud.
|
||||
c.Log().Error("audit append failed — failing request closed",
|
||||
"path", path, "method", method, "err", aerr)
|
||||
return c.JSON(503, map[string]any{
|
||||
"error": map[string]string{
|
||||
"code": "audit_unavailable",
|
||||
"message": "Request could not be securely recorded",
|
||||
},
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// isSecurityRelevant is the coverage predicate — the ONE place that decides which
|
||||
// requests enter the audit trail (AC/AU scope). Kept tiny and total so the
|
||||
// coverage matrix is reviewable at a glance. Order matters and is deliberate:
|
||||
// the security-relevant conditions are checked FIRST and are unconditional, so
|
||||
// the health-probe exemption can NEVER be used to evade audit of a mutation or a
|
||||
// denial (a POST/DELETE, or any 401/403, is always audited whatever the path).
|
||||
// - any auth-failure outcome (401/403) on any method (a denied access attempt),
|
||||
// - any /v1/admin/* request (admin reads are access-control-relevant),
|
||||
// - any mutating request (POST/PUT/PATCH/DELETE).
|
||||
//
|
||||
// Only then, a genuine liveness probe (a GET to an exact health route) is
|
||||
// exempted — it is neither a mutation, a denial, nor an admin call, so it is pure
|
||||
// request-log noise. The exemption matches EXACT probe paths, never an arbitrary
|
||||
// path that merely ends in "/health" (which a wildcard/attacker-named segment
|
||||
// like POST /v1/admin/orgs/x/health could otherwise abuse to slip past audit).
|
||||
func isSecurityRelevant(method, path string, status int) bool {
|
||||
// Unconditional security signals — never suppressed by any path shape. A
|
||||
// denial, an admin call, or a mutation is ALWAYS audited, whatever the path
|
||||
// (so a wildcard/attacker-named "/health" tail cannot evade it).
|
||||
if status == 401 || status == 403 {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/v1/admin/") {
|
||||
return true
|
||||
}
|
||||
if isMutation(method) {
|
||||
return true
|
||||
}
|
||||
// Everything left is a safe read (non-mutating, non-admin, non-denied). None
|
||||
// are audited — they are request-log noise, not security events. (Liveness
|
||||
// probes fall here too; there is no separate case because the answer is the
|
||||
// same: not recorded.)
|
||||
return false
|
||||
}
|
||||
|
||||
// isMutation reports whether a method changes state.
|
||||
func isMutation(method string) bool {
|
||||
switch method {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// actorFromCtx builds the Actor from the sanitized identity headers, gating
|
||||
// ANTI-FORGERY of the recorded actor on a VALIDATED principal.
|
||||
//
|
||||
// The authoritative "this request carried a validated principal" signal is a
|
||||
// non-empty X-User-Id (c.User()): SanitizeIdentity sets X-User-Id ONLY from a
|
||||
// JWT it verified, and strips any client-supplied copy on ingress. A request
|
||||
// with no principal — anonymous, OR one bearing an INVALID/garbage bearer that
|
||||
// failed validation — has an empty c.User().
|
||||
//
|
||||
// In that unvalidated case the org header is NOT trustworthy: SanitizeIdentity's
|
||||
// Phase-1 residual restores a client-supplied X-Org-Id for the data path, so an
|
||||
// anonymous attacker could send X-Org-Id: victim-org and, if we recorded it,
|
||||
// forge a FALSE ATTRIBUTION (an event stamped with a victim's org). So when there
|
||||
// is no validated sub, the actor is left EMPTY — the record stands as an honest
|
||||
// anonymous event identified by SourceIP, never mis-attributed to a claimed org.
|
||||
//
|
||||
// With a validated sub, org/sub/email all reflect the verified principal and are
|
||||
// recorded authoritatively.
|
||||
func actorFromCtx(c *zip.Ctx) audit.Actor {
|
||||
sub := strings.TrimSpace(c.User())
|
||||
if sub == "" {
|
||||
// No validated principal — do not trust the client-asserted org.
|
||||
return audit.Actor{}
|
||||
}
|
||||
return audit.Actor{
|
||||
Org: strings.TrimSpace(c.Org()),
|
||||
Sub: sub,
|
||||
Email: strings.TrimSpace(c.UserEmail()),
|
||||
}
|
||||
}
|
||||
|
||||
// authFromCtx records HOW the caller authenticated and the VALIDATED admin bit.
|
||||
// IsAdmin comes from c.IsAdmin() (the sanitized X-User-IsAdmin, true only for a
|
||||
// verified global admin), never a raw header. Method is inferred from the
|
||||
// presence/shape of a credential: an Authorization/X-Authorization bearer or a
|
||||
// session cookie ⇒ "jwt" (or "api-key" for an opaque hk-/sk- token); none ⇒
|
||||
// "none".
|
||||
func authFromCtx(c *zip.Ctx) audit.AuthContext {
|
||||
return audit.AuthContext{
|
||||
Method: authMethodOf(c),
|
||||
IsAdmin: c.IsAdmin(),
|
||||
}
|
||||
}
|
||||
|
||||
// authMethodOf classifies the credential kind WITHOUT capturing it — it inspects
|
||||
// only the token PREFIX (never stores the value). Order mirrors the sanitizer's
|
||||
// extraction (bearer, then cookie).
|
||||
func authMethodOf(c *zip.Ctx) string {
|
||||
auth := c.Header("Authorization")
|
||||
if auth == "" {
|
||||
auth = c.Header("X-Authorization")
|
||||
}
|
||||
if tok := bearerFromAuth(auth); tok != "" {
|
||||
if isAPIKey(tok) {
|
||||
return "api-key"
|
||||
}
|
||||
return "jwt"
|
||||
}
|
||||
if basicFromAuth(auth) != "" {
|
||||
return "basic"
|
||||
}
|
||||
for _, name := range cookieTokenNames {
|
||||
if c.Fiber().Cookies(name) != "" {
|
||||
return "jwt"
|
||||
}
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
// effectiveStatus reconciles the response status with a returned error. If the
|
||||
// handler returned a *zip.HTTPError (e.g. ErrForbidden), its Status is
|
||||
// authoritative — the framework renders it after this middleware unwinds, so the
|
||||
// live response status does not yet reflect it. A non-HTTPError returned error
|
||||
// with a still-2xx response means the framework will render a 500. Otherwise the
|
||||
// response status stands.
|
||||
func effectiveStatus(respStatus int, err error) int {
|
||||
if err != nil {
|
||||
var he *zip.HTTPError
|
||||
if errors.As(err, &he) && he.Status != 0 {
|
||||
return he.Status
|
||||
}
|
||||
// A non-HTTPError propagating up renders as 500, unless the handler already
|
||||
// set an explicit error status on the response.
|
||||
if respStatus < 400 {
|
||||
return 500
|
||||
}
|
||||
}
|
||||
return respStatus
|
||||
}
|
||||
|
||||
// outcomeOf maps the final HTTP status + handler error to an audit Outcome.
|
||||
// 2xx/3xx ⇒ success; 401/403 ⇒ deny; everything else (4xx/5xx) ⇒ error. The
|
||||
// reason is a short, non-sensitive label derived from the status class — never a
|
||||
// raw upstream error body (which could echo sensitive detail).
|
||||
func outcomeOf(status int, err error) audit.Outcome {
|
||||
switch {
|
||||
case status == 401:
|
||||
return audit.Outcome{Result: "deny", Status: status, Reason: "unauthenticated"}
|
||||
case status == 403:
|
||||
return audit.Outcome{Result: "deny", Status: status, Reason: "forbidden"}
|
||||
case status >= 500:
|
||||
return audit.Outcome{Result: "error", Status: status, Reason: "server_error"}
|
||||
case status >= 400:
|
||||
return audit.Outcome{Result: "error", Status: status, Reason: "client_error"}
|
||||
default:
|
||||
return audit.Outcome{Result: "success", Status: status}
|
||||
}
|
||||
}
|
||||
|
||||
// routeFamily reduces a concrete path to its stable route family for the action
|
||||
// verb, dropping trailing high-cardinality id segments so "DELETE /v1/admin/
|
||||
// orgs/acme" and "DELETE /v1/admin/orgs/globex" share the action "DELETE
|
||||
// /v1/admin/orgs". The full concrete path is preserved separately in Record.Path.
|
||||
func routeFamily(path string) string {
|
||||
segs := strings.Split(strings.Trim(path, "/"), "/")
|
||||
// Keep the leading "v1/<subsystem>/<noun>" and stop before an id-looking tail.
|
||||
out := make([]string, 0, len(segs))
|
||||
for _, s := range segs {
|
||||
if looksLikeID(s) {
|
||||
break
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "/" + strings.Join(segs, "/")
|
||||
}
|
||||
return "/" + strings.Join(out, "/")
|
||||
}
|
||||
|
||||
// resourceFromPath derives the {type,id} resource from a /v1/<subsystem>/<type>/
|
||||
// [<id>] path. Type is the noun after the subsystem; ID is the following segment
|
||||
// when it looks like an identifier. Best-effort — the Action verb + Path are the
|
||||
// authoritative locator; this is a convenience for filtering by resource type.
|
||||
func resourceFromPath(path string) audit.Resource {
|
||||
segs := strings.Split(strings.Trim(path, "/"), "/")
|
||||
// segs: [v1, <subsystem>, <type>, <id?>, ...]
|
||||
if len(segs) < 3 {
|
||||
return audit.Resource{}
|
||||
}
|
||||
res := audit.Resource{Type: segs[2]}
|
||||
if len(segs) >= 4 && looksLikeID(segs[3]) {
|
||||
res.ID = scrubToken(segs[3])
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// scrubToken replaces a path segment that is a credential-shaped token with a
|
||||
// fixed marker, so a secret that ever appears in a URL is never recorded
|
||||
// verbatim. It catches, in increasing generality:
|
||||
// - a known API-key prefix (hk-/sk-/pk-/fw_/hz_ — what isAPIKey recognizes),
|
||||
// ALSO after percent-decoding, so hk%2DKEY can't slip the prefix check,
|
||||
// - a JWT (three base64url parts split by '.', starting eyJ),
|
||||
// - a long, high-entropy base64url/hex run (>=24) — a raw API key / access
|
||||
// token / hex secret that carries no telltale prefix.
|
||||
//
|
||||
// A normal identifier passes through unchanged: a UUID (5 hyphen-split groups,
|
||||
// each short), a slug, a numeric id, a dotted model name — none is a long
|
||||
// unbroken high-entropy blob. See TestScrubToken_NoFalsePositives.
|
||||
//
|
||||
// RED-review hardening (finding: scrub bypass): the entropy test now (a) accepts
|
||||
// the FULL base64url alphabet incl. '-' and '_' (RFC 4648 §5), (b) does NOT
|
||||
// require a digit (an all-alpha opaque key is still a secret), (c) percent-
|
||||
// decodes first so %2D/%5F can't hide structure, and (d) drops the threshold to
|
||||
// 24 (short enough for a 128-bit base64 or a 24-hex key, long enough that no
|
||||
// human-readable slug reaches it).
|
||||
func scrubToken(seg string) string {
|
||||
dec := percentDecode(seg)
|
||||
if isAPIKey(seg) || isAPIKey(dec) ||
|
||||
looksLikeJWT(seg) || looksLikeJWT(dec) ||
|
||||
looksLikeHighEntropyToken(seg) || looksLikeHighEntropyToken(dec) {
|
||||
return "[REDACTED-TOKEN]"
|
||||
}
|
||||
return seg
|
||||
}
|
||||
|
||||
// percentDecode best-effort URL-decodes s so a percent-encoded credential
|
||||
// (hk%2DKEY, sk%5Flive%5F…) is normalized before the credential tests run. On a
|
||||
// malformed escape it returns s unchanged (the raw form is then tested as-is).
|
||||
func percentDecode(s string) string {
|
||||
// Decode repeatedly (bounded) so a NESTED encoding (%252D -> %2D -> -) is
|
||||
// fully normalized before the credential tests run. Stop when a pass makes no
|
||||
// change, on a malformed escape, or after a small cap (defeats a decode bomb).
|
||||
for i := 0; i < 3 && strings.Contains(s, "%"); i++ {
|
||||
dec, err := url.PathUnescape(s)
|
||||
if err != nil || dec == s {
|
||||
break
|
||||
}
|
||||
s = dec
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// looksLikeJWT reports whether s is a JSON Web Token OR a JWT header segment. A
|
||||
// full JWT is three base64url parts split by '.', header starting "eyJ". But when
|
||||
// free text (a UA) is tokenized on '.', a dotted JWT splits into parts; a real
|
||||
// header part is >=24 chars (caught by the high-entropy run), yet to be safe we
|
||||
// ALSO flag any lone segment starting with the canonical base64url header prefix
|
||||
// "eyJ" (which decodes to '{"') regardless of length — a JWT header can never be
|
||||
// a legitimate resource id, so redacting it has no false-positive cost.
|
||||
func looksLikeJWT(s string) bool {
|
||||
if !strings.HasPrefix(s, "eyJ") {
|
||||
return false
|
||||
}
|
||||
// A full token (two dots) or a bare header segment — either way, redact.
|
||||
return true
|
||||
}
|
||||
|
||||
// highEntropyMinLen is the length at/above which an UNBROKEN run of base64/hex
|
||||
// chars is treated as an opaque secret. 24 covers a 128-bit base64 token, a
|
||||
// 24-nibble hex key, and short API keys, while every human-readable path
|
||||
// segment/slug/model-name stays under it once split on its separators — no
|
||||
// single run of a name like "text-embedding-3-large" reaches 24.
|
||||
const highEntropyMinLen = 24
|
||||
|
||||
// looksLikeHighEntropyToken reports whether s CONTAINS an unbroken run of
|
||||
// >= highEntropyMinLen base64/hex chars — the shape of a raw API key / access
|
||||
// token / hex secret — UNLESS s is a structured human identifier.
|
||||
//
|
||||
// The run alphabet is [A-Za-z0-9_+/-] — the base64 alphabets (url-safe §5 '-”_'
|
||||
// and standard §4 '+”/') and hex. '-' is INCLUDED so a url-safe-base64 token
|
||||
// that embeds '-' is still caught by its run (excluding '-' left an ~11% bypass
|
||||
// for 32-byte url-safe tokens whose '-' happened to break every 24-run — measured).
|
||||
//
|
||||
// TWO-STAGE DETECTION:
|
||||
//
|
||||
// 1. UNCONDITIONAL run scan — a >= highEntropyMinLen UNBROKEN run over
|
||||
// [A-Za-z0-9_+/-] flags the value REGARDLESS of the structured-id exemption.
|
||||
// This catches every raw secret WITHOUT internal separators (hex, base64) —
|
||||
// the realistic "a client bug put a raw key in the URL" case — at 100%. A
|
||||
// structured identifier never has a 24-char unbroken run, so this stage never
|
||||
// over-scrubs one.
|
||||
//
|
||||
// 2. STRUCTURED-ID EXEMPTION for the rest (values with separators that DON'T have
|
||||
// a 24-run): exempt a clearly hyphen-joined human id, judged by LEXICAL
|
||||
// content — every group WORD-LIKE (single-case word or decimal number), which
|
||||
// a mixed-case base64 chunk or a long hex-with-letters chunk is NOT (those are
|
||||
// redacted). Shape alone is attacker-satisfiable (RED found a 3x12-chunked
|
||||
// secret slipped a shape-only check); the lexical test rejects the common
|
||||
// secret encodings.
|
||||
//
|
||||
// ACCEPTED RESIDUAL BOUND (documented, per RED review): the ONLY residual is a
|
||||
// secret deliberately chunked into >=3 SINGLE-CASE-ALPHABETIC groups of <=12 chars
|
||||
// with no hex-letter runs >= hexChunkMinLen (a lowercase- or uppercase-only base32
|
||||
// alphabet, e.g. abcdefghijkl-mnopqrstuvwx-…). Such a value is lexically
|
||||
// indistinguishable from a hyphenated model id (deepseek-r1-distill-qwen-32b
|
||||
// carries the SAME 24-char entropy budget), so it passes stage 2. This is NOT
|
||||
// closable by any length/case/count rule without a word dictionary
|
||||
// (over-engineering for a defense-in-depth URL/UA backstop). Mixed-case base64
|
||||
// chunks AND small-hex chunks (md5/sha display grouping) ARE now caught
|
||||
// (isWordLikeGroup). The residual does not widen exposure for any REAL credential:
|
||||
// Hanzo keys are hk-/sk-/pk-/fw_/hz_-prefixed (isAPIKey, caught at any
|
||||
// length/shape), JWTs are eyJ-prefixed (looksLikeJWT), and request/response BODIES
|
||||
// are never read. It is an adversary DELIBERATELY base32-chunking their OWN secret
|
||||
// into a URL path to seed an admin-only audit row — contrived, low-value. The
|
||||
// realistic accidental leak (an unbroken raw key) is caught by stage 1.
|
||||
//
|
||||
// A canonical UUID is exempt (its longest run is 12; the check documents intent).
|
||||
func looksLikeHighEntropyToken(s string) bool {
|
||||
if len(s) < highEntropyMinLen {
|
||||
return false
|
||||
}
|
||||
// Stage 1 — unconditional: a long unbroken run is always a secret.
|
||||
if hasHighEntropyRun(s) {
|
||||
return true
|
||||
}
|
||||
// Stage 2 — separated values: redact unless it's a lexical structured id.
|
||||
if isUUID(s) || isStructuredID(s) {
|
||||
return false
|
||||
}
|
||||
// A >=24-length value with separators, not a UUID, not a structured id — e.g.
|
||||
// "sk.live.LONGSECRET…" dotted, or a chunk pattern that is not word-like.
|
||||
return true
|
||||
}
|
||||
|
||||
// hasHighEntropyRun reports whether s contains an unbroken run of
|
||||
// >= highEntropyMinLen high-entropy chars where the run alphabet EXCLUDES '-'
|
||||
// (and '.'): a real separator breaks the run. This is stage 1 — it fires only on
|
||||
// a genuinely UNBROKEN opaque blob (a raw hex/base64 key with no separators), so
|
||||
// it never catches a hyphenated identifier (which stage 2 then classifies). A
|
||||
// url-safe-base64 secret that embeds '-' still trips because the run on ONE side
|
||||
// of the hyphen is >= 24 (verified: "AbCdEf-GhIjKl_MnOpQrStUvWxYz012345" -> 27).
|
||||
func hasHighEntropyRun(s string) bool {
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if isUnbrokenTokenChar(r) {
|
||||
run++
|
||||
if run >= highEntropyMinLen {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isUnbrokenTokenChar is the stage-1 run alphabet: base64/hex MINUS '-' (and the
|
||||
// implicit exclusion of '.', space, etc.). '_' '+' '/' are kept — they appear
|
||||
// inside opaque tokens and are not identifier separators.
|
||||
func isUnbrokenTokenChar(r rune) bool {
|
||||
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '+' || r == '/'
|
||||
}
|
||||
|
||||
// idPartMaxLen bounds a hyphen-group length in the structured-id exemption. 12
|
||||
// covers the longest word in real model ids ("embedding", "20241022", "preview")
|
||||
// while a raw secret's random hyphen groups routinely exceed it.
|
||||
const idPartMaxLen = 12
|
||||
|
||||
// isStructuredID reports whether s is a hyphen-joined human identifier (a model
|
||||
// name, slug): >= 3 hyphen groups where EVERY group is non-empty, <= idPartMaxLen
|
||||
// chars, and WORD-LIKE. The word-like test is the anti-bypass core — a raw secret
|
||||
// chunk cannot satisfy it — so the exemption is not attacker-satisfiable by shape.
|
||||
func isStructuredID(s string) bool {
|
||||
groups := strings.Split(s, "-")
|
||||
if len(groups) < 3 {
|
||||
return false
|
||||
}
|
||||
for _, g := range groups {
|
||||
if g == "" || len(g) > idPartMaxLen || !isWordLikeGroup(g) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isWordLikeGroup reports whether a hyphen group looks like a model-id token (a
|
||||
// dictionary word or a decimal number) rather than a random secret chunk. Two
|
||||
// lexical signals reject a secret chunk:
|
||||
// - MIXED CASE (both upper and lower letters) — base64 tokens are dense
|
||||
// mixed-case; real model tokens are single-case ("sonnet", "Instruct", "3").
|
||||
// - an all-hex-with-letters run (>= hexChunkMinLen chars, all [0-9a-fA-F], and
|
||||
// not all-digits) — a hex secret chunk ("dead", "beef", "cafebabe"); a version
|
||||
// date ("20241022", all digits) is NOT hex-with-letters, so it stays
|
||||
// word-like. The threshold is 4: RED re-review verified that NO real model-id
|
||||
// group is all-hex-with-letters of length >= 4, so 4 (vs the prior 8) closes
|
||||
// the small-hex-chunk leak (md5/sha shown in "xxxx-xxxx" display grouping)
|
||||
// with zero model-id over-scrub.
|
||||
func isWordLikeGroup(g string) bool {
|
||||
var hasUpper, hasLower, allHex, allDigit bool = false, false, true, true
|
||||
for _, r := range g {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
hasUpper = true
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLower = true
|
||||
}
|
||||
if r < '0' || r > '9' {
|
||||
allDigit = false
|
||||
}
|
||||
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
|
||||
if !isHex {
|
||||
allHex = false
|
||||
}
|
||||
}
|
||||
if hasUpper && hasLower {
|
||||
return false // dense mixed-case → base64 secret chunk, not a word.
|
||||
}
|
||||
if allHex && !allDigit && len(g) >= hexChunkMinLen {
|
||||
return false // hex-with-letters → hex secret chunk (not a version date).
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hexChunkMinLen is the length at/above which an all-hex-with-letters group is
|
||||
// treated as a secret chunk rather than a word. 4 is the tightest bound that does
|
||||
// not over-scrub any real model-id group (RED-verified across 19 model ids) while
|
||||
// catching hex secrets displayed in short groups (md5 "xxxx-xxxx", uuid-ish).
|
||||
const hexChunkMinLen = 4
|
||||
|
||||
// isUUID reports whether s is a canonical 8-4-4-4-12 hex UUID (case-insensitive).
|
||||
// Used to exempt uuids from the high-entropy secret test — a uuid is a legitimate
|
||||
// resource id, not a credential.
|
||||
func isUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if r != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// maxUserAgentLen caps the recorded User-Agent so an oversized UA can neither
|
||||
// bloat the trail nor smuggle a long payload. 512 chars covers every real UA.
|
||||
const maxUserAgentLen = 512
|
||||
|
||||
// scrubFreeText scrubs credential-shaped words out of client-controlled free
|
||||
// text (the User-Agent) and caps its length. It tokenizes on a broad delimiter
|
||||
// superset — whitespace and the punctuation that commonly glues a token into a
|
||||
// UA/header value (= ; , : / ( ) [ ] { } " ' < > | and backslash) — scrubs each
|
||||
// token, and rebuilds the string preserving the exact delimiters between tokens.
|
||||
//
|
||||
// RED-review hardening (finding: UA tokenizer split on too few delimiters, and
|
||||
// strings.ReplaceAll was substring-fragile): this walks the string in one pass
|
||||
// (token-run, delimiter-run, …) and replaces each credential token IN PLACE, so a
|
||||
// secret delimited by ':' '/' '(' etc. is caught and a token that is a substring
|
||||
// of another is never mis-replaced. A normal UA ("Mozilla/5.0 (Macintosh …)") is
|
||||
// unchanged because none of its words is credential-shaped.
|
||||
func scrubFreeText(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if len(s) > maxUserAgentLen {
|
||||
s = s[:maxUserAgentLen]
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
start := -1 // start index of the current token run, or -1 in a delimiter run.
|
||||
flush := func(end int) {
|
||||
if start >= 0 {
|
||||
b.WriteString(scrubToken(s[start:end]))
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
for i, r := range s {
|
||||
if isFreeTextDelimiter(r) {
|
||||
flush(i)
|
||||
b.WriteRune(r)
|
||||
} else if start < 0 {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
flush(len(s))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isFreeTextDelimiter reports whether r separates tokens in free text (a UA /
|
||||
// header value). Deliberately broad so a credential can't hide behind an unusual
|
||||
// separator.
|
||||
//
|
||||
// RED re-review (UA bypass persists): '.' '@' '#' '~' are INCLUDED — a prefixed
|
||||
// key glued by one of them (client@sk-live-KEY, app.sk-live-KEY, build#hk-KEY)
|
||||
// otherwise stayed one token whose PREFIX was no longer sk-/hk-, so isAPIKey
|
||||
// missed it. Splitting on them exposes the bare key to scrubToken. Real UA dots
|
||||
// are numeric version separators (<24, safe) and are split harmlessly. A JWT
|
||||
// (eyJ.h.p.s) is handled up-front by scrubToken via looksLikeJWT before any
|
||||
// tokenizer runs on a URL path segment; in free text a dotted JWT will split,
|
||||
// but each ~40-char base64 part is itself a >=24 high-entropy run, so every part
|
||||
// is still redacted.
|
||||
func isFreeTextDelimiter(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r', '=', ';', ',', ':', '/', '\\',
|
||||
'(', ')', '[', ']', '{', '}', '"', '\'', '<', '>', '|', '&', '?',
|
||||
'.', '@', '#', '~':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scrubCredentialSegments applies scrubToken to every segment of a path, so the
|
||||
// recorded Path can never carry a credential even if a future route embeds one.
|
||||
// Route nouns/ids pass through unchanged (they are not isAPIKey-shaped).
|
||||
func scrubCredentialSegments(path string) string {
|
||||
if !strings.ContainsAny(path, "/") {
|
||||
return scrubToken(path)
|
||||
}
|
||||
segs := strings.Split(path, "/")
|
||||
changed := false
|
||||
for i, s := range segs {
|
||||
if scrubbed := scrubToken(s); scrubbed != s {
|
||||
segs[i] = scrubbed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return path
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
// looksLikeID heuristically flags a path segment as a high-cardinality id (a
|
||||
// uuid, a long hex/opaque token, or a numeric id) vs a fixed route noun. Used to
|
||||
// collapse route families; false positives only make the action slightly more
|
||||
// specific, never leak anything.
|
||||
func looksLikeID(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if len(s) >= 16 { // long opaque/uuid-ish segment
|
||||
return true
|
||||
}
|
||||
allDigits := true
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return allDigits && len(s) > 0
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
package cloud
|
||||
|
||||
// Integration tests for the audit middleware. They drive REAL requests through
|
||||
// the zip/fiber stack (app.Fiber().Test) with the audit middleware in front of a
|
||||
// handler, backed by a REAL on-disk audit store, then read the store back to
|
||||
// assert what was (and was not) recorded. No mocks — the whole capture path runs.
|
||||
//
|
||||
// The middleware trusts SanitizeIdentity to have already validated identity, so
|
||||
// these tests set the sanitized X-User-* headers directly (as SanitizeIdentity
|
||||
// would after verifying a JWT) — that is the contract boundary under test here.
|
||||
// The forgery/bypass properties of SanitizeIdentity itself are proven in
|
||||
// middleware_identity_test.go; here we prove the middleware records the VALIDATED
|
||||
// identity and the correct outcome for every security-relevant request.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// newAuditApp wires a zip app with the audit middleware in front of a small set
|
||||
// of routes covering the coverage matrix: a mutating POST, a safe GET, an
|
||||
// admin-gated route that 403s, and a route that echoes a (secret-bearing) body
|
||||
// so we can prove the body never reaches a record.
|
||||
func newAuditApp(t *testing.T) (*zip.App, *audit.Recorder) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("audit.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
|
||||
// Mutating route — must be audited.
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusCreated, map[string]string{"id": "sec_1"})
|
||||
})
|
||||
// Safe read — must NOT be audited (not a mutation, not admin, not a denial).
|
||||
app.Get("/v1/pricing/models", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
// Admin route that denies (as the real admin guard would) — the 403 is a
|
||||
// security event and MUST be audited even though it is a GET.
|
||||
app.Get("/v1/admin/orgs", func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
// A mutation whose request body carries secrets — used to prove the body is
|
||||
// never captured. The handler ignores the body; the point is what the
|
||||
// middleware records (metadata only).
|
||||
app.Post("/v1/iam/users", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
return app, rec
|
||||
}
|
||||
|
||||
// asAdmin sets the sanitized identity headers a VALIDATED global admin would
|
||||
// carry after SanitizeIdentity (X-User-IsAdmin=true, org=admin).
|
||||
func asAdmin(req *http.Request) {
|
||||
req.Header.Set("X-User-Id", "z@hanzo.ai")
|
||||
req.Header.Set("X-User-Email", "z@hanzo.ai")
|
||||
req.Header.Set("X-Org-Id", "admin")
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
req.Header.Set("Authorization", "Bearer eyJ.validated.jwt") // shape only; classifies as jwt
|
||||
}
|
||||
|
||||
// asUser sets sanitized headers for a normal (non-admin) validated principal.
|
||||
func asUser(req *http.Request) {
|
||||
req.Header.Set("X-User-Id", "alice")
|
||||
req.Header.Set("X-Org-Id", "acme")
|
||||
req.Header.Set("Authorization", "Bearer eyJ.validated.jwt")
|
||||
}
|
||||
|
||||
func mustTest(t *testing.T, app *zip.App, req *http.Request) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", req.Method, req.URL.Path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestAudit_RecordsMutation proves a mutating request is captured with the
|
||||
// correct validated actor, action, resource, outcome, and auth context — and the
|
||||
// record is hash-chained (has a hash) and verifies.
|
||||
func TestAudit_RecordsMutation(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
asUser(req)
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
|
||||
req.Header.Set("User-Agent", "test-agent/1.0")
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("recorded %d events, want exactly 1", total)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Actor.Org != "acme" || r.Actor.Sub != "alice" {
|
||||
t.Errorf("actor = %+v, want org=acme sub=alice (the VALIDATED identity)", r.Actor)
|
||||
}
|
||||
if r.Method != "POST" || r.Path != "/v1/kms/secrets" {
|
||||
t.Errorf("method/path = %s %s, want POST /v1/kms/secrets", r.Method, r.Path)
|
||||
}
|
||||
if r.Resource.Type != "secrets" {
|
||||
t.Errorf("resource type = %q, want secrets", r.Resource.Type)
|
||||
}
|
||||
if r.Outcome.Result != "success" || r.Outcome.Status != 201 {
|
||||
t.Errorf("outcome = %+v, want success/201", r.Outcome)
|
||||
}
|
||||
if r.Auth.Method != "jwt" {
|
||||
t.Errorf("auth method = %q, want jwt", r.Auth.Method)
|
||||
}
|
||||
if r.SourceIP != "203.0.113.9" {
|
||||
t.Errorf("source ip = %q, want the left-most XFF entry", r.SourceIP)
|
||||
}
|
||||
if r.UserAgent != "test-agent/1.0" {
|
||||
t.Errorf("user agent = %q, want test-agent/1.0", r.UserAgent)
|
||||
}
|
||||
if r.Hash == "" {
|
||||
t.Error("record has no hash — not chained")
|
||||
}
|
||||
if iv, _ := rec.Verify(t.Context()); !iv.OK {
|
||||
t.Errorf("chain broke after one record at %d (%s)", iv.BrokenAt, iv.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_RecordsDenial proves a 403 (access-control denial) is audited even on
|
||||
// a GET, with outcome result="deny", and records the actor who was denied.
|
||||
func TestAudit_RecordsDenial(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
// A NON-admin hits the admin route → 403.
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/admin/orgs", nil)
|
||||
asUser(req) // not admin
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{Result: "deny"})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("recorded %d denials, want 1", total)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Outcome.Result != "deny" || r.Outcome.Status != 403 {
|
||||
t.Errorf("outcome = %+v, want deny/403", r.Outcome)
|
||||
}
|
||||
if r.Actor.Sub != "alice" {
|
||||
t.Errorf("denied actor sub = %q, want alice", r.Actor.Sub)
|
||||
}
|
||||
if r.Auth.IsAdmin {
|
||||
t.Error("denied non-admin recorded as admin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_SkipsSafeReads proves an ordinary successful GET on a non-admin route
|
||||
// is NOT audited — the trail captures security events, not read-log noise.
|
||||
func TestAudit_SkipsSafeReads(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/pricing/models", nil)
|
||||
asUser(req)
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
_, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("a safe GET was audited (%d rows) — trail should skip non-security reads", total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_AdminReadIsAudited proves a SUCCESSFUL admin read is audited (admin
|
||||
// access itself is an AC-relevant event), distinguishing it from a normal read.
|
||||
func TestAudit_AdminReadIsAudited(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/admin/orgs", nil)
|
||||
asAdmin(req)
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("admin read recorded %d, want 1", total)
|
||||
}
|
||||
if !rows[0].Auth.IsAdmin || rows[0].Outcome.Result != "success" {
|
||||
t.Errorf("admin read record = auth.isAdmin=%v outcome=%+v, want admin+success", rows[0].Auth.IsAdmin, rows[0].Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_NeverCapturesRequestBody is the secret-safety proof: a mutation whose
|
||||
// body is FULL of credentials is audited, but the stored record contains NONE of
|
||||
// the body — the middleware captures metadata only, so a secret in a body can
|
||||
// never leak into the trail. This is the "reuse RedactUserSecrets" guarantee at
|
||||
// its strongest: the code that could leak a secret never reads it.
|
||||
func TestAudit_NeverCapturesRequestBody(t *testing.T) {
|
||||
app, rec := newAuditApp(t)
|
||||
|
||||
secretBody := `{"username":"bob","password":"hunter2","apiKey":"sk-live-DEADBEEF","token":"ghp_SECRET"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/iam/users", strings.NewReader(secretBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
asAdmin(req)
|
||||
// Also stuff a secret into a header value that is NOT an identity header — it
|
||||
// must not be captured either (we only record User-Agent + XFF, never arbitrary
|
||||
// headers, and never Authorization's value).
|
||||
req.Header.Set("Authorization", "Bearer eyJsuper.secret.token.value")
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("recorded %d, want 1", total)
|
||||
}
|
||||
// Serialize the WHOLE record and scan for any secret substring.
|
||||
blob, _ := json.Marshal(rows[0])
|
||||
for _, secret := range []string{"hunter2", "sk-live-DEADBEEF", "ghp_SECRET", "super.secret.token.value"} {
|
||||
if strings.Contains(string(blob), secret) {
|
||||
t.Fatalf("SECRET LEAKED into audit record: %q found in %s", secret, blob)
|
||||
}
|
||||
}
|
||||
// But the metadata IS there: the auth method is classified without the token.
|
||||
if rows[0].Auth.Method != "jwt" {
|
||||
t.Errorf("auth method = %q, want jwt (classified from prefix, token not stored)", rows[0].Auth.Method)
|
||||
}
|
||||
if rows[0].Before != nil || rows[0].After != nil {
|
||||
t.Errorf("middleware set before/after (%s / %s) — it must never read bodies", rows[0].Before, rows[0].After)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_ScrubsCredentialInPath proves a credential-shaped token that rides in
|
||||
// the URL PATH (e.g. a KMS route where a caller wrongly puts an sk-/hk- key in the
|
||||
// path) is never recorded verbatim in either Path or resource.ID — defense in
|
||||
// depth beyond "bodies are never read". A normal identifier is untouched.
|
||||
func TestAudit_ScrubsCredentialInPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Delete("/v1/kms/secrets/*", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
|
||||
// Three credential shapes smuggled into the path across three requests: a
|
||||
// prefixed key, a raw high-entropy hex secret (NO telltale prefix), and a JWT.
|
||||
paths := []struct{ path, secret string }{
|
||||
{"/v1/kms/secrets/sk-live-SUPERSECRETKEY1234567890", "SUPERSECRETKEY"},
|
||||
{"/v1/kms/secrets/deadbeefcafe0123456789abcdef0123456789abcdef0123", "deadbeefcafe0123"},
|
||||
{"/v1/kms/secrets/eyJhbGciOiJIUzI1NiJ9.cGF5bG9hZA.c2ln", "eyJhbGciOiJIUzI1NiJ9"},
|
||||
}
|
||||
for _, tc := range paths {
|
||||
req := httptest.NewRequest(http.MethodDelete, tc.path, nil)
|
||||
asAdmin(req)
|
||||
if resp := mustTest(t, app, req); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status = %d, want 200", tc.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
rows, total, err := rec.Query(t.Context(), audit.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if total != len(paths) {
|
||||
t.Fatalf("recorded %d, want %d", total, len(paths))
|
||||
}
|
||||
blob, _ := json.Marshal(rows)
|
||||
for _, tc := range paths {
|
||||
if strings.Contains(string(blob), tc.secret) {
|
||||
t.Fatalf("credential in path leaked into record: %q present in %s", tc.secret, blob)
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
if !strings.Contains(r.Path, "[REDACTED-TOKEN]") {
|
||||
t.Errorf("path token not scrubbed: %q", r.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_ScrubsSecretInUserAgent proves a bearer/API-key embedded in the
|
||||
// client-controlled User-Agent is scrubbed, and a normal UA is untouched.
|
||||
func TestAudit_ScrubsSecretInUserAgent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
asAdmin(req)
|
||||
req.Header.Set("User-Agent", "myclient/1.0 Bearer eyJhbGciOiJI.pay.sig key=sk-live-LEAKME99999")
|
||||
mustTest(t, app, req)
|
||||
|
||||
rows, _, _ := rec.Query(t.Context(), audit.Filter{})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("got %d rows, want 1", len(rows))
|
||||
}
|
||||
for _, secret := range []string{"eyJhbGciOiJI", "sk-live-LEAKME99999"} {
|
||||
if strings.Contains(rows[0].UserAgent, secret) {
|
||||
t.Errorf("UA secret leaked: %q in %q", secret, rows[0].UserAgent)
|
||||
}
|
||||
}
|
||||
// The non-secret UA prefix survives (audit usefulness preserved).
|
||||
if !strings.Contains(rows[0].UserAgent, "myclient/1.0") {
|
||||
t.Errorf("UA over-scrubbed, lost the client name: %q", rows[0].UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrubToken_NoFalsePositives proves legitimate identifiers are NEVER
|
||||
// scrubbed — the guard fires only on genuinely secret-shaped segments, so the
|
||||
// audit trail keeps its query precision for normal resource ids.
|
||||
func TestScrubToken_NoFalsePositives(t *testing.T) {
|
||||
for _, id := range []string{
|
||||
"acme-corp", "gpt-4o-mini", "my_project_123", "user@example.com",
|
||||
"550e8400-e29b-41d4-a716-446655440000", // uuid (hyphens)
|
||||
"claude-opus-4-20250514", "text-embedding-3-large",
|
||||
"my-cool-project-name", "feature-branch-xyz",
|
||||
"deployment-2024-01-15", "report.pdf", "data.json",
|
||||
// Red re-review round 2 — hyphenated model ids MUST pass (AU-3: an auditor
|
||||
// must still see WHICH model a config change touched).
|
||||
"claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022",
|
||||
"claude-sonnet-4-20250514", "claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-sonnet-latest", "deepseek-r1-distill-qwen-32b",
|
||||
"claude-3-opus-20240229", "stable-diffusion-xl-base",
|
||||
"mixtral-8x7b-instruct", "llama-3-1-8b-instruct", "whisper-large-v3-turbo",
|
||||
"v1", "models", "sync", "12345", "a", "",
|
||||
} {
|
||||
if got := scrubToken(id); got != id {
|
||||
t.Errorf("false scrub: legit id %q → %q", id, got)
|
||||
}
|
||||
}
|
||||
// And genuine secrets ARE scrubbed.
|
||||
for _, sec := range []string{
|
||||
"sk-live-abcdef", "hk-1234567890abcdef", "eyJhbG.payload.signature",
|
||||
"deadbeefcafe0123456789abcdef0123456789abcdef0123", // 48-char raw hex
|
||||
} {
|
||||
if scrubToken(sec) == sec {
|
||||
t.Errorf("missed secret: %q not scrubbed", sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrubToken_RedReviewBypassClasses is the regression for the 4 scrub-bypass
|
||||
// classes Red found: base64url with -/_, all-alpha opaque >=len, percent-encoded
|
||||
// prefixes, and delimiter-glued UA tokens. Each MUST now be redacted.
|
||||
func TestScrubToken_RedReviewBypassClasses(t *testing.T) {
|
||||
for _, sec := range []string{
|
||||
"AbCdEf-GhIjKl_MnOpQrStUvWxYz012345", // base64url with - and _
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN", // all-alpha opaque, no digit
|
||||
"hk%2DROTATEKEY0001SECRETKEY", // percent-encoded hk-
|
||||
"sk%5Flive%5FBYPASS0001SECRETKEY", // percent-encoded sk_
|
||||
// Red re-review round 2 — dotted-exemption + encoding + standard-base64:
|
||||
"deadbeefcafe0123456789abcdef0123456789abcdef.x", // ".x" tail forces dotted exemption
|
||||
"AbCdEfGhIjKlMnOpQrStUvWxYz012345.json", // secret with a filename-ish suffix
|
||||
"hk%252DROTATEKEY0001SECRETKEY", // double percent-encoded hk-
|
||||
"AbCdEfGhIjKlMnOpQrStUvWx0123456789", // 34-char opaque run (standard/url b64)
|
||||
// Red re-review round 3 — interior-hyphen raw secret (NOT a structured id:
|
||||
// only 1 hyphen, long parts) must still redact despite '-' in the run.
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
// Red final re-review — a secret CHUNKED to satisfy the structured-id shape
|
||||
// (>=3 groups <=12 chars) must STILL redact: the lexical (word-like) test
|
||||
// rejects mixed-case base64 chunks and hex chunks (>= hexChunkMinLen=4).
|
||||
"AbCdEfGhIjKl-MnOpQrStUvWx-YzAbCdEfGhIj", // mixed-case base64 chunks
|
||||
"A1b2C3d4-E5f6G7h8-I9j0K1l2", // 128-bit mixed-case chunks
|
||||
"deadbeef-cafebabe-01234567-89abcdef", // all-hex chunks (8-char groups)
|
||||
"abcdef01-23456789-abcdef01", // hex chunks
|
||||
// Red final polish — SMALL hex chunks (md5/sha "xxxx-xxxx" display) now
|
||||
// caught by the len>=4 hex rule.
|
||||
"abcd-ef01-2345-6789-abcd-ef01", // 4-char hex groups
|
||||
"dead-beef-cafe-babe-0123-4567", // 4-char hex groups
|
||||
} {
|
||||
if got := scrubToken(sec); got != "[REDACTED-TOKEN]" {
|
||||
t.Errorf("Red bypass STILL OPEN: %q → %q (want redacted)", sec, got)
|
||||
}
|
||||
}
|
||||
// UA with a secret glued by :/()[]= — must be scrubbed, client name kept.
|
||||
ua := scrubFreeText("myapp/1.0 (token:sk-live-BYPASS0001) [key=hk-1234567890abcdef]")
|
||||
for _, leak := range []string{"sk-live-BYPASS0001", "hk-1234567890abcdef"} {
|
||||
if strings.Contains(ua, leak) {
|
||||
t.Errorf("UA bypass: %q leaked in %q", leak, ua)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(ua, "myapp/1.0") {
|
||||
t.Errorf("UA over-scrubbed, lost client name: %q", ua)
|
||||
}
|
||||
// Red re-review round 2 — a key glued by . @ # ~ must NOT survive.
|
||||
for _, glued := range []string{
|
||||
"client@sk-live-SECRETKEY00001", "app.sk-live-SECRETKEY00001",
|
||||
"build#hk-SECRETKEY000000001", "v1~sk-live-SECRETKEY00001",
|
||||
} {
|
||||
if got := scrubFreeText(glued); strings.Contains(got, "SECRETKEY") {
|
||||
t.Errorf("UA glue-char bypass: %q → %q (secret survives)", glued, got)
|
||||
}
|
||||
}
|
||||
// Normal UAs must be byte-identical (no false scrub).
|
||||
for _, ua := range []string{
|
||||
"console2", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
"curl/8.1.2", "Go-http-client/2.0",
|
||||
} {
|
||||
if got := scrubFreeText(ua); got != ua {
|
||||
t.Errorf("UA false positive: %q → %q", ua, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_HealthSuffixCannotEvadeAudit proves a mutating request whose path
|
||||
// ENDS in /health (an attacker-named wildcard segment) is STILL audited — the
|
||||
// liveness exemption is exact and never suppresses a mutation or a denial. This
|
||||
// closes an audit-evasion hole where POST /v1/admin/orgs/x/health would slip past.
|
||||
func TestAudit_HealthSuffixCannotEvadeAudit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Post("/v1/admin/orgs/*", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
|
||||
app.Get("/v1/kms/health", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) })
|
||||
|
||||
// (a) A mutating POST ending in /health MUST be audited.
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/admin/orgs/evil/health", nil)
|
||||
asAdmin(req)
|
||||
mustTest(t, app, req)
|
||||
_, total, _ := rec.Query(t.Context(), audit.Filter{})
|
||||
if total != 1 {
|
||||
t.Fatalf("EVASION: mutating POST ending /health audited %d times, want 1", total)
|
||||
}
|
||||
|
||||
// (b) A genuine liveness GET /v1/kms/health MUST still be skipped.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/v1/kms/health", nil)
|
||||
mustTest(t, app, req2)
|
||||
_, total2, _ := rec.Query(t.Context(), audit.Filter{})
|
||||
if total2 != 1 {
|
||||
t.Fatalf("liveness probe was audited (total went %d→%d) — should be exempt", total, total2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_AnonRequestNotAttributedToForgedOrg proves an UNAUTHENTICATED
|
||||
// attacker cannot forge a false attribution: sending X-Org-Id/X-User-Id/
|
||||
// X-User-IsAdmin with no validated principal records an ANONYMOUS actor (empty
|
||||
// org+sub, not admin), never the claimed victim org. Runs the REAL SanitizeIdentity
|
||||
// (nil validator ⇒ strips authority, restores client X-Org-Id for the data path)
|
||||
// ahead of AuditTrail, exactly as serve.go wires them.
|
||||
func TestAudit_AnonRequestNotAttributedToForgedOrg(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(SanitizeIdentity(nil, "admin")) // trust boundary
|
||||
app.Use(AuditTrail(rec))
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error { return c.JSON(http.StatusOK, map[string]string{"ok": "1"}) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
// Anonymous attacker forging every identity header.
|
||||
req.Header.Set("X-Org-Id", "victim-org")
|
||||
req.Header.Set("X-User-Id", "victim-user")
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
mustTest(t, app, req)
|
||||
|
||||
rows, _, _ := rec.Query(t.Context(), audit.Filter{})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("got %d rows, want 1", len(rows))
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Auth.IsAdmin {
|
||||
t.Error("forged X-User-IsAdmin survived into the record")
|
||||
}
|
||||
if r.Actor.Sub != "" {
|
||||
t.Errorf("forged X-User-Id recorded as actor.Sub = %q", r.Actor.Sub)
|
||||
}
|
||||
if r.Actor.Org == "victim-org" {
|
||||
t.Errorf("FALSE ATTRIBUTION: anonymous request stamped with claimed org %q", r.Actor.Org)
|
||||
}
|
||||
if r.Auth.Method != "none" {
|
||||
t.Errorf("auth method = %q, want none (no valid credential)", r.Auth.Method)
|
||||
}
|
||||
// The event is still recorded (a mutation), honestly anonymous.
|
||||
if r.Outcome.Result != "success" {
|
||||
t.Errorf("outcome = %+v, want the anonymous mutation recorded", r.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_NoopWhenUnconfigured proves a nil Recorder makes the middleware a
|
||||
// pass-through (an unconfigured deployment is never blocked), exactly like
|
||||
// BillingGate's nil-client behavior.
|
||||
func TestAudit_NoopWhenUnconfigured(t *testing.T) {
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(nil))
|
||||
var ran bool
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
|
||||
ran = true
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
resp := mustTest(t, app, req)
|
||||
if resp.StatusCode != http.StatusOK || !ran {
|
||||
t.Fatalf("nil-recorder gate must pass through: status=%d ran=%v", resp.StatusCode, ran)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_FailsClosedOnWriteError proves that when the audit store cannot
|
||||
// record a security-relevant event, the request is failed CLOSED (503) rather
|
||||
// than allowed to succeed unlogged (AU-5). We force the failure by closing the
|
||||
// store's DB before the request, so Append errors.
|
||||
func TestAudit_FailsClosedOnWriteError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
// Close the underlying store so every subsequent Append fails.
|
||||
_ = rec.Close()
|
||||
|
||||
app := zip.New(zip.Config{})
|
||||
app.Use(AuditTrail(rec))
|
||||
var ran bool
|
||||
app.Post("/v1/kms/secrets", func(c *zip.Ctx) error {
|
||||
ran = true
|
||||
return c.JSON(http.StatusCreated, map[string]string{"id": "x"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/kms/secrets", nil)
|
||||
asAdmin(req)
|
||||
resp := mustTest(t, app, req)
|
||||
// The handler may have run (audit wraps AFTER the chain), but the response the
|
||||
// CLIENT sees must be the fail-closed 503, not the handler's 201 — a
|
||||
// security-relevant action that could not be recorded is not acknowledged as
|
||||
// success.
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 (fail-closed when audit write fails)", resp.StatusCode)
|
||||
}
|
||||
_ = ran
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package cloud
|
||||
|
||||
// The datastore (ClickHouse) OLAP mirror — a best-effort projection of the audit
|
||||
// trail for fleet-wide, long-retention, cross-deployment query. It implements
|
||||
// audit.Mirror.
|
||||
//
|
||||
// The datastore is the natural OLAP audit sink: the table is a MergeTree, which
|
||||
// is INSERT-ONLY by engine — ClickHouse rejects UPDATE/DELETE against it at parse
|
||||
// time ("MergeTree does not support mutations"), so the mirror is append-only at
|
||||
// the storage layer, matching the local chain's discipline. We create the table
|
||||
// idempotently on first connect (CREATE TABLE IF NOT EXISTS) and insert via the
|
||||
// canonical clickhouse-go PrepareBatch → Append → Send idiom (the same the
|
||||
// provisioning subsystem uses; the driver is already in cloud's module graph, so
|
||||
// this adds no dependency).
|
||||
//
|
||||
// This mirror is NEVER the integrity authority — the local SQLite hash-chain is.
|
||||
// Its rows carry the same seq + hash so an operator CAN cross-check the OLAP copy
|
||||
// against the chain, but a mirror gap is a query-completeness issue, not a
|
||||
// tamper-evidence one. Every mirror error is logged and dropped by the Recorder;
|
||||
// the request path never sees it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// clickhouseMirror writes audit records to a ClickHouse MergeTree table.
|
||||
type clickhouseMirror struct {
|
||||
conn clickhouse.Conn
|
||||
table string
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
// newAuditMirror builds the OLAP mirror from operator config, or returns nil when
|
||||
// no datastore is configured (mirroring is optional — the local chain is the
|
||||
// authority). It connects lazily-validated (a Ping) and ensures the table exists.
|
||||
//
|
||||
// Config (all from env / KMS-injected secrets, never hard-coded):
|
||||
//
|
||||
// CLOUD_AUDIT_CLICKHOUSE_ADDR host:9000 of the datastore native port
|
||||
// CLOUD_AUDIT_CLICKHOUSE_DB database (default "hanzo")
|
||||
// CLOUD_AUDIT_CLICKHOUSE_TABLE table (default "audit_log")
|
||||
// CLOUD_AUDIT_CLICKHOUSE_USER user
|
||||
// CLOUD_AUDIT_CLICKHOUSE_PASSWORD password (KMS-backed secret)
|
||||
func newAuditMirror(log luxlog.Logger) (audit.Mirror, error) {
|
||||
addr := strings.TrimSpace(os.Getenv("CLOUD_AUDIT_CLICKHOUSE_ADDR"))
|
||||
if addr == "" {
|
||||
return nil, nil // no datastore configured — local chain only.
|
||||
}
|
||||
db := getenv("CLOUD_AUDIT_CLICKHOUSE_DB", "hanzo")
|
||||
table := getenv("CLOUD_AUDIT_CLICKHOUSE_TABLE", "audit_log")
|
||||
|
||||
conn, err := clickhouse.Open(&clickhouse.Options{
|
||||
Addr: []string{addr},
|
||||
Auth: clickhouse.Auth{
|
||||
Database: db,
|
||||
Username: os.Getenv("CLOUD_AUDIT_CLICKHOUSE_USER"),
|
||||
Password: os.Getenv("CLOUD_AUDIT_CLICKHOUSE_PASSWORD"),
|
||||
},
|
||||
DialTimeout: 5 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit mirror: open: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := conn.Ping(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("audit mirror: ping %s: %w", addr, err)
|
||||
}
|
||||
|
||||
qualified := db + "." + table
|
||||
m := &clickhouseMirror{conn: conn, table: qualified, log: log}
|
||||
if err := m.ensureTable(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("audit OLAP mirror connected", "addr", addr, "table", qualified)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ensureTable creates the append-only audit table if it does not exist. MergeTree
|
||||
// = insert-only (mutations rejected at parse time). Partitioned by month and
|
||||
// ordered for the (org, time) query pattern; seq + hash are carried so the OLAP
|
||||
// copy is cross-checkable against the local chain.
|
||||
func (m *clickhouseMirror) ensureTable(ctx context.Context) error {
|
||||
ddl := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
seq UInt64,
|
||||
ts DateTime64(3, 'UTC'),
|
||||
actor_org LowCardinality(String),
|
||||
actor_sub String,
|
||||
actor_email String,
|
||||
action LowCardinality(String),
|
||||
res_type LowCardinality(String),
|
||||
res_id String,
|
||||
auth_method LowCardinality(String),
|
||||
is_admin UInt8,
|
||||
result LowCardinality(String),
|
||||
status UInt16,
|
||||
reason String,
|
||||
source_ip String,
|
||||
user_agent String,
|
||||
request_id String,
|
||||
method LowCardinality(String),
|
||||
path String,
|
||||
prev_hash String,
|
||||
hash String
|
||||
) ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(ts)
|
||||
ORDER BY (actor_org, ts, seq)`, m.table)
|
||||
if err := m.conn.Exec(ctx, ddl); err != nil {
|
||||
return fmt.Errorf("audit mirror: ensure table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Append writes one record to the OLAP mirror via the canonical batch idiom. The
|
||||
// before/after diffs are DELIBERATELY not mirrored — the OLAP copy is for
|
||||
// query/analytics over the event stream, and keeping the (already-redacted but
|
||||
// still payload-bearing) diffs out of the fleet warehouse minimizes the blast
|
||||
// radius of a warehouse compromise. The full record (with diffs) lives only in
|
||||
// the local, access-controlled chain.
|
||||
func (m *clickhouseMirror) Append(ctx context.Context, r audit.Record) error {
|
||||
batch, err := m.conn.PrepareBatch(ctx, "INSERT INTO "+m.table+` (
|
||||
seq, ts, actor_org, actor_sub, actor_email, action, res_type, res_id,
|
||||
auth_method, is_admin, result, status, reason, source_ip, user_agent,
|
||||
request_id, method, path, prev_hash, hash)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit mirror: prepare: %w", err)
|
||||
}
|
||||
if err := batch.Append(
|
||||
r.Seq, r.Time.UTC(), r.Actor.Org, r.Actor.Sub, r.Actor.Email,
|
||||
r.Action, r.Resource.Type, r.Resource.ID,
|
||||
r.Auth.Method, boolToUint8(r.Auth.IsAdmin),
|
||||
r.Outcome.Result, uint16(r.Outcome.Status), r.Outcome.Reason,
|
||||
r.SourceIP, r.UserAgent, r.RequestID, r.Method, r.Path,
|
||||
r.PrevHash, r.Hash,
|
||||
); err != nil {
|
||||
_ = batch.Abort()
|
||||
return fmt.Errorf("audit mirror: append: %w", err)
|
||||
}
|
||||
return batch.Send()
|
||||
}
|
||||
|
||||
// Checkpoint persists a head-digest checkpoint to an INDEPENDENT digest table in
|
||||
// the datastore — the AU-9 tail-truncation anchor. Because this lives in a store
|
||||
// SEPARATE from the local SQLite chain, truncating the chain cannot also rewrite
|
||||
// the checkpoint history: an external monitor querying this table sees the count
|
||||
// series and alerts on any regression. Best-effort; a failure is dropped by the
|
||||
// Recorder (the structured log carries the same digest). Implements
|
||||
// audit.CheckpointSink.
|
||||
func (m *clickhouseMirror) Checkpoint(ctx context.Context, cp audit.Checkpoint) error {
|
||||
if err := m.ensureCheckpointTable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
batch, err := m.conn.PrepareBatch(ctx, "INSERT INTO "+m.table+"_checkpoints (ts, count, head)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit mirror: checkpoint prepare: %w", err)
|
||||
}
|
||||
if err := batch.Append(cp.Time.UTC(), cp.Count, cp.Head); err != nil {
|
||||
_ = batch.Abort()
|
||||
return fmt.Errorf("audit mirror: checkpoint append: %w", err)
|
||||
}
|
||||
return batch.Send()
|
||||
}
|
||||
|
||||
// ensureCheckpointTable creates the append-only checkpoint digest table. A plain
|
||||
// MergeTree ordered by time — the monitor reads the latest rows and checks that
|
||||
// count never decreases.
|
||||
func (m *clickhouseMirror) ensureCheckpointTable(ctx context.Context) error {
|
||||
ddl := fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s_checkpoints (
|
||||
ts DateTime64(3, 'UTC'),
|
||||
count UInt64,
|
||||
head String
|
||||
) ENGINE = MergeTree
|
||||
ORDER BY ts`, m.table)
|
||||
if err := m.conn.Exec(ctx, ddl); err != nil {
|
||||
return fmt.Errorf("audit mirror: ensure checkpoint table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolToUint8(b bool) uint8 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cloud
|
||||
|
||||
// Audit trail construction — the wiring Serve calls to stand up the Recorder.
|
||||
//
|
||||
// The audit store is a COMPLIANCE CONTROL, so its persistence is treated like the
|
||||
// pricing catalog overlay's: a non-persistent (in-memory) audit trail would
|
||||
// silently lose the record of every prior action on each restart — a fail-OPEN
|
||||
// degradation of an integrity control. So an empty DataDir is a hard boot error
|
||||
// in a normal run (prod always sets CLOUD_DATA_DIR; provisioning + pricing already
|
||||
// require it, so the unified binary always has one). The trail can be turned OFF
|
||||
// deliberately (CLOUD_AUDIT_DISABLED=true) for a minimal single-service dev run —
|
||||
// an explicit opt-out, never a silent one.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// buildAuditRecorder constructs the audit Recorder from cfg: the append-only
|
||||
// SQLite chain at {DataDir}/audit.db plus a best-effort ClickHouse OLAP mirror
|
||||
// when a datastore is configured. Returns (nil, nil) only when the trail is
|
||||
// explicitly disabled — the caller then wires a no-op middleware.
|
||||
func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, error) {
|
||||
if getenvBool("CLOUD_AUDIT_DISABLED") {
|
||||
if logger != nil {
|
||||
logger.Warn("audit trail DISABLED by CLOUD_AUDIT_DISABLED — no tamper-evident record will be kept")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
return nil, fmt.Errorf("empty DataDir — the audit trail is a compliance control and requires a persistent data dir (set CLOUD_DATA_DIR); refusing to boot with a non-persistent trail that would lose all prior records on restart (or set CLOUD_AUDIT_DISABLED=true to opt out explicitly)")
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("data dir: %w", err)
|
||||
}
|
||||
|
||||
// OLAP mirror is optional and best-effort. A mirror that cannot be reached at
|
||||
// boot must NOT stop the binary — the local chain is the authority — so a
|
||||
// mirror construction error is logged and the trail runs local-only.
|
||||
var mirror audit.Mirror
|
||||
if m, err := newAuditMirror(logger); err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("audit OLAP mirror unavailable — running local-only (chain integrity unaffected)", "err", err)
|
||||
}
|
||||
} else {
|
||||
mirror = m
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(cfg.DataDir, "audit.db")
|
||||
rec, err := audit.Open(dbPath, mirror)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open audit store: %w", err)
|
||||
}
|
||||
|
||||
// AU-9 tail-truncation anchor: emit a periodic head-digest checkpoint to the
|
||||
// append-only observability log (and, when a mirror supports it, an
|
||||
// independent digest store). An external o11y monitor compares consecutive
|
||||
// checkpoints and alerts on a count regression — the only way to detect that
|
||||
// the most-recent records were deleted (an internal chain walk cannot). The
|
||||
// interval is CLOUD_AUDIT_CHECKPOINT_INTERVAL (default 5m; 0 disables).
|
||||
interval := auditCheckpointInterval()
|
||||
if logger != nil {
|
||||
rec.StartCheckpoints(interval, func(cp audit.Checkpoint) {
|
||||
logger.Info("audit_head_checkpoint",
|
||||
"count", cp.Count, "head", cp.Head, "ts", cp.Time.Format(time.RFC3339Nano))
|
||||
})
|
||||
} else {
|
||||
rec.StartCheckpoints(interval, nil)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
count, head := rec.Head()
|
||||
logger.Info("audit trail ready (tamper-evident, append-only)",
|
||||
"store", dbPath, "records", count, "head", head,
|
||||
"mirror", mirror != nil, "checkpoint_interval", interval.String())
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// auditCheckpointInterval resolves the head-digest checkpoint cadence.
|
||||
// CLOUD_AUDIT_CHECKPOINT_INTERVAL is a Go duration (e.g. "5m", "1h"); default 5m;
|
||||
// "0" disables periodic checkpoints (the on-close checkpoint still fires).
|
||||
func auditCheckpointInterval() time.Duration {
|
||||
if v := getenv("CLOUD_AUDIT_CHECKPOINT_INTERVAL", ""); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return 5 * time.Minute
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package cloud
|
||||
|
||||
// In-binary IAM JWT validation — the trust anchor for SanitizeIdentity.
|
||||
//
|
||||
// This MIRRORS github.com/hanzoai/gateway/v2/iamauth, the canonical edge
|
||||
// validator, but cloud deliberately does NOT import that package: iamauth lives
|
||||
// in the heavyweight gateway module (KrakenD/gin/traefik) AND gateway/v2 already
|
||||
// imports github.com/hanzoai/cloud, so importing it back would braid a module
|
||||
// cycle and pull the gateway's whole dependency tree into cloud for ~150 lines
|
||||
// of validation. The gateway remains the PRIMARY edge authority — in production
|
||||
// it fronts cloud-api (universe routes.yaml). This validator is the in-binary
|
||||
// defense-in-depth layer for the in-cluster / direct path, kept tiny and
|
||||
// auditable on go-jose alone (already in cloud's module graph).
|
||||
//
|
||||
// What it enforces, exactly like iamauth.ValidateToken: signature against the
|
||||
// IAM JWKS, issuer (strict), audience (allowlist, OR semantics), and expiry —
|
||||
// always. A token missing the issuer is rejected.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gojose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
)
|
||||
|
||||
// idClaims is the subset of Hanzo IAM JWT claims the identity sanitizer needs.
|
||||
// Shape mirrors iamauth.Claims so a token resolves identically at both layers.
|
||||
type idClaims struct {
|
||||
jwt.Claims
|
||||
|
||||
Owner string `json:"owner"` // org slug (the tenant)
|
||||
Name string `json:"name"` // display name (id fallback)
|
||||
PreferredUsername string `json:"preferred_username"` // id fallback
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// userID resolves the canonical user id: sub, then preferred_username, then
|
||||
// name. IAM may leave sub empty.
|
||||
func (c *idClaims) userID() string {
|
||||
if c.Subject != "" {
|
||||
return c.Subject
|
||||
}
|
||||
if c.PreferredUsername != "" {
|
||||
return c.PreferredUsername
|
||||
}
|
||||
return c.Name
|
||||
}
|
||||
|
||||
// jwtSigAlgs is the accepted signature-algorithm allowlist passed to
|
||||
// jwt.ParseSigned (go-jose v4 requires it explicitly). RSA + ECDSA + PSS, the
|
||||
// set IAM may sign with — never "none".
|
||||
var jwtSigAlgs = []gojose.SignatureAlgorithm{
|
||||
gojose.RS256, gojose.RS384, gojose.RS512,
|
||||
gojose.ES256, gojose.ES384, gojose.ES512,
|
||||
gojose.PS256, gojose.PS384, gojose.PS512,
|
||||
}
|
||||
|
||||
// identityValidator validates an IAM JWT against a cached JWKS. Issuer +
|
||||
// audience + expiry are always enforced.
|
||||
type identityValidator struct {
|
||||
issuer string
|
||||
audiences []string
|
||||
cache *jwksCache
|
||||
}
|
||||
|
||||
// newIdentityValidator builds a validator. ttl<=0 uses the 15m JWKS default.
|
||||
func newIdentityValidator(issuer, jwksURL string, audiences []string, ttl time.Duration) *identityValidator {
|
||||
return &identityValidator{
|
||||
issuer: strings.TrimSpace(issuer),
|
||||
audiences: audiences,
|
||||
cache: newJWKSCache(jwksURL, ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// validate parses raw, verifies its signature against the JWKS, and enforces
|
||||
// issuer/audience/expiry. Returns the claims on success, an error otherwise.
|
||||
func (v *identityValidator) validate(raw string) (*idClaims, error) {
|
||||
tok, err := jwt.ParseSigned(raw, jwtSigAlgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
keys, err := v.cache.get()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwks: %w", err)
|
||||
}
|
||||
|
||||
var claims idClaims
|
||||
if err := verifyAgainstKeys(tok, keys, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reject a missing issuer: an empty expected issuer would skip the
|
||||
// comparison and let tokens from any issuer pass.
|
||||
if claims.Issuer == "" {
|
||||
return nil, fmt.Errorf("missing issuer")
|
||||
}
|
||||
// Reject a missing expiry: ValidateWithLeeway only enforces exp when present
|
||||
// (it checks `if c.Expiry != nil`), so a token with NO exp would never expire.
|
||||
// An IAM access token always carries exp; require it.
|
||||
if claims.Expiry == nil {
|
||||
return nil, fmt.Errorf("missing expiry")
|
||||
}
|
||||
expected := jwt.Expected{Issuer: v.issuer}
|
||||
if len(v.audiences) > 0 {
|
||||
expected.AnyAudience = jwt.Audience(v.audiences)
|
||||
}
|
||||
if err := claims.Claims.ValidateWithLeeway(expected, 2*time.Minute); err != nil {
|
||||
return nil, fmt.Errorf("claims: %w", err)
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// verifyAgainstKeys tries the kid-matched key first, then any RSA signing key —
|
||||
// mirrors iamauth's selection so a token verifies the same way at both layers.
|
||||
func verifyAgainstKeys(tok *jwt.JSONWebToken, keys *gojose.JSONWebKeySet, claims *idClaims) error {
|
||||
var lastErr error
|
||||
for _, h := range tok.Headers {
|
||||
if h.KeyID == "" {
|
||||
continue
|
||||
}
|
||||
for _, k := range keys.Key(h.KeyID) {
|
||||
if err := tok.Claims(k.Key, claims); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range keys.Keys {
|
||||
if k.Use != "sig" && k.Use != "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := k.Key.(*rsa.PublicKey); !ok {
|
||||
continue
|
||||
}
|
||||
if err := tok.Claims(k.Key, claims); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("no matching key: %w", lastErr)
|
||||
}
|
||||
return fmt.Errorf("no matching key in JWKS")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// JWKS cache (TTL refresh, stale-on-error) — mirrors iamauth.JWKSCache.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type jwksCache struct {
|
||||
mu sync.RWMutex
|
||||
keys *gojose.JSONWebKeySet
|
||||
fetchedAt time.Time
|
||||
ttl time.Duration
|
||||
url string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newJWKSCache(url string, ttl time.Duration) *jwksCache {
|
||||
if ttl <= 0 {
|
||||
ttl = 15 * time.Minute
|
||||
}
|
||||
return &jwksCache{url: url, ttl: ttl, client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
// get returns the cached key set, refreshing past TTL. On a fetch error with a
|
||||
// previously-cached set, the stale set is returned rather than failing — a
|
||||
// transient JWKS blip must not flap validation (and so admin auth) closed.
|
||||
func (c *jwksCache) get() (*gojose.JSONWebKeySet, error) {
|
||||
c.mu.RLock()
|
||||
if c.keys != nil && time.Since(c.fetchedAt) < c.ttl {
|
||||
k := c.keys
|
||||
c.mu.RUnlock()
|
||||
return k, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.keys != nil && time.Since(c.fetchedAt) < c.ttl {
|
||||
return c.keys, nil
|
||||
}
|
||||
keys, err := c.fetch()
|
||||
if err != nil {
|
||||
if c.keys != nil {
|
||||
return c.keys, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
c.keys = keys
|
||||
c.fetchedAt = time.Now()
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (c *jwksCache) fetch() (*gojose.JSONWebKeySet, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request: %w", err)
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
var set gojose.JSONWebKeySet
|
||||
if err := json.Unmarshal(body, &set); err != nil {
|
||||
return nil, fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
return &set, nil
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Token extraction — mirrors iamauth's Bearer / Basic / API-key helpers.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// isAPIKey reports whether tok is an opaque, backend-validated key (hk-/sk-/…)
|
||||
// rather than a JWT, so the sanitizer skips JWT parsing for it.
|
||||
func isAPIKey(tok string) bool {
|
||||
return strings.HasPrefix(tok, "hk-") ||
|
||||
strings.HasPrefix(tok, "sk-") ||
|
||||
strings.HasPrefix(tok, "pk-") ||
|
||||
strings.HasPrefix(tok, "fw_") ||
|
||||
strings.HasPrefix(tok, "hz_")
|
||||
}
|
||||
|
||||
// bearerFromAuth extracts the token from a "Bearer <token>" header value.
|
||||
func bearerFromAuth(auth string) string {
|
||||
if auth == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
// basicFromAuth extracts the token from an HTTP Basic header value: the password
|
||||
// field (the go/.netrc proxy idiom), falling back to the username when empty.
|
||||
func basicFromAuth(auth string) string {
|
||||
if auth == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Basic") {
|
||||
return ""
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
user, pass, ok := strings.Cut(string(raw), ":")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if pass != "" {
|
||||
return pass
|
||||
}
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cloud
|
||||
|
||||
import "strings"
|
||||
|
||||
// Brand white-label registry (HIP-0111).
|
||||
//
|
||||
// The cloud binary is one artifact serving every brand's API host
|
||||
// (api.hanzo.ai, api.lux.cloud, api.zoo.cloud, api.cloud.pars.network, ...).
|
||||
// Brand is a per-deployment value (CLOUD_BRAND / --brand). This registry maps a
|
||||
// brand to its PUBLIC IAM facts — the canonical OIDC issuer the deployment must
|
||||
// validate JWTs against. These are public (issuer host + brand domain), so they
|
||||
// live in code, not in KMS.
|
||||
//
|
||||
// One source of truth: nothing else in the binary hardcodes a per-brand issuer.
|
||||
// Config.IAMIssuer is derived from here when the operator does not pin one, so a
|
||||
// lux deployment validates against lux.id, a zoo deployment against zoo.id, etc.,
|
||||
// instead of silently defaulting every brand to iam.hanzo.ai.
|
||||
|
||||
// BrandInfo is the PUBLIC per-brand identity used for token validation + URL
|
||||
// scoping. No secrets.
|
||||
type BrandInfo struct {
|
||||
// ID is the canonical brand key.
|
||||
ID string
|
||||
// IAMIssuer is the OIDC issuer (JWKS source) for this brand — the value the
|
||||
// JWT `iss` claim must equal and whose /v1/iam/.well-known/jwks signs tokens.
|
||||
IAMIssuer string
|
||||
// Domain is the brand's primary marketing/site domain (for response scoping).
|
||||
Domain string
|
||||
}
|
||||
|
||||
// brands is the brand→IAM registry. Keys are the canonical brand IDs accepted
|
||||
// by CLOUD_BRAND. Per HIP-0111 §Brands: hanzo→hanzo.id, lux→lux.id,
|
||||
// zoo→zoo.id, pars→pars.id, bootnode→id.bootno.de.
|
||||
//
|
||||
// IAMIssuer MUST equal the `iss` IAM actually stamps AND host the signing JWKS.
|
||||
// For hanzo the live .well-known/openid-configuration on BOTH hanzo.id and
|
||||
// iam.hanzo.ai reports issuer=https://hanzo.id + jwks_uri=
|
||||
// https://hanzo.id/v1/iam/.well-known/jwks (iam.hanzo.ai is a routing alias, not
|
||||
// the issuer), and the cloud CLI already defaults to hanzo.id. Pinning
|
||||
// iam.hanzo.ai here would fail the issuer check on every real token, anonymizing
|
||||
// every principal — global admin would 403 platform-wide (fail-secure, but
|
||||
// broken). lux/zoo/pars already correctly point at their own .id issuers.
|
||||
var brands = map[string]BrandInfo{
|
||||
"hanzo": {ID: "hanzo", IAMIssuer: "https://hanzo.id", Domain: "hanzo.ai"},
|
||||
"lux": {ID: "lux", IAMIssuer: "https://lux.id", Domain: "lux.network"},
|
||||
"zoo": {ID: "zoo", IAMIssuer: "https://zoo.id", Domain: "zoo.ngo"},
|
||||
"pars": {ID: "pars", IAMIssuer: "https://pars.id", Domain: "pars.network"},
|
||||
"bootnode": {ID: "bootnode", IAMIssuer: "https://id.bootno.de", Domain: "bootno.de"},
|
||||
}
|
||||
|
||||
// DefaultBrand is the fallback brand when CLOUD_BRAND is unknown.
|
||||
const DefaultBrand = "hanzo"
|
||||
|
||||
// BrandFor returns the BrandInfo for id, falling back to the Hanzo brand for an
|
||||
// unknown id. Lookup is case-insensitive.
|
||||
func BrandFor(id string) BrandInfo {
|
||||
if b, ok := brands[strings.ToLower(strings.TrimSpace(id))]; ok {
|
||||
return b
|
||||
}
|
||||
return brands[DefaultBrand]
|
||||
}
|
||||
|
||||
// IssuerForBrand returns the canonical OIDC issuer for a brand id.
|
||||
func IssuerForBrand(id string) string {
|
||||
return BrandFor(id).IAMIssuer
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBrandFor(t *testing.T) {
|
||||
// hanzo → hanzo.id (NOT iam.hanzo.ai): brand.go was pinned to the real OIDC
|
||||
// issuer in fddaeb14 ("pin hanzo IAM issuer to hanzo.id") — iam.hanzo.ai is a
|
||||
// routing alias, and the live .well-known reports iss=https://hanzo.id, so a
|
||||
// token would fail the issuer check against iam.hanzo.ai. This stale
|
||||
// assertion predated that pin; aligned here (drive-by, brand.go unchanged).
|
||||
cases := map[string]string{
|
||||
"hanzo": "https://hanzo.id",
|
||||
"lux": "https://lux.id",
|
||||
"zoo": "https://zoo.id",
|
||||
"pars": "https://pars.id",
|
||||
"bootnode": "https://id.bootno.de",
|
||||
"LUX": "https://lux.id", // case-insensitive
|
||||
" zoo ": "https://zoo.id", // trimmed
|
||||
"unknown": "https://hanzo.id", // falls back to hanzo
|
||||
"": "https://hanzo.id", // empty → hanzo default
|
||||
}
|
||||
for brand, want := range cases {
|
||||
if got := IssuerForBrand(brand); got != want {
|
||||
t.Errorf("IssuerForBrand(%q) = %q, want %q", brand, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfig_IssuerDerivedFromBrand asserts that when CLOUD_IAM_ISSUER is
|
||||
// unset, the issuer is derived from CLOUD_BRAND — so a non-hanzo brand does not
|
||||
// silently validate against iam.hanzo.ai.
|
||||
func TestLoadConfig_IssuerDerivedFromBrand(t *testing.T) {
|
||||
t.Setenv("CLOUD_BRAND", "lux")
|
||||
os.Unsetenv("CLOUD_IAM_ISSUER")
|
||||
cfg := LoadConfig()
|
||||
if cfg.IAMIssuer != "https://lux.id" {
|
||||
t.Fatalf("derived issuer = %q, want https://lux.id", cfg.IAMIssuer)
|
||||
}
|
||||
if cfg.Brand != "lux" {
|
||||
t.Fatalf("brand = %q, want lux", cfg.Brand)
|
||||
}
|
||||
}
|
||||
@@ -3,34 +3,243 @@ package cloud
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/commerce/metering"
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/hanzoai/cloud/clients"
|
||||
"github.com/hanzoai/cloud/clients/kmsembed"
|
||||
)
|
||||
|
||||
// BuildDeps constructs the Deps used by every subsystem's Mount(app, deps).
|
||||
// For each enabled subsystem, the corresponding Client field gets a real
|
||||
// in-process implementation; disabled subsystems get a ZAP-RPC client
|
||||
// pointing at an external endpoint (if configured) or a nil client (if
|
||||
// not used).
|
||||
//
|
||||
// In this initial scaffold the clients are nil — concrete in-process
|
||||
// wiring lands as each subsystem's Mount() is integrated. Subsystems
|
||||
// should defensively handle deps.X == nil during the rollout.
|
||||
// Wiring rules per HIP-0106 inter-subsystem contract:
|
||||
//
|
||||
// 1. If the subsystem is enabled in this process, the Client field is
|
||||
// left nil here. The subsystem's own Mount() will install a typed
|
||||
// in-process Client into Deps via the SetClient helpers exposed by
|
||||
// this package. (Subsystem Mounts run after BuildDeps; they have
|
||||
// full access to construct their concrete implementation, and the
|
||||
// resulting object goes back into Deps for everyone else to call.)
|
||||
//
|
||||
// 2. If the subsystem is disabled but cfg has a non-empty ZAP RPC
|
||||
// endpoint for it, the Client field gets a ZAP-RPC stub targeting
|
||||
// that endpoint. Subsystem code calls deps.X.Foo(...) without
|
||||
// knowing the call goes over the wire.
|
||||
//
|
||||
// 3. If the subsystem is disabled AND there is no endpoint, the Client
|
||||
// field gets a "disabled" stub that fails closed with a clear
|
||||
// error. Mount-time consumers detect this with
|
||||
// clients.IsDisabled(err) and log a friendly "dep X needed by Y
|
||||
// not configured" message.
|
||||
//
|
||||
// JSON does not appear in any of these paths. Inter-subsystem calls
|
||||
// are ZAP-typed Go values either via direct method dispatch (mode 1)
|
||||
// or via ZAP RPC over the wire (mode 2). JSON happens only at the
|
||||
// gateway/ingress edge, through the hanzoai/zip jsonenc helper.
|
||||
//
|
||||
// Payments and Vault are special: they are NEVER in-process per
|
||||
// HIP-0106 solo-vault CDE. Their clients always resolve via
|
||||
// clients.PaymentsRPCAt / clients.VaultRPCAt; the disabled stub fires
|
||||
// when no endpoint is configured.
|
||||
func BuildDeps(cfg *Config) Deps {
|
||||
logger := luxlog.New("cloud")
|
||||
logger.Info("building deps",
|
||||
"brand", cfg.Brand,
|
||||
"domain", cfg.Domain,
|
||||
"iam_issuer", cfg.IAMIssuer,
|
||||
"data_dir", cfg.DataDir,
|
||||
"enabled", cfg.Enable,
|
||||
)
|
||||
return Deps{
|
||||
Logger: logger,
|
||||
Brand: cfg.Brand,
|
||||
Domain: cfg.Domain,
|
||||
DataDir: cfg.DataDir,
|
||||
// Subsystem clients are populated incrementally by subsystem Init
|
||||
// hooks during the migration. See cmd/cloud/main.go.
|
||||
|
||||
deps := Deps{
|
||||
Logger: logger,
|
||||
Brand: cfg.Brand,
|
||||
Env: cfg.Env,
|
||||
Domain: cfg.Domain,
|
||||
IAMIssuer: cfg.IAMIssuer,
|
||||
DataDir: cfg.DataDir,
|
||||
}
|
||||
|
||||
// For each subsystem: enabled → leave nil (Mount fills it); not
|
||||
// enabled + endpoint → RPC client; not enabled + no endpoint →
|
||||
// disabled stub.
|
||||
deps.IAM = pickIAMClient(cfg, logger)
|
||||
deps.KMS = pickKMSClient(cfg, logger)
|
||||
deps.Base = pickBaseClient(cfg, logger)
|
||||
deps.Commerce = pickCommerceClient(cfg, logger)
|
||||
deps.AI = pickAIClient(cfg, logger)
|
||||
deps.O11y = pickO11yClient(cfg, logger)
|
||||
deps.VFS = pickVFSClient(cfg, logger)
|
||||
deps.MQ = pickMQClient(cfg, logger)
|
||||
|
||||
// Payments and Vault never co-resident. Disabled stub when no
|
||||
// endpoint, otherwise RPC.
|
||||
deps.Payments = pickPaymentsClient(cfg, logger)
|
||||
deps.Vault = pickVaultClient(cfg, logger)
|
||||
|
||||
// Billing metering client for the request-edge gate. nil-safe: when no
|
||||
// commerce URL is configured the resulting client is !Enabled() and the
|
||||
// gate is a no-op.
|
||||
deps.Metering = buildMeteringClient(cfg, logger)
|
||||
|
||||
return deps
|
||||
}
|
||||
|
||||
// buildMeteringClient constructs the commerce metering client for BillingGate.
|
||||
// An empty CommerceHTTPURL yields a not-Enabled() client (allow + no-op),
|
||||
// matching the metering package's "not configured" mode, so an unconfigured
|
||||
// deployment is never blocked. The token is a KMS-sourced secret supplied via
|
||||
// config; it is never logged.
|
||||
func buildMeteringClient(cfg *Config, log luxlog.Logger) *metering.Client {
|
||||
m, err := metering.New(metering.Config{
|
||||
BaseURL: cfg.CommerceHTTPURL,
|
||||
Token: cfg.CommerceServiceToken,
|
||||
Org: cfg.Brand, // X-Org-Id default for S2S; per-request org overrides.
|
||||
FailOpen: cfg.BillingFailOpen,
|
||||
})
|
||||
if err != nil {
|
||||
// Only an unparseable URL reaches here. Fall back to a not-configured
|
||||
// client (no-op gate) rather than failing boot over billing wiring.
|
||||
log.Error("billing: invalid commerce URL, gate disabled", "err", err)
|
||||
m, _ = metering.New(metering.Config{})
|
||||
}
|
||||
if m.Enabled() {
|
||||
log.Info("billing gate enabled", "commerce_url", cfg.CommerceHTTPURL, "fail_open", cfg.BillingFailOpen)
|
||||
} else {
|
||||
log.Info("billing gate disabled (no commerce URL)")
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// pickIAMClient returns the canonical IAMClient for this process.
|
||||
// nil = enabled here, Mount will fill it. RPC = remote endpoint
|
||||
// configured. Disabled = not enabled, no endpoint.
|
||||
func pickIAMClient(cfg *Config, log luxlog.Logger) IAMClient {
|
||||
if cfg.Enabled("iam") {
|
||||
return nil
|
||||
}
|
||||
if cfg.IAMZAPAddr != "" {
|
||||
log.Info("deps.IAM → ZAP RPC", "addr", cfg.IAMZAPAddr)
|
||||
return clients.IAMRPCAt(cfg.IAMZAPAddr)
|
||||
}
|
||||
return clients.DisabledIAM()
|
||||
}
|
||||
|
||||
// pickKMSClient resolves deps.KMS. When the kms subsystem is co-resident
|
||||
// (Enabled("kmssvc")) it returns the IN-PROCESS Client backed by the embedded
|
||||
// luxfi/kms SecretStore under CLOUD_DATA_DIR — no external RPC. A store-open
|
||||
// failure is NOT fatal to the whole binary: it falls back to the disabled stub
|
||||
// (fail-closed) and logs, so a bad data dir degrades KMS rather than crashing
|
||||
// every subsystem. Absent co-residency the legacy ZAP-RPC + disabled fallbacks
|
||||
// apply (out-of-process KMS, or not wired).
|
||||
//
|
||||
// The internal subsystem name is "kmssvc" (see clients/kms.init — it avoids the
|
||||
// serve.go generic-health shadow on /v1/kms/health); the client gate keys on the
|
||||
// same name so "enabled" is one concept.
|
||||
func pickKMSClient(cfg *Config, log luxlog.Logger) KMSClient {
|
||||
if cfg.Enabled("kmssvc") {
|
||||
c, err := kmsembed.New(kmsembed.Config{
|
||||
DataDir: cfg.DataDir,
|
||||
MasterKeyB64: cfg.KMSMasterKeyRef,
|
||||
MPCAddr: cfg.KMSMPCAddr,
|
||||
MPCVaultID: cfg.KMSMPCVaultID,
|
||||
}, log)
|
||||
if err != nil {
|
||||
log.Error("deps.KMS: embedded KMS unavailable, failing closed", "err", err)
|
||||
return clients.DisabledKMS()
|
||||
}
|
||||
log.Info("deps.KMS → in-process (embedded luxfi/kms)", "ready", c.Ready(), "signing", c.SigningConfigured())
|
||||
return c
|
||||
}
|
||||
if cfg.KMSZAPAddr != "" {
|
||||
log.Info("deps.KMS → ZAP RPC", "addr", cfg.KMSZAPAddr)
|
||||
return clients.KMSRPCAt(cfg.KMSZAPAddr)
|
||||
}
|
||||
return clients.DisabledKMS()
|
||||
}
|
||||
|
||||
func pickBaseClient(cfg *Config, log luxlog.Logger) BaseClient {
|
||||
if cfg.Enabled("base") {
|
||||
return nil
|
||||
}
|
||||
if cfg.BaseZAPAddr != "" {
|
||||
log.Info("deps.Base → ZAP RPC", "addr", cfg.BaseZAPAddr)
|
||||
return clients.BaseRPCAt(cfg.BaseZAPAddr)
|
||||
}
|
||||
return clients.DisabledBase()
|
||||
}
|
||||
|
||||
func pickCommerceClient(cfg *Config, log luxlog.Logger) CommerceClient {
|
||||
if cfg.Enabled("commerce") {
|
||||
return nil
|
||||
}
|
||||
if cfg.CommerceZAPAddr != "" {
|
||||
log.Info("deps.Commerce → ZAP RPC", "addr", cfg.CommerceZAPAddr)
|
||||
return clients.CommerceRPCAt(cfg.CommerceZAPAddr)
|
||||
}
|
||||
return clients.DisabledCommerce()
|
||||
}
|
||||
|
||||
func pickAIClient(cfg *Config, log luxlog.Logger) AIClient {
|
||||
if cfg.Enabled("ai") {
|
||||
return nil
|
||||
}
|
||||
if cfg.AIZAPAddr != "" {
|
||||
log.Info("deps.AI → ZAP RPC", "addr", cfg.AIZAPAddr)
|
||||
return clients.AIRPCAt(cfg.AIZAPAddr)
|
||||
}
|
||||
return clients.DisabledAI()
|
||||
}
|
||||
|
||||
func pickO11yClient(cfg *Config, log luxlog.Logger) O11yClient {
|
||||
if cfg.Enabled("o11y") {
|
||||
return nil
|
||||
}
|
||||
if cfg.O11yZAPAddr != "" {
|
||||
log.Info("deps.O11y → ZAP RPC", "addr", cfg.O11yZAPAddr)
|
||||
return clients.O11yRPCAt(cfg.O11yZAPAddr)
|
||||
}
|
||||
// O11y disabled-stub is no-op (not fail-closed) — telemetry
|
||||
// going nowhere is a normal mode.
|
||||
return clients.DisabledO11y()
|
||||
}
|
||||
|
||||
func pickVFSClient(cfg *Config, log luxlog.Logger) VFSClient {
|
||||
if cfg.Enabled("vfs") {
|
||||
return nil
|
||||
}
|
||||
if cfg.VFSZAPAddr != "" {
|
||||
log.Info("deps.VFS → ZAP RPC", "addr", cfg.VFSZAPAddr)
|
||||
return clients.VFSRPCAt(cfg.VFSZAPAddr)
|
||||
}
|
||||
return clients.DisabledVFS()
|
||||
}
|
||||
|
||||
func pickMQClient(cfg *Config, log luxlog.Logger) MQClient {
|
||||
if cfg.Enabled("mq") {
|
||||
return nil
|
||||
}
|
||||
if cfg.MQZAPAddr != "" {
|
||||
log.Info("deps.MQ → ZAP RPC", "addr", cfg.MQZAPAddr)
|
||||
return clients.MQRPCAt(cfg.MQZAPAddr)
|
||||
}
|
||||
return clients.DisabledMQ()
|
||||
}
|
||||
|
||||
func pickPaymentsClient(cfg *Config, log luxlog.Logger) PaymentsClient {
|
||||
if cfg.PaymentsZAPAddr != "" {
|
||||
log.Info("deps.Payments → ZAP RPC", "addr", cfg.PaymentsZAPAddr)
|
||||
return clients.PaymentsRPCAt(cfg.PaymentsZAPAddr)
|
||||
}
|
||||
return clients.DisabledPayments()
|
||||
}
|
||||
|
||||
func pickVaultClient(cfg *Config, log luxlog.Logger) VaultClient {
|
||||
if cfg.VaultZAPAddr != "" {
|
||||
log.Info("deps.Vault → ZAP RPC", "addr", cfg.VaultZAPAddr)
|
||||
return clients.VaultRPCAt(cfg.VaultZAPAddr)
|
||||
}
|
||||
return clients.DisabledVault()
|
||||
}
|
||||
|
||||
// MountFunc is the canonical signature every subsystem exposes per
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package cloud_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients"
|
||||
)
|
||||
|
||||
// TestBuildDeps_EnabledLeavesNil verifies that BuildDeps leaves an enabled
|
||||
// Mount-fills-it subsystem's Client field nil — the subsystem Mount() installs
|
||||
// it. KMS is the exception (see TestBuildDeps_KMSEnabledIsInProcess): it is
|
||||
// constructed eagerly in BuildDeps because its store must exist before any
|
||||
// dependent subsystem mounts.
|
||||
func TestBuildDeps_EnabledLeavesNil(t *testing.T) {
|
||||
cfg := &cloud.Config{
|
||||
Brand: "hanzo",
|
||||
Domain: "api.hanzo.ai",
|
||||
DataDir: t.TempDir(),
|
||||
Enable: []string{"iam", "base", "commerce", "ai", "o11y", "vfs", "mq"},
|
||||
}
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
|
||||
if deps.IAM != nil {
|
||||
t.Errorf("deps.IAM: enabled subsystem must leave Client nil, got %T", deps.IAM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDeps_KMSEnabledIsInProcess verifies the HIP-0106 "embed KMS in cloud"
|
||||
// contract: when the kms subsystem (kmssvc) is enabled, deps.KMS is a live
|
||||
// in-process client (never nil, never a disabled stub) so other subsystems get a
|
||||
// working KMS via direct Go dispatch with no RPC. Absent a master key it still
|
||||
// resolves (health-only, fail-closed) — the point is that deps.KMS is populated.
|
||||
func TestBuildDeps_KMSEnabledIsInProcess(t *testing.T) {
|
||||
cfg := &cloud.Config{
|
||||
Brand: "hanzo",
|
||||
Domain: "api.hanzo.ai",
|
||||
DataDir: t.TempDir(),
|
||||
Enable: []string{"kmssvc"},
|
||||
}
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
|
||||
if deps.KMS == nil {
|
||||
t.Fatal("deps.KMS: enabled kmssvc must give an in-process client, got nil")
|
||||
}
|
||||
// It must NOT be the fail-closed disabled stub — that stub returns IsDisabled
|
||||
// errors; an in-process client (no master key) returns a master-key error.
|
||||
_, err := deps.KMS.GetSecret(context.Background(), "any")
|
||||
if err == nil {
|
||||
t.Fatal("GetSecret with no master key must fail closed")
|
||||
}
|
||||
if clients.IsDisabled(err) {
|
||||
t.Errorf("deps.KMS resolved to the DISABLED stub, want the in-process client: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDeps_DisabledNoEndpointReturnsDisabled verifies that a
|
||||
// disabled subsystem with no RPC endpoint resolves to the disabled
|
||||
// fail-closed stub.
|
||||
func TestBuildDeps_DisabledNoEndpointReturnsDisabled(t *testing.T) {
|
||||
cfg := &cloud.Config{
|
||||
Brand: "hanzo",
|
||||
Domain: "api.hanzo.ai",
|
||||
DataDir: "/tmp",
|
||||
Enable: []string{"gateway"}, // intentionally none of the others
|
||||
}
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
|
||||
if deps.IAM == nil {
|
||||
t.Fatal("deps.IAM: disabled + no endpoint must give a disabled stub, got nil")
|
||||
}
|
||||
_, err := deps.IAM.VerifyJWT(context.Background(), "tok")
|
||||
if err == nil {
|
||||
t.Fatal("expected disabledErr from VerifyJWT")
|
||||
}
|
||||
if !clients.IsDisabled(err) {
|
||||
t.Errorf("expected IsDisabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDeps_DisabledWithEndpointReturnsRPC verifies that a
|
||||
// disabled subsystem with a configured ZAP endpoint resolves to the
|
||||
// RPC stub.
|
||||
func TestBuildDeps_DisabledWithEndpointReturnsRPC(t *testing.T) {
|
||||
cfg := &cloud.Config{
|
||||
Brand: "hanzo",
|
||||
Domain: "api.hanzo.ai",
|
||||
DataDir: "/tmp",
|
||||
Enable: []string{"gateway"},
|
||||
IAMZAPAddr: "iam.hanzo.svc:9653",
|
||||
}
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
|
||||
if deps.IAM == nil {
|
||||
t.Fatal("deps.IAM: expected RPC stub")
|
||||
}
|
||||
_, err := deps.IAM.VerifyJWT(context.Background(), "tok")
|
||||
if err == nil {
|
||||
t.Fatal("expected error from RPC stub (transport pending)")
|
||||
}
|
||||
if clients.IsDisabled(err) {
|
||||
t.Errorf("expected NOT-disabled, got disabled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDeps_PaymentsAndVault_AlwaysRPC verifies that payments and
|
||||
// vault always resolve to a non-nil client even though neither is in
|
||||
// the enabled list — they are not co-resident per HIP-0106.
|
||||
func TestBuildDeps_PaymentsAndVault_AlwaysRPC(t *testing.T) {
|
||||
cfg := &cloud.Config{
|
||||
Brand: "hanzo",
|
||||
Domain: "api.hanzo.ai",
|
||||
DataDir: "/tmp",
|
||||
Enable: []string{"commerce"},
|
||||
PaymentsZAPAddr: "payments.hanzo.svc:9653",
|
||||
VaultZAPAddr: "vault.hanzo.svc:9653",
|
||||
}
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
|
||||
if deps.Payments == nil {
|
||||
t.Fatal("deps.Payments must be non-nil even when not enabled")
|
||||
}
|
||||
if deps.Vault == nil {
|
||||
t.Fatal("deps.Vault must be non-nil even when not enabled")
|
||||
}
|
||||
// Call them to confirm typed dispatch — they'll return "transport
|
||||
// pending" errors but not nil deref.
|
||||
if _, err := deps.Payments.CreateIntent(context.Background(), &cloud.IntentRequest{Token: "tok-1", Currency: "USD", AmountCents: 100}); err == nil {
|
||||
t.Fatal("expected RPC stub error")
|
||||
}
|
||||
if _, err := deps.Vault.Charge(context.Background(), &cloud.VaultChargeRequest{Token: "tok-1", AmountCents: 100}); err == nil {
|
||||
t.Fatal("expected RPC stub error")
|
||||
}
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// iamClient is a thin client over the Hanzo IAM OAuth2 surface at
|
||||
// {issuer}/v1/iam/oauth/*. It speaks only the standard token + userinfo
|
||||
// endpoints; it holds no IAM business logic.
|
||||
type iamClient struct {
|
||||
issuer string
|
||||
clientID string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newIAMClient(issuer, clientID string) *iamClient {
|
||||
return &iamClient{
|
||||
issuer: strings.TrimRight(issuer, "/"),
|
||||
clientID: clientID,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// tokenResp is the OAuth2 token endpoint response (success or RFC-6749 error).
|
||||
type tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
// postForm performs an x-www-form-urlencoded POST to an oauth endpoint and
|
||||
// decodes the token response, surfacing OAuth errors as Go errors.
|
||||
func (c *iamClient) postForm(ctx context.Context, endpoint string, form url.Values) (*tokenResp, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.issuer+endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
|
||||
var tr tokenResp
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return nil, fmt.Errorf("iam %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if tr.Error != "" {
|
||||
return nil, fmt.Errorf("iam %s: %s: %s", endpoint, tr.Error, tr.ErrorDesc)
|
||||
}
|
||||
if tr.AccessToken == "" {
|
||||
return nil, fmt.Errorf("iam %s: HTTP %d: no access_token in response", endpoint, resp.StatusCode)
|
||||
}
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
// passwordGrant exchanges username+password for a token (the live IAM client
|
||||
// supports password grant; device_code is hard-rejected server-side).
|
||||
func (c *iamClient) passwordGrant(ctx context.Context, username, password, scope string) (*tokenResp, error) {
|
||||
return c.postForm(ctx, "/v1/iam/oauth/access_token", url.Values{
|
||||
"grant_type": {"password"},
|
||||
"client_id": {c.clientID},
|
||||
"username": {username},
|
||||
"password": {password},
|
||||
"scope": {scope},
|
||||
})
|
||||
}
|
||||
|
||||
// refreshGrant exchanges a refresh token for a fresh access token.
|
||||
func (c *iamClient) refreshGrant(ctx context.Context, refreshToken string) (*tokenResp, error) {
|
||||
return c.postForm(ctx, "/v1/iam/oauth/refresh_token", url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"client_id": {c.clientID},
|
||||
"refresh_token": {refreshToken},
|
||||
})
|
||||
}
|
||||
|
||||
// decodeJWTClaims base64url-decodes a JWT's payload segment WITHOUT verifying
|
||||
// the signature — used only to display the user's own token claims locally.
|
||||
func decodeJWTClaims(token string) (map[string]any, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("not a JWT (need 3 dot-separated segments)")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode JWT payload: %w", err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, fmt.Errorf("parse JWT claims: %w", err)
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// claimString reads a string claim, tolerating absence.
|
||||
func claimString(claims map[string]any, key string) string {
|
||||
if v, ok := claims[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// credsFromToken builds a Credentials carrying the token plus the identity
|
||||
// fields decoded from its claims. expiresIn (token endpoint) wins for expiry;
|
||||
// otherwise the JWT `exp` claim is used.
|
||||
func credsFromToken(tr *tokenResp) *Credentials {
|
||||
c := &Credentials{
|
||||
AccessToken: tr.AccessToken,
|
||||
RefreshToken: tr.RefreshToken,
|
||||
TokenType: firstNonEmpty(tr.TokenType, "Bearer"),
|
||||
}
|
||||
if claims, err := decodeJWTClaims(tr.AccessToken); err == nil {
|
||||
c.Subject = firstNonEmpty(claimString(claims, "email"), claimString(claims, "sub"))
|
||||
c.Owner = claimString(claims, "owner")
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
c.Expiry = int64(exp)
|
||||
}
|
||||
}
|
||||
if tr.ExpiresIn > 0 {
|
||||
c.Expiry = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second).Unix()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands: login / logout / whoami, grouped under `auth`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// loginFlags are shared by `hanzo login` and `hanzo auth login`.
|
||||
type loginFlags struct {
|
||||
username string
|
||||
passwordStdin bool
|
||||
token string
|
||||
platformToken string
|
||||
buildToken string
|
||||
scope string
|
||||
}
|
||||
|
||||
func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
|
||||
creds, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case lf.token != "":
|
||||
// Paste an externally-minted token. Decode claims for identity.
|
||||
tr := &tokenResp{AccessToken: lf.token, TokenType: "Bearer"}
|
||||
creds = credsFromToken(tr)
|
||||
default:
|
||||
username := lf.username
|
||||
if username == "" {
|
||||
username, err = prompt(cmd, "Email: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
password, err := readPassword(cmd, lf.passwordStdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
iam := newIAMClient(env.IAMIssuer, env.ClientID)
|
||||
tr, err := iam.passwordGrant(cmd.Context(), username, password, lf.scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creds = credsFromToken(tr)
|
||||
}
|
||||
|
||||
// Optional machine-to-machine tokens for the platform control plane,
|
||||
// stored alongside the identity so apps/deploy work post-login.
|
||||
if lf.platformToken != "" {
|
||||
creds.PlatformToken = lf.platformToken
|
||||
}
|
||||
if lf.buildToken != "" {
|
||||
creds.BuildToken = lf.buildToken
|
||||
}
|
||||
|
||||
if err := creds.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
who := firstNonEmpty(creds.Subject, "(unknown)")
|
||||
if creds.Owner != "" {
|
||||
who += " @ " + creds.Owner
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s (token expires %s)\n", who, shortTime(creds.Expiry))
|
||||
return nil
|
||||
}
|
||||
|
||||
func newLoginCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
|
||||
lf := &loginFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate against Hanzo IAM and store a token",
|
||||
Long: "Authenticate against Hanzo IAM (hanzo.id) via the password grant and store\n" +
|
||||
"the token in ~/.hanzo/credentials.json (mode 0600). Use --token to store an\n" +
|
||||
"externally-minted token instead, and --platform-token to store the platform\n" +
|
||||
"control-plane service token needed by apps/deploy/clusters.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error { return runLogin(envOf(), lf, cmd) },
|
||||
}
|
||||
bindLoginFlags(cmd, lf)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func bindLoginFlags(cmd *cobra.Command, lf *loginFlags) {
|
||||
f := cmd.Flags()
|
||||
f.StringVarP(&lf.username, "username", "u", "", "IAM username/email")
|
||||
f.BoolVar(&lf.passwordStdin, "password-stdin", false, "read the password from stdin (for automation)")
|
||||
f.StringVar(&lf.token, "token", "", "store this access token directly (skip the password grant)")
|
||||
f.StringVar(&lf.platformToken, "platform-token", "", "also store the platform control-plane service token")
|
||||
f.StringVar(&lf.buildToken, "build-token", "", "also store the platform build-enqueue token")
|
||||
f.StringVar(&lf.scope, "scope", "openid profile email", "OAuth scope")
|
||||
}
|
||||
|
||||
func newLogoutCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove stored credentials",
|
||||
Args: cobra.NoArgs,
|
||||
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := DeleteCredentials(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Logged out.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newWhoamiCmd(envOf func() *Env) *cobra.Command {
|
||||
var verify bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current identity from the stored token",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
env := envOf()
|
||||
tok := env.accessToken()
|
||||
if tok == "" {
|
||||
return fmt.Errorf("not logged in: run `hanzo login`")
|
||||
}
|
||||
claims, err := decodeJWTClaims(tok)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if verify {
|
||||
if err := verifyUserInfo(cmd.Context(), env, tok); err != nil {
|
||||
return fmt.Errorf("token rejected by IAM: %w", err)
|
||||
}
|
||||
}
|
||||
return env.emit(claims, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "email: %s\n", claimString(claims, "email"))
|
||||
fmt.Fprintf(w, "name: %s\n", firstNonEmpty(claimString(claims, "displayName"), claimString(claims, "name")))
|
||||
fmt.Fprintf(w, "org: %s\n", claimString(claims, "owner"))
|
||||
fmt.Fprintf(w, "subject: %s\n", claimString(claims, "sub"))
|
||||
fmt.Fprintf(w, "issuer: %s\n", claimString(claims, "iss"))
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
fmt.Fprintf(w, "expires: %s\n", shortTime(int64(exp)))
|
||||
}
|
||||
if verify {
|
||||
fmt.Fprintln(w, "verified: yes (IAM userinfo accepted the token)")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&verify, "verify", false, "verify the token against the IAM userinfo endpoint")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// verifyUserInfo calls the IAM userinfo endpoint with the bearer token; a 2xx
|
||||
// means IAM accepts the token as live.
|
||||
func verifyUserInfo(ctx context.Context, env *Env, token string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, env.IAMIssuer+"/v1/iam/oauth/userinfo", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
|
||||
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newAuthCmd is the `auth` group: login/logout/whoami plus `token` (print the
|
||||
// stored access token, for piping into other tools).
|
||||
func newAuthCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage authentication",
|
||||
}
|
||||
tokenCmd := &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Print the stored access token",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
tok := envOf().accessToken()
|
||||
if tok == "" {
|
||||
return fmt.Errorf("not logged in: run `hanzo login`")
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), tok)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newLoginCmd(envOf, gf), newLogoutCmd(), newWhoamiCmd(envOf), tokenCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// prompt writes a prompt to stderr and reads a trimmed line from stdin.
|
||||
func prompt(cmd *cobra.Command, label string) (string, error) {
|
||||
fmt.Fprint(cmd.ErrOrStderr(), label)
|
||||
r := bufio.NewReader(cmd.InOrStdin())
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
|
||||
// readPassword reads a password without echo from the terminal, or as a plain
|
||||
// line from stdin when --password-stdin is set (automation) or stdin is not a
|
||||
// terminal.
|
||||
func readPassword(cmd *cobra.Command, fromStdin bool) (string, error) {
|
||||
if fromStdin {
|
||||
r := bufio.NewReader(cmd.InOrStdin())
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
if f, ok := cmd.InOrStdin().(*os.File); ok && term.IsTerminal(int(f.Fd())) {
|
||||
fmt.Fprint(cmd.ErrOrStderr(), "Password: ")
|
||||
b, err := term.ReadPassword(int(f.Fd()))
|
||||
fmt.Fprintln(cmd.ErrOrStderr())
|
||||
return string(b), err
|
||||
}
|
||||
// Non-terminal stdin without --password-stdin: read a line so piped input
|
||||
// still works, but nudge toward the explicit flag.
|
||||
r := bufio.NewReader(cmd.InOrStdin())
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeJWT builds an unsigned JWT (alg=none) carrying claims — enough to test
|
||||
// the local, signature-free claim decode the CLI uses for display.
|
||||
func makeJWT(claims map[string]any) string {
|
||||
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||
p, _ := json.Marshal(claims)
|
||||
return hdr + "." + base64.RawURLEncoding.EncodeToString(p) + ".sig"
|
||||
}
|
||||
|
||||
func TestDecodeJWTClaims(t *testing.T) {
|
||||
tok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo", "sub": "abc", "exp": float64(1783110016)})
|
||||
claims, err := decodeJWTClaims(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if claimString(claims, "email") != "z@hanzo.ai" || claimString(claims, "owner") != "hanzo" {
|
||||
t.Fatalf("claims wrong: %+v", claims)
|
||||
}
|
||||
if _, err := decodeJWTClaims("not-a-jwt"); err == nil {
|
||||
t.Fatalf("expected error for non-JWT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredsFromToken(t *testing.T) {
|
||||
tok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo", "exp": float64(2000000000)})
|
||||
// expires_in present → wins over exp.
|
||||
c := credsFromToken(&tokenResp{AccessToken: tok, RefreshToken: "r", ExpiresIn: 3600})
|
||||
if c.Subject != "z@hanzo.ai" || c.Owner != "hanzo" || c.RefreshToken != "r" {
|
||||
t.Fatalf("identity not extracted: %+v", c)
|
||||
}
|
||||
if c.Expiry == 2000000000 {
|
||||
t.Fatalf("expires_in should win over exp claim")
|
||||
}
|
||||
// No expires_in → falls back to exp claim.
|
||||
c2 := credsFromToken(&tokenResp{AccessToken: tok})
|
||||
if c2.Expiry != 2000000000 {
|
||||
t.Fatalf("exp claim fallback failed: %d", c2.Expiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordGrant(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/iam/oauth/access_token" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.Form.Get("grant_type") != "password" || r.Form.Get("client_id") != "hanzo-console" ||
|
||||
r.Form.Get("username") != "z@hanzo.ai" || r.Form.Get("password") != "pw" {
|
||||
t.Errorf("bad form: %v", r.Form)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": makeJWT(map[string]any{"email": "z@hanzo.ai"}),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "r",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newIAMClient(srv.URL, "hanzo-console")
|
||||
tr, err := c.passwordGrant(context.Background(), "z@hanzo.ai", "pw", "openid")
|
||||
if err != nil {
|
||||
t.Fatalf("passwordGrant: %v", err)
|
||||
}
|
||||
if tr.AccessToken == "" || tr.RefreshToken != "r" {
|
||||
t.Fatalf("token resp bad: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordGrantError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(400)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "invalid_grant", "error_description": "bad password"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := newIAMClient(srv.URL, "hanzo-console")
|
||||
_, err := c.passwordGrant(context.Background(), "u", "p", "openid")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid_grant") {
|
||||
t.Fatalf("expected invalid_grant error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshGrant(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.URL.Path != "/v1/iam/oauth/refresh_token" || r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "rt" {
|
||||
t.Errorf("bad refresh request: %s %v", r.URL.Path, r.Form)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": makeJWT(nil), "token_type": "Bearer"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := newIAMClient(srv.URL, "hanzo-console")
|
||||
if _, err := c.refreshGrant(context.Background(), "rt"); err != nil {
|
||||
t.Fatalf("refreshGrant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runRoot executes the cobra root with args, returning stdout and any error.
|
||||
// stderr is discarded; stdin is provided for password prompts.
|
||||
func runRoot(t *testing.T, stdin string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := newRootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(new(bytes.Buffer))
|
||||
root.SetIn(strings.NewReader(stdin))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestLoginCommandPasswordStdin(t *testing.T) {
|
||||
sandbox(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo"}),
|
||||
"token_type": "Bearer", "expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, err := runRoot(t, "pw\n", "login", "-u", "z@hanzo.ai", "--password-stdin", "--iam-issuer", srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "Logged in as z@hanzo.ai @ hanzo") {
|
||||
t.Fatalf("login output: %q", out)
|
||||
}
|
||||
creds, _ := LoadCredentials()
|
||||
if creds.AccessToken == "" || creds.Subject != "z@hanzo.ai" {
|
||||
t.Fatalf("credentials not persisted: %+v", creds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginTokenPasteAndPlatformToken(t *testing.T) {
|
||||
sandbox(t)
|
||||
tok := makeJWT(map[string]any{"email": "ops@hanzo.ai", "owner": "hanzo"})
|
||||
out, err := runRoot(t, "", "login", "--token", tok, "--platform-token", "svc-123")
|
||||
if err != nil {
|
||||
t.Fatalf("login --token: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "ops@hanzo.ai") {
|
||||
t.Fatalf("login output: %q", out)
|
||||
}
|
||||
creds, _ := LoadCredentials()
|
||||
if creds.AccessToken != tok || creds.PlatformToken != "svc-123" {
|
||||
t.Fatalf("creds not stored: %+v", creds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoamiCommand(t *testing.T) {
|
||||
sandbox(t)
|
||||
creds := credsFromToken(&tokenResp{AccessToken: makeJWT(map[string]any{
|
||||
"email": "z@hanzo.ai", "name": "z", "owner": "hanzo", "sub": "u-1", "iss": "https://hanzo.id",
|
||||
})})
|
||||
if err := creds.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := runRoot(t, "", "whoami")
|
||||
if err != nil {
|
||||
t.Fatalf("whoami: %v", err)
|
||||
}
|
||||
for _, want := range []string{"z@hanzo.ai", "hanzo", "u-1", "https://hanzo.id"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("whoami missing %q in %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoamiLoggedOut(t *testing.T) {
|
||||
sandbox(t)
|
||||
if _, err := runRoot(t, "", "whoami"); err == nil {
|
||||
t.Fatalf("expected error when logged out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutCommand(t *testing.T) {
|
||||
sandbox(t)
|
||||
(&Credentials{AccessToken: "x"}).Save()
|
||||
if _, err := runRoot(t, "", "logout"); err != nil {
|
||||
t.Fatalf("logout: %v", err)
|
||||
}
|
||||
if c, _ := LoadCredentials(); c.AccessToken != "" {
|
||||
t.Fatalf("logout did not clear credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthTokenCommand(t *testing.T) {
|
||||
sandbox(t)
|
||||
(&Credentials{AccessToken: "the-token"}).Save()
|
||||
out, err := runRoot(t, "", "auth", "token")
|
||||
if err != nil {
|
||||
t.Fatalf("auth token: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "the-token" {
|
||||
t.Fatalf("auth token output: %q", out)
|
||||
}
|
||||
}
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
// Package cli is the Hanzo cloud-control CLI — the gcloud/doctl-class client
|
||||
// half of the `hanzo` binary.
|
||||
//
|
||||
// `hanzo <subsystem>` SERVES a subsystem (server mode, cmd/hanzo dispatch);
|
||||
// `hanzo <verb>` CONTROLS the live estate (client mode, this package):
|
||||
//
|
||||
// hanzo login | auth identity against hanzo.id (IAM)
|
||||
// hanzo apps list|get the platform apps board (declared/running/drift)
|
||||
// hanzo deploy drive a platform redeploy (rolling, zero-downtime)
|
||||
// hanzo clusters … provision/list/select dedicated DOKS clusters
|
||||
// hanzo build enqueue a platform-native (arcd) build
|
||||
// hanzo k8s … current deploy target helpers
|
||||
// hanzo config … ~/.hanzo/config preferences
|
||||
//
|
||||
// It is a THIN client over surfaces that already exist — Hanzo IAM
|
||||
// (hanzo.id /v1/iam/oauth/*), the platform REST control plane
|
||||
// (platform.hanzo.ai /v1/*), and the cloud /v1 API. It invents no parallel
|
||||
// API and holds no business logic; every command is one HTTP call shaped by
|
||||
// resolved configuration. Secrets live only in ~/.hanzo (0600) or the
|
||||
// environment — never in source, never logged.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version is the binary version, set by cmd/hanzo from its -ldflags value so
|
||||
// the CLI and the server report one string. Used in the User-Agent.
|
||||
var Version = "dev"
|
||||
|
||||
// Default endpoints. Overridable per-field via config / env / flag.
|
||||
const (
|
||||
defaultIAMIssuer = "https://hanzo.id"
|
||||
defaultPlatformURL = "https://platform.hanzo.ai"
|
||||
defaultCloudURL = "https://api.hanzo.ai"
|
||||
// hanzo-console is the only live IAM client that accepts the password
|
||||
// grant today; a dedicated `hanzo-cli` client is a one-line IAM seed
|
||||
// follow-up. Override with `--client-id` / HANZO_CLIENT_ID / config.
|
||||
defaultClientID = "hanzo-console"
|
||||
)
|
||||
|
||||
// controlCommands maps every client-mode verb to its one-line help. cmd/hanzo
|
||||
// reads this both to ROUTE (a first token in here means client mode) and to
|
||||
// list the commands in `hanzo help`, so the verb set is defined exactly once.
|
||||
var controlCommands = map[string]string{
|
||||
"login": "authenticate against Hanzo IAM (hanzo.id) and store a token",
|
||||
"logout": "remove stored credentials",
|
||||
"whoami": "show the current identity from the stored token",
|
||||
"auth": "manage authentication (login, logout, whoami, token)",
|
||||
"apps": "list/get the platform apps board (declared/running/drift)",
|
||||
"deploy": "drive a platform redeploy (rolling restart, zero-downtime)",
|
||||
"clusters": "provision/list/select dedicated DOKS clusters",
|
||||
"build": "enqueue a platform-native (arcd) build",
|
||||
"k8s": "deploy-target helpers (current target)",
|
||||
"config": "view/edit ~/.hanzo/config preferences",
|
||||
}
|
||||
|
||||
// IsControlVerb reports whether sub is a client-mode command (and therefore
|
||||
// must be routed to this package, not the server dispatcher).
|
||||
func IsControlVerb(sub string) bool {
|
||||
_, ok := controlCommands[sub]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ControlCommands returns the verb→description map for `hanzo help`.
|
||||
func ControlCommands() map[string]string { return controlCommands }
|
||||
|
||||
// Execute runs the control CLI with args (already stripped of "hanzo"). It is
|
||||
// the single entrypoint cmd/hanzo calls for client-mode verbs.
|
||||
func Execute(args []string) error {
|
||||
root := newRootCmd()
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config — non-secret preferences, ~/.hanzo/config (JSON).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Config holds non-secret CLI preferences. Every field is optional; empty
|
||||
// fields fall back to the built-in defaults at resolution time.
|
||||
type Config struct {
|
||||
Org string `json:"org,omitempty"`
|
||||
Output string `json:"output,omitempty"` // "table" (default) | "json"
|
||||
IAMIssuer string `json:"iam_issuer,omitempty"`
|
||||
PlatformURL string `json:"platform_url,omitempty"`
|
||||
CloudURL string `json:"cloud_url,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
}
|
||||
|
||||
// Credentials holds secret material, ~/.hanzo/credentials.json, mode 0600.
|
||||
// AccessToken/RefreshToken are the IAM user identity (from `hanzo login`);
|
||||
// PlatformToken/BuildToken are the machine-to-machine tokens the platform
|
||||
// REST control plane requires (it cannot validate IAM user tokens).
|
||||
type Credentials struct {
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Expiry int64 `json:"expiry,omitempty"` // unix seconds
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // org slug from the token
|
||||
PlatformToken string `json:"platform_token,omitempty"`
|
||||
BuildToken string `json:"build_token,omitempty"`
|
||||
}
|
||||
|
||||
// hanzoDir is ~/.hanzo, created 0700 if missing. Overridable with HANZO_HOME
|
||||
// (used by tests to sandbox the credential store).
|
||||
func hanzoDir() (string, error) {
|
||||
if h := os.Getenv("HANZO_HOME"); h != "" {
|
||||
return h, os.MkdirAll(h, 0o700)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(home, ".hanzo")
|
||||
return dir, os.MkdirAll(dir, 0o700)
|
||||
}
|
||||
|
||||
func configPath() (string, error) {
|
||||
dir, err := hanzoDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p := os.Getenv("HANZO_CONFIG"); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
return filepath.Join(dir, "config"), nil
|
||||
}
|
||||
|
||||
func credentialsPath() (string, error) {
|
||||
dir, err := hanzoDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "credentials.json"), nil
|
||||
}
|
||||
|
||||
// loadJSON reads a JSON file into v; a missing file is not an error (v is left
|
||||
// at its zero value) so first-run with no config/credentials just works.
|
||||
func loadJSON(path string, v any) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// writeJSON writes v as indented JSON at path with the given mode, via a
|
||||
// temp-file rename so a crash mid-write never truncates the store.
|
||||
func writeJSON(path string, v any, mode os.FileMode) error {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(b, '\n'), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// LoadConfig reads ~/.hanzo/config (or HANZO_CONFIG).
|
||||
func LoadConfig() (*Config, error) {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Config{}
|
||||
return c, loadJSON(p, c)
|
||||
}
|
||||
|
||||
// Save persists the config (mode 0644 — non-secret).
|
||||
func (c *Config) Save() error {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(p, c, 0o644)
|
||||
}
|
||||
|
||||
// LoadCredentials reads ~/.hanzo/credentials.json.
|
||||
func LoadCredentials() (*Credentials, error) {
|
||||
p, err := credentialsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Credentials{}
|
||||
return c, loadJSON(p, c)
|
||||
}
|
||||
|
||||
// Save persists credentials with mode 0600 (owner read/write only).
|
||||
func (c *Credentials) Save() error {
|
||||
p, err := credentialsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(p, c, 0o600)
|
||||
}
|
||||
|
||||
// DeleteCredentials removes the credential store (used by logout).
|
||||
func DeleteCredentials() error {
|
||||
p, err := credentialsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env — the effective, resolved settings a command operates with.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Env is the fully-resolved runtime context for a command: config + creds
|
||||
// merged with environment and the global flags. Built once in the root's
|
||||
// PersistentPreRunE and read by every subcommand.
|
||||
type Env struct {
|
||||
cfg *Config
|
||||
creds *Credentials
|
||||
|
||||
Org string
|
||||
Output string
|
||||
IAMIssuer string
|
||||
PlatformURL string
|
||||
CloudURL string
|
||||
ClientID string
|
||||
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
// flag values bound by the persistent flags (empty == unset, fall through).
|
||||
type globalFlags struct {
|
||||
org, output, platformURL, iamIssuer, cloudURL, clientID, platformToken string
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty argument, or "".
|
||||
func firstNonEmpty(vs ...string) string {
|
||||
for _, v := range vs {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolve merges flags > env > config > built-in defaults into an Env. It is
|
||||
// pure given its inputs (config/creds are loaded by the caller) so it is
|
||||
// directly unit-testable.
|
||||
func resolve(cfg *Config, creds *Credentials, f globalFlags) *Env {
|
||||
e := &Env{cfg: cfg, creds: creds, out: os.Stdout}
|
||||
e.Output = firstNonEmpty(f.output, os.Getenv("HANZO_OUTPUT"), cfg.Output, "table")
|
||||
e.IAMIssuer = strings.TrimRight(firstNonEmpty(f.iamIssuer, os.Getenv("HANZO_IAM_ISSUER"), cfg.IAMIssuer, defaultIAMIssuer), "/")
|
||||
e.PlatformURL = strings.TrimRight(firstNonEmpty(f.platformURL, os.Getenv("HANZO_PLATFORM_URL"), cfg.PlatformURL, defaultPlatformURL), "/")
|
||||
e.CloudURL = strings.TrimRight(firstNonEmpty(f.cloudURL, os.Getenv("HANZO_CLOUD_URL"), cfg.CloudURL, defaultCloudURL), "/")
|
||||
e.ClientID = firstNonEmpty(f.clientID, os.Getenv("HANZO_CLIENT_ID"), cfg.ClientID, defaultClientID)
|
||||
// Org for platform calls is the platform organization id (a distinct
|
||||
// namespace from the IAM token's `owner` slug), so it comes only from
|
||||
// flag/env/config — never silently from the token.
|
||||
e.Org = firstNonEmpty(f.org, os.Getenv("HANZO_ORG"), cfg.Org)
|
||||
return e
|
||||
}
|
||||
|
||||
// accessToken is the IAM user token (identity / cloud calls).
|
||||
func (e *Env) accessToken() string {
|
||||
return firstNonEmpty(os.Getenv("HANZO_TOKEN"), e.creds.AccessToken)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (e *Env) platformToken(flagVal string) string {
|
||||
return firstNonEmpty(
|
||||
flagVal,
|
||||
os.Getenv("HANZO_PLATFORM_TOKEN"),
|
||||
os.Getenv("PLATFORM_SERVICE_TOKEN"),
|
||||
os.Getenv("PAAS_SERVICE_TOKEN"),
|
||||
e.creds.PlatformToken,
|
||||
)
|
||||
}
|
||||
|
||||
// buildToken resolves the platform build-enqueue token (a distinct credential
|
||||
// from the service token — see /v1/arcd/enqueue).
|
||||
func (e *Env) buildToken(flagVal string) string {
|
||||
return firstNonEmpty(
|
||||
flagVal,
|
||||
os.Getenv("HANZO_BUILD_TOKEN"),
|
||||
os.Getenv("PLATFORM_BUILD_CALLBACK_TOKEN"),
|
||||
e.creds.BuildToken,
|
||||
)
|
||||
}
|
||||
|
||||
// requireOrg returns the resolved org or a clear error telling the user how to
|
||||
// set it.
|
||||
func (e *Env) requireOrg() (string, error) {
|
||||
if e.Org == "" {
|
||||
return "", fmt.Errorf("no org set: pass --org, set HANZO_ORG, or run `hanzo config set org <org>`")
|
||||
}
|
||||
return e.Org, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output helpers — one place decides JSON vs human-readable tables.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// emit prints v as JSON when --output=json, otherwise calls table to render a
|
||||
// human view. This is the single output branch for every command.
|
||||
func (e *Env) emit(v any, table func(w io.Writer)) error {
|
||||
if e.Output == "json" {
|
||||
enc := json.NewEncoder(e.out)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
table(e.out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root command + global flags.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
var f globalFlags
|
||||
var env *Env
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "hanzo",
|
||||
Short: "Hanzo cloud control — manage the live Hanzo estate",
|
||||
Long: "hanzo — gcloud/doctl-class control for the Hanzo platform (IAM, apps, deploys, clusters, builds).",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: false,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
creds, err := LoadCredentials()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load credentials: %w", err)
|
||||
}
|
||||
env = resolve(cfg, creds, f)
|
||||
env.out = cmd.OutOrStdout()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
pf := root.PersistentFlags()
|
||||
pf.StringVar(&f.org, "org", "", "organization (overrides config / HANZO_ORG)")
|
||||
pf.StringVarP(&f.output, "output", "o", "", "output format: table|json")
|
||||
pf.StringVar(&f.platformURL, "platform-url", "", "platform base URL (default "+defaultPlatformURL+")")
|
||||
pf.StringVar(&f.iamIssuer, "iam-issuer", "", "IAM issuer (default "+defaultIAMIssuer+")")
|
||||
pf.StringVar(&f.cloudURL, "cloud-url", "", "cloud API base URL (default "+defaultCloudURL+")")
|
||||
pf.StringVar(&f.clientID, "client-id", "", "IAM OAuth client id (default "+defaultClientID+")")
|
||||
pf.StringVar(&f.platformToken, "platform-token", "", "platform control-plane service token (else env/credential store)")
|
||||
|
||||
// envOf returns the resolved Env for a command's RunE (always non-nil after
|
||||
// PersistentPreRunE).
|
||||
envOf := func() *Env { return env }
|
||||
|
||||
root.AddCommand(
|
||||
newVersionCmd(),
|
||||
newAuthCmd(envOf, &f),
|
||||
newLoginCmd(envOf, &f),
|
||||
newLogoutCmd(),
|
||||
newWhoamiCmd(envOf),
|
||||
newAppsCmd(envOf, &f),
|
||||
newDeployCmd(envOf, &f),
|
||||
newClustersCmd(envOf, &f),
|
||||
newBuildCmd(envOf, &f),
|
||||
newK8sCmd(envOf, &f),
|
||||
newConfigCmd(),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func newVersionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the hanzo version",
|
||||
Args: cobra.NoArgs,
|
||||
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "hanzo %s\n", Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// config command — view/edit the non-secret preference file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newConfigCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "View/edit ~/.hanzo/config preferences",
|
||||
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
|
||||
configKeys := []string{"org", "output", "iam_issuer", "platform_url", "cloud_url", "client_id"}
|
||||
|
||||
get := &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Short: "Print one config value",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := cfg.field(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), v)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
set := &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: "Set one config value (keys: " + strings.Join(configKeys, ", ") + ")",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.setField(args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "set %s = %s\n", args[0], args[1])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "Print the full config",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := json.NewEncoder(cmd.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(cfg)
|
||||
},
|
||||
}
|
||||
|
||||
path := &cobra.Command{
|
||||
Use: "path",
|
||||
Short: "Print the config file path",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), p)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(get, set, list, path)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// field returns the named config value as a string.
|
||||
func (c *Config) field(key string) (string, error) {
|
||||
switch key {
|
||||
case "org":
|
||||
return c.Org, nil
|
||||
case "output":
|
||||
return c.Output, nil
|
||||
case "iam_issuer":
|
||||
return c.IAMIssuer, nil
|
||||
case "platform_url":
|
||||
return c.PlatformURL, nil
|
||||
case "cloud_url":
|
||||
return c.CloudURL, nil
|
||||
case "client_id":
|
||||
return c.ClientID, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown config key %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// setField sets the named config value.
|
||||
func (c *Config) setField(key, val string) error {
|
||||
switch key {
|
||||
case "org":
|
||||
c.Org = val
|
||||
case "output":
|
||||
if val != "table" && val != "json" {
|
||||
return fmt.Errorf("output must be table|json")
|
||||
}
|
||||
c.Output = val
|
||||
case "iam_issuer":
|
||||
c.IAMIssuer = val
|
||||
case "platform_url":
|
||||
c.PlatformURL = val
|
||||
case "cloud_url":
|
||||
c.CloudURL = val
|
||||
case "client_id":
|
||||
c.ClientID = val
|
||||
default:
|
||||
return fmt.Errorf("unknown config key %q", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shortTime renders a unix timestamp for human tables; "" for zero.
|
||||
func shortTime(unix int64) string {
|
||||
if unix == 0 {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(unix, 0).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// sortedKeys returns the keys of m, sorted — for deterministic help output.
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain restores stdout (quiet.go redirected it to stderr at init) so go
|
||||
// test's own reporting stays on stdout.
|
||||
func TestMain(m *testing.M) {
|
||||
RestoreStdout()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// sandbox isolates the credential/config store in a temp dir and clears every
|
||||
// env var resolve() consults, so tests are deterministic and never touch the
|
||||
// developer's real ~/.hanzo.
|
||||
func sandbox(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HANZO_HOME", dir)
|
||||
for _, k := range []string{
|
||||
"HANZO_CONFIG", "HANZO_OUTPUT", "HANZO_IAM_ISSUER", "HANZO_PLATFORM_URL",
|
||||
"HANZO_CLOUD_URL", "HANZO_CLIENT_ID", "HANZO_ORG", "HANZO_TOKEN",
|
||||
"HANZO_PLATFORM_TOKEN", "PLATFORM_SERVICE_TOKEN", "PAAS_SERVICE_TOKEN",
|
||||
"HANZO_BUILD_TOKEN", "PLATFORM_BUILD_CALLBACK_TOKEN",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestConfigRoundTrip(t *testing.T) {
|
||||
sandbox(t)
|
||||
in := &Config{Org: "acme", Output: "json", PlatformURL: "https://p.example", ClientID: "hanzo-console"}
|
||||
if err := in.Save(); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
out, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if *out != *in {
|
||||
t.Fatalf("round-trip mismatch: %+v != %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsRoundTripAndPerms(t *testing.T) {
|
||||
dir := sandbox(t)
|
||||
in := &Credentials{AccessToken: "tok", RefreshToken: "ref", TokenType: "Bearer", Subject: "z@hanzo.ai", Owner: "hanzo", PlatformToken: "pt"}
|
||||
if err := in.Save(); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(dir, "credentials.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("credentials perm = %o, want 0600", perm)
|
||||
}
|
||||
out, err := LoadCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if *out != *in {
|
||||
t.Fatalf("round-trip mismatch: %+v != %+v", out, in)
|
||||
}
|
||||
if err := DeleteCredentials(); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if out, _ := LoadCredentials(); out.AccessToken != "" {
|
||||
t.Fatalf("credentials not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFilesIsZeroValue(t *testing.T) {
|
||||
sandbox(t)
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil || cfg.Org != "" {
|
||||
t.Fatalf("missing config should be zero value, got %+v err %v", cfg, err)
|
||||
}
|
||||
creds, err := LoadCredentials()
|
||||
if err != nil || creds.AccessToken != "" {
|
||||
t.Fatalf("missing credentials should be zero value, got %+v err %v", creds, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaults(t *testing.T) {
|
||||
sandbox(t)
|
||||
e := resolve(&Config{}, &Credentials{}, globalFlags{})
|
||||
if e.IAMIssuer != defaultIAMIssuer || e.PlatformURL != defaultPlatformURL ||
|
||||
e.CloudURL != defaultCloudURL || e.ClientID != defaultClientID || e.Output != "table" {
|
||||
t.Fatalf("defaults not applied: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePrecedenceFlagOverEnvOverConfig(t *testing.T) {
|
||||
sandbox(t)
|
||||
t.Setenv("HANZO_ORG", "env-org")
|
||||
cfg := &Config{Org: "cfg-org", Output: "json"}
|
||||
// Flag wins.
|
||||
if e := resolve(cfg, &Credentials{}, globalFlags{org: "flag-org"}); e.Org != "flag-org" {
|
||||
t.Fatalf("flag should win: %q", e.Org)
|
||||
}
|
||||
// Env beats config.
|
||||
if e := resolve(cfg, &Credentials{}, globalFlags{}); e.Org != "env-org" {
|
||||
t.Fatalf("env should beat config: %q", e.Org)
|
||||
}
|
||||
// Config used when no flag/env.
|
||||
t.Setenv("HANZO_ORG", "")
|
||||
if e := resolve(cfg, &Credentials{}, globalFlags{}); e.Org != "cfg-org" {
|
||||
t.Fatalf("config should be used: %q", e.Org)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformTokenPrecedence(t *testing.T) {
|
||||
sandbox(t)
|
||||
e := resolve(&Config{}, &Credentials{PlatformToken: "from-creds"}, globalFlags{})
|
||||
if got := e.platformToken(""); got != "from-creds" {
|
||||
t.Fatalf("creds token: %q", got)
|
||||
}
|
||||
t.Setenv("PAAS_SERVICE_TOKEN", "from-paas")
|
||||
if got := e.platformToken(""); got != "from-paas" {
|
||||
t.Fatalf("PAAS env should beat creds: %q", got)
|
||||
}
|
||||
t.Setenv("PLATFORM_SERVICE_TOKEN", "from-platform")
|
||||
if got := e.platformToken(""); got != "from-platform" {
|
||||
t.Fatalf("PLATFORM env should beat PAAS: %q", got)
|
||||
}
|
||||
t.Setenv("HANZO_PLATFORM_TOKEN", "from-hanzo")
|
||||
if got := e.platformToken(""); got != "from-hanzo" {
|
||||
t.Fatalf("HANZO_PLATFORM_TOKEN should beat all envs: %q", got)
|
||||
}
|
||||
if got := e.platformToken("from-flag"); got != "from-flag" {
|
||||
t.Fatalf("flag should beat everything: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTokenPrecedence(t *testing.T) {
|
||||
sandbox(t)
|
||||
e := resolve(&Config{}, &Credentials{BuildToken: "creds"}, globalFlags{})
|
||||
if got := e.buildToken(""); got != "creds" {
|
||||
t.Fatalf("creds build token: %q", got)
|
||||
}
|
||||
t.Setenv("PLATFORM_BUILD_CALLBACK_TOKEN", "cb")
|
||||
if got := e.buildToken(""); got != "cb" {
|
||||
t.Fatalf("callback env: %q", got)
|
||||
}
|
||||
if got := e.buildToken("flag"); got != "flag" {
|
||||
t.Fatalf("flag wins: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTokenFromEnvOverCreds(t *testing.T) {
|
||||
sandbox(t)
|
||||
e := resolve(&Config{}, &Credentials{AccessToken: "creds"}, globalFlags{})
|
||||
if got := e.accessToken(); got != "creds" {
|
||||
t.Fatalf("creds token: %q", got)
|
||||
}
|
||||
t.Setenv("HANZO_TOKEN", "env")
|
||||
if got := e.accessToken(); got != "env" {
|
||||
t.Fatalf("env token should win: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireOrg(t *testing.T) {
|
||||
sandbox(t)
|
||||
e := resolve(&Config{}, &Credentials{}, globalFlags{})
|
||||
if _, err := e.requireOrg(); err == nil {
|
||||
t.Fatalf("expected error when org unset")
|
||||
}
|
||||
e = resolve(&Config{Org: "acme"}, &Credentials{}, globalFlags{})
|
||||
if org, err := e.requireOrg(); err != nil || org != "acme" {
|
||||
t.Fatalf("org=%q err=%v", org, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFieldGetSet(t *testing.T) {
|
||||
c := &Config{}
|
||||
if err := c.setField("org", "acme"); err != nil || c.Org != "acme" {
|
||||
t.Fatalf("set org: %v", err)
|
||||
}
|
||||
if v, _ := c.field("org"); v != "acme" {
|
||||
t.Fatalf("get org: %q", v)
|
||||
}
|
||||
if err := c.setField("output", "xml"); err == nil {
|
||||
t.Fatalf("invalid output should error")
|
||||
}
|
||||
if err := c.setField("nope", "x"); err == nil {
|
||||
t.Fatalf("unknown key should error")
|
||||
}
|
||||
if _, err := c.field("nope"); err == nil {
|
||||
t.Fatalf("unknown key get should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsControlVerb(t *testing.T) {
|
||||
for _, v := range []string{"login", "apps", "deploy", "clusters", "build", "k8s", "config", "auth", "whoami", "logout"} {
|
||||
if !IsControlVerb(v) {
|
||||
t.Errorf("%q should be a control verb", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"iam", "kms", "cloud", "gateway", "datastore", "nope"} {
|
||||
if IsControlVerb(v) {
|
||||
t.Errorf("%q must NOT be a control verb (server mode)", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitJSONvsTable(t *testing.T) {
|
||||
// JSON branch: encodes the value, ignores the table func.
|
||||
var jbuf bytes.Buffer
|
||||
ej := &Env{Output: "json", out: &jbuf}
|
||||
called := false
|
||||
if err := ej.emit(map[string]string{"k": "v"}, func(_ io.Writer) { called = true }); err != nil {
|
||||
t.Fatalf("emit json: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("table func must not run in json mode")
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(jbuf.Bytes(), &got); err != nil || got["k"] != "v" {
|
||||
t.Fatalf("json output bad: %q (%v)", jbuf.String(), err)
|
||||
}
|
||||
|
||||
// Table branch: runs the table func, does not emit JSON.
|
||||
var tbuf bytes.Buffer
|
||||
et := &Env{Output: "table", out: &tbuf}
|
||||
if err := et.emit(map[string]string{"k": "v"}, func(w io.Writer) { _, _ = w.Write([]byte("ROW")) }); err != nil {
|
||||
t.Fatalf("emit table: %v", err)
|
||||
}
|
||||
if !strings.Contains(tbuf.String(), "ROW") {
|
||||
t.Fatalf("table output missing: %q", tbuf.String())
|
||||
}
|
||||
}
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// platform builds a platform REST client from the resolved env + the global
|
||||
// --platform-token flag. The token may be empty here; the client surfaces a
|
||||
// precise error on first use.
|
||||
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 == "" {
|
||||
return "-"
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// yesno renders a bool for a table cell.
|
||||
func yesno(b bool) string {
|
||||
if b {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// newTab returns a tabwriter writing to w with a 2-space gutter.
|
||||
func newTab(w io.Writer) *tabwriter.Writer {
|
||||
return tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apps — the observe surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newAppsCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "apps",
|
||||
Short: "List/get the platform apps board (declared/running/latest/drift)",
|
||||
}
|
||||
|
||||
var envFilter, healthFilter string
|
||||
var driftOnly bool
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List apps with declared/running tags, health and drift",
|
||||
Args: cobra.NoArgs,
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emit(res, func(w io.Writer) {
|
||||
tw := newTab(w)
|
||||
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))
|
||||
}
|
||||
tw.Flush()
|
||||
fmt.Fprintf(w, "\n%d apps (ok=%d yellow=%d red=%d)\n",
|
||||
res.Summary.Total, res.Summary.ByDrift["ok"],
|
||||
res.Summary.ByDrift["yellow"], res.Summary.ByDrift["red"])
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().StringVar(&envFilter, "env", "", "filter by env: dev|test|main")
|
||||
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",
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emit(a, func(w io.Writer) {
|
||||
tw := newTab(w)
|
||||
fmt.Fprintf(tw, "id:\t%s\n", a.ID)
|
||||
fmt.Fprintf(tw, "org:\t%s\n", a.Org)
|
||||
fmt.Fprintf(tw, "app:\t%s\n", a.App)
|
||||
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, "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)
|
||||
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)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deploy — the drive surface (rolling restart, zero-downtime).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newDeployCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
var project, 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.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
e := envOf()
|
||||
org, err := e.requireOrg()
|
||||
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
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "", "project id")
|
||||
cmd.Flags().StringVar(&environment, "env", "", "environment id")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clusters — dedicated DOKS cluster lifecycle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newClustersCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "clusters",
|
||||
Aliases: []string{"cluster"},
|
||||
Short: "Provision/list/select dedicated DOKS clusters",
|
||||
}
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the org's dedicated 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)
|
||||
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")
|
||||
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))
|
||||
}
|
||||
tw.Flush()
|
||||
if len(cs) == 0 {
|
||||
fmt.Fprintln(w, "(no dedicated clusters)")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
get := &cobra.Command{
|
||||
Use: "get <cluster-id>",
|
||||
Short: "Show one cluster",
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cs {
|
||||
if c.DoksClusterID == 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)
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printCluster(w io.Writer, c Cluster) {
|
||||
tw := newTab(w)
|
||||
fmt.Fprintf(tw, "id:\t%s\n", c.DoksClusterID)
|
||||
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)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build — platform-native (arcd) build enqueue.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newBuildCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
|
||||
var br BuildReq
|
||||
var buildToken string
|
||||
cmd := &cobra.Command{
|
||||
Use: "build <repo>",
|
||||
Short: "Enqueue a platform-native (arcd) build (no GitHub builders)",
|
||||
Long: "Enqueue a build on the platform's native CI fabric (arcd). Builds and pushes\n" +
|
||||
"the named image at a SHA; on completion the platform patches the operator\n" +
|
||||
"Service CR (build-job → deploy). Requires a live registered runner for the\n" +
|
||||
"target pool (409 otherwise).",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
e := envOf()
|
||||
if len(args) == 1 {
|
||||
br.Repo = args[0]
|
||||
}
|
||||
if br.Repo == "" || br.SHA == "" || br.Image == "" {
|
||||
return fmt.Errorf("--repo (or positional), --sha and --image are required")
|
||||
}
|
||||
if br.OrganizationID == "" {
|
||||
br.OrganizationID = e.Org // optional; server defaults to DEFAULT_BUILD_ORG_ID
|
||||
}
|
||||
job, err := e.platform(gf).EnqueueBuild(cmd.Context(), br, e.buildToken(buildToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emit(job, func(w io.Writer) {
|
||||
tw := newTab(w)
|
||||
fmt.Fprintf(tw, "buildJobId:\t%s\n", job.BuildJobID)
|
||||
fmt.Fprintf(tw, "status:\t%s\n", job.Status)
|
||||
fmt.Fprintf(tw, "runnerPool:\t%s\n", job.RunnerPool)
|
||||
fmt.Fprintf(tw, "image:\t%s\n", job.Image)
|
||||
fmt.Fprintf(tw, "target:\t%s\n", job.Target)
|
||||
tw.Flush()
|
||||
})
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&br.Repo, "repo", "", "owner/name (e.g. hanzoai/pricing)")
|
||||
f.StringVar(&br.SHA, "sha", "", "commit SHA to build")
|
||||
f.StringVar(&br.Image, "image", "", "image to build+push (e.g. ghcr.io/hanzoai/pricing:<tag>)")
|
||||
f.StringVar(&br.Branch, "branch", "", "branch (default main)")
|
||||
f.StringVar(&br.Dockerfile, "dockerfile", "", "Dockerfile path")
|
||||
f.StringVar(&br.Context, "context", "", "build context")
|
||||
f.StringVar(&br.DockerTarget, "target", "", "Docker build stage (--target)")
|
||||
f.StringVar(&br.OS, "os", "", "linux|darwin|windows (default linux)")
|
||||
f.StringVar(&br.Arch, "arch", "", "amd64|arm64 (default amd64)")
|
||||
f.StringVar(&buildToken, "build-token", "", "platform build-enqueue token (else env/credential store)")
|
||||
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",
|
||||
}
|
||||
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) })
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(target)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
t.Helper()
|
||||
sandbox(t)
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("HANZO_PLATFORM_URL", srv.URL)
|
||||
t.Setenv("HANZO_PLATFORM_TOKEN", "svc-tok")
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func TestAppsListCommandTable(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = 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"}`)},
|
||||
},
|
||||
Summary: struct {
|
||||
Total int `json:"total"`
|
||||
ByDrift map[string]int `json:"byDrift"`
|
||||
}{Total: 1, ByDrift: map[string]int{"ok": 1}},
|
||||
})
|
||||
})
|
||||
out, err := runRoot(t, "", "apps", "list")
|
||||
if err != nil {
|
||||
t.Fatalf("apps list: %v", err)
|
||||
}
|
||||
for _, want := range []string{"APP", "iam", "v1.2.3", "green", "ok", "1 apps"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("apps list table missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"}}})
|
||||
})
|
||||
out, err := runRoot(t, "", "apps", "list", "-o", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("apps list json: %v", err)
|
||||
}
|
||||
var res AppsList
|
||||
if err := json.Unmarshal([]byte(out), &res); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
if len(res.Apps) != 1 || res.Apps[0].App != "iam" {
|
||||
t.Fatalf("json decode wrong: %+v", res.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
})
|
||||
out, err := runRoot(t, "", "deploy", "app-x", "--org", "acme", "--project", "p1", "--env", "e1")
|
||||
if err != nil {
|
||||
t.Fatalf("deploy: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "redeployed app-x") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
_ = 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},
|
||||
}})
|
||||
})
|
||||
out, err := runRoot(t, "", "clusters", "list", "--org", "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("clusters list: %v", err)
|
||||
}
|
||||
for _, want := range []string{"NAME", "hanzo-acme", "c1", "ready", "yes"} {
|
||||
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"}}})
|
||||
})
|
||||
out, err := runRoot(t, "", "k8s", "target", "--org", "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("k8s target: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "hanzo-k8s") || !strings.Contains(out, "shared") {
|
||||
t.Fatalf("k8s target output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandValidation(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(202) })
|
||||
// Missing --sha/--image → validation error before any HTTP call.
|
||||
if _, err := runRoot(t, "", "build", "hanzoai/pricing"); err == nil {
|
||||
t.Fatalf("build must require --sha and --image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommand(t *testing.T) {
|
||||
withPlatform(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/arcd/enqueue" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer bt" {
|
||||
t.Errorf("build auth = %q", got)
|
||||
}
|
||||
w.WriteHeader(202)
|
||||
_ = json.NewEncoder(w).Encode(BuildJob{BuildJobID: "bj-9", Status: "queued", Image: "ghcr.io/hanzoai/pricing:t"})
|
||||
})
|
||||
out, err := runRoot(t, "", "build", "hanzoai/pricing", "--sha", "abc", "--image", "ghcr.io/hanzoai/pricing:t", "--build-token", "bt")
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "bj-9") {
|
||||
t.Fatalf("build output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetGetCommand(t *testing.T) {
|
||||
sandbox(t)
|
||||
if _, err := runRoot(t, "", "config", "set", "org", "acme"); err != nil {
|
||||
t.Fatalf("config set: %v", err)
|
||||
}
|
||||
out, err := runRoot(t, "", "config", "get", "org")
|
||||
if err != nil {
|
||||
t.Fatalf("config get: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "acme" {
|
||||
t.Fatalf("config get = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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.
|
||||
type Platform struct {
|
||||
baseURL string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newPlatform(baseURL, token string) *Platform {
|
||||
return &Platform{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// apiError carries the HTTP status + server message for a failed call so
|
||||
// commands can give precise diagnostics (e.g. 401 → token problem).
|
||||
type apiError struct {
|
||||
status int
|
||||
message string
|
||||
path string
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string {
|
||||
msg := e.message
|
||||
if msg == "" {
|
||||
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`)"
|
||||
}
|
||||
return fmt.Sprintf("platform %s: HTTP %d: %s%s", e.path, e.status, msg, hint)
|
||||
}
|
||||
|
||||
// do performs one JSON request with the given bearer token, decoding a 2xx body
|
||||
// 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>`")
|
||||
}
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return &apiError{status: resp.StatusCode, message: serverMessage(raw), path: path}
|
||||
}
|
||||
if out != nil && len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("platform %s: decode response: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverMessage pulls the `{ "message": … }` field platform errors use, falling
|
||||
// back to the raw (truncated) body.
|
||||
func serverMessage(raw []byte) string {
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(raw, &e) == nil {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
if e.Error != "" {
|
||||
return e.Error
|
||||
}
|
||||
}
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if len(s) > 240 {
|
||||
s = s[:240] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apps board — GET /v1/apps, GET /v1/apps/{id}, POST /v1/apps/sync.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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.
|
||||
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"`
|
||||
}
|
||||
|
||||
// AppsList is the /v1/apps envelope: ordered rows + a drift summary.
|
||||
type AppsList struct {
|
||||
Apps []AppView `json:"apps"`
|
||||
Summary struct {
|
||||
Total int `json:"total"`
|
||||
ByDrift map[string]int `json:"byDrift"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
// AppsQuery are the optional /v1/apps filters.
|
||||
type AppsQuery struct {
|
||||
Org string
|
||||
Env string
|
||||
Health string
|
||||
Drift bool
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if q.Health != "" {
|
||||
v.Set("health", q.Health)
|
||||
}
|
||||
if q.Drift {
|
||||
v.Set("drift", "1")
|
||||
}
|
||||
path := "/v1/apps"
|
||||
if len(v) > 0 {
|
||||
path += "?" + v.Encode()
|
||||
}
|
||||
out := &AppsList{}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// driftSeverity extracts the severity string from the raw drift object.
|
||||
func driftSeverity(raw json.RawMessage) string {
|
||||
var d struct {
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
if json.Unmarshal(raw, &d) == nil && d.Severity != "" {
|
||||
return d.Severity
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedicated clusters — /v1/org/{org}/cluster[ /select | /{id}/install-baseline ].
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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).
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var out struct {
|
||||
Clusters []Cluster `json:"clusters"`
|
||||
}
|
||||
err := p.do(ctx, http.MethodGet, "/v1/org/"+url.PathEscape(org)+"/cluster", 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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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"`
|
||||
}
|
||||
if err := p.do(ctx, http.MethodPost, path, p.token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !out.OK {
|
||||
return fmt.Errorf("redeploy did not report ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build — POST /v1/arcd/enqueue (platform-native CI, no GitHub builders).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BuildReq is the direct-enqueue body. Repo/SHA/Image are required.
|
||||
type BuildReq struct {
|
||||
Repo string `json:"repo"`
|
||||
SHA string `json:"sha"`
|
||||
Image string `json:"image"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Dockerfile string `json:"dockerfile,omitempty"`
|
||||
Context string `json:"context,omitempty"`
|
||||
DockerTarget string `json:"dockerTarget,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
OrganizationID string `json:"organizationId,omitempty"`
|
||||
}
|
||||
|
||||
// BuildJob is the enqueue acceptance (HTTP 202).
|
||||
type BuildJob struct {
|
||||
BuildJobID string `json:"buildJobId"`
|
||||
Status string `json:"status"`
|
||||
RunnerPool string `json:"runnerPool"`
|
||||
Image string `json:"image"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
// EnqueueBuild enqueues a native build. It authenticates with the dedicated
|
||||
// build-callback token, not the service token.
|
||||
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>`")
|
||||
}
|
||||
out := &BuildJob{}
|
||||
return out, p.do(ctx, http.MethodPost, "/v1/arcd/enqueue", buildToken, req, out)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// platformStub spins an httptest server whose handler is provided by the test,
|
||||
// plus a client pointed at it with the given token.
|
||||
func platformStub(t *testing.T, token string, h http.HandlerFunc) (*Platform, func()) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
return newPlatform(srv.URL, token), srv.Close
|
||||
}
|
||||
|
||||
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.Query().Get("env") != "main" || r.URL.Query().Get("drift") != "1" {
|
||||
t.Errorf("query = %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"}`)}},
|
||||
})
|
||||
})
|
||||
defer done()
|
||||
|
||||
res, err := p.Apps(context.Background(), AppsQuery{Env: "main", Drift: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Apps: %v", err)
|
||||
}
|
||||
if len(res.Apps) != 1 || res.Apps[0].App != "iam" {
|
||||
t.Fatalf("apps wrong: %+v", res.Apps)
|
||||
}
|
||||
if driftSeverity(res.Apps[0].Drift) != "red" {
|
||||
t.Fatalf("drift severity = %q", driftSeverity(res.Apps[0].Drift))
|
||||
}
|
||||
}
|
||||
|
||||
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.Query().Get("org") != "hanzoai" {
|
||||
t.Errorf("org query = %s", r.URL.RawQuery)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(AppView{ID: "hanzoai/iam/main", App: "iam"})
|
||||
})
|
||||
defer done()
|
||||
a, err := p.App(context.Background(), "hanzoai/iam/main", "hanzoai")
|
||||
if err != nil || a.App != "iam" {
|
||||
t.Fatalf("App: %v %+v", err, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformSyncApps(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)
|
||||
}
|
||||
})
|
||||
defer done()
|
||||
|
||||
cs, err := p.Clusters(context.Background(), "acme")
|
||||
if err != nil || len(cs) != 1 || cs[0].DoksClusterID != "c1" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
})
|
||||
defer done()
|
||||
if err := p.Redeploy(context.Background(), "acme", "p1", "e1", "app-x"); err != nil {
|
||||
t.Fatalf("Redeploy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
})
|
||||
defer done()
|
||||
if err := p.Redeploy(context.Background(), "o", "p", "e", "c"); err == nil {
|
||||
t.Fatalf("expected error when ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformEnqueueBuild(t *testing.T) {
|
||||
p, done := platformStub(t, "svc-tok", func(w http.ResponseWriter, r *http.Request) {
|
||||
// The build endpoint must use the BUILD token, not the service token.
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer build-tok" {
|
||||
t.Errorf("build auth header = %q (must use build token)", got)
|
||||
}
|
||||
if r.URL.Path != "/v1/arcd/enqueue" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req BuildReq
|
||||
_ = json.Unmarshal(body, &req)
|
||||
if req.Repo != "hanzoai/pricing" || req.SHA != "abc123" || req.Image == "" {
|
||||
t.Errorf("build body = %+v", req)
|
||||
}
|
||||
w.WriteHeader(202)
|
||||
_ = json.NewEncoder(w).Encode(BuildJob{BuildJobID: "bj-1", Status: "queued", RunnerPool: "runner-pool-32g", Image: req.Image})
|
||||
})
|
||||
defer done()
|
||||
|
||||
job, err := p.EnqueueBuild(context.Background(), BuildReq{Repo: "hanzoai/pricing", SHA: "abc123", Image: "ghcr.io/hanzoai/pricing:t"}, "build-tok")
|
||||
if err != nil || job.BuildJobID != "bj-1" {
|
||||
t.Fatalf("EnqueueBuild: %v %+v", err, job)
|
||||
}
|
||||
if _, err := p.EnqueueBuild(context.Background(), BuildReq{Repo: "r", SHA: "s", Image: "i"}, ""); err == nil {
|
||||
t.Fatalf("expected error with empty build token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformError401Hint(t *testing.T) {
|
||||
p, done := platformStub(t, "bad", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(401)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "Unauthorized"})
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cli
|
||||
|
||||
import "os"
|
||||
|
||||
// realStdout is the process's true stdout, captured at this package's init
|
||||
// before the server-graph dependencies (iam/beego, kms) run their own init()
|
||||
// functions — several of which emit warnings to stdout (e.g. the IAM registry
|
||||
// signing-key loader). To keep the CLI's stdout machine-readable (so
|
||||
// `hanzo apps list -o json | jq` is never corrupted by a dependency's startup
|
||||
// chatter), this init redirects stdout to stderr for the duration of process
|
||||
// initialization; RestoreStdout puts the real stdout back before any command
|
||||
// writes a byte.
|
||||
//
|
||||
// This is best-effort: it only helps when this package initializes before the
|
||||
// noisy dependency (cmd/hanzo imports cli first) AND that dependency reads the
|
||||
// os.Stdout variable at log time rather than capturing it earlier. main always
|
||||
// calls RestoreStdout, so correctness never depends on the redirect taking.
|
||||
var realStdout = os.Stdout
|
||||
|
||||
func init() { os.Stdout = os.Stderr }
|
||||
|
||||
// RestoreStdout restores the real process stdout. cmd/hanzo calls this as its
|
||||
// first statement so every command writes to the genuine stdout.
|
||||
func RestoreStdout() { os.Stdout = realStdout }
|
||||
@@ -0,0 +1,532 @@
|
||||
// Package admin mounts the god-mode admin surface (/v1/admin/*) the Hanzo
|
||||
// Admin Console (admin.hanzo.ai, apps/operator) calls, per the api.ts contract.
|
||||
//
|
||||
// It is an AGGREGATOR, not a new store: identity (orgs/users/roles/applications/
|
||||
// audit/me) is read from IAM, the money panels (spend/tokens/credits) from
|
||||
// commerce, and System Health from o11y — every one a real upstream, none fused
|
||||
// into this binary (see subsystems.go). The facade fans out over HTTP exactly
|
||||
// like o11ysvc / productsvc: it holds no business logic, it shapes the reads into
|
||||
// the casibase envelope { status, msg, data, data2 } the operator's transport
|
||||
// decodes (get<T> reads data; getList<T> reads data + data2 total).
|
||||
//
|
||||
// SECURITY — every route is GLOBAL-ADMIN ONLY, fail-closed. The gate is the
|
||||
// SAME predicate the rest of cloud uses: c.IsAdmin(), which after SanitizeIdentity
|
||||
// (serve.go) is true ONLY for a JWT-validated principal whose org is the admin org
|
||||
// (owner == AdminOrg — IAM's IsGlobalAdmin), matching the gateway's admin-guard.
|
||||
// No principal → 403; a tenant-admin (owner != AdminOrg) → 403; a forged
|
||||
// X-User-IsAdmin never survives ingress. admin adds no service credential to
|
||||
// the IAM fan-out — it replays the caller's own cookie/bearer, so it can never
|
||||
// read more than the caller already could, and IAM re-checks IsGlobalAdmin too.
|
||||
//
|
||||
// Panels with no in-binary feed yet (the Usage & Costs timeseries + per-product
|
||||
// breakdown live in insights/datastore; the product/workload registry + infra
|
||||
// tiles live in platform.hanzo.ai / the operator inventory) return the real,
|
||||
// honest empty state — never a fabricated number. The operator UI renders those
|
||||
// as an em-dash / empty table by design.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// svc holds the resolved upstream clients + the admin org for this deployment.
|
||||
type svc struct {
|
||||
iam *iamClient
|
||||
commerce *commerceClient
|
||||
health *healthClient
|
||||
adminOrg string
|
||||
// auditStore is cloud's OWN tamper-evident audit store (nil when unconfigured,
|
||||
// in which case /v1/admin/audit falls back to the IAM get-records proxy). Serve
|
||||
// builds it and hands it over via deps.Audit. See audit.go.
|
||||
auditStore *audit.Recorder
|
||||
}
|
||||
|
||||
// Mount registers the /v1/admin/* surface on app. Every handler gates on
|
||||
// c.IsAdmin() first (global-admin only), then aggregates real upstream data.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("admin.Mount: nil zip.App")
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
return fmt.Errorf("admin.Mount: nil deps.Logger")
|
||||
}
|
||||
logger = logger.New("subsystem", "admin")
|
||||
|
||||
s := &svc{
|
||||
iam: newIAMClient(iamBase(deps)),
|
||||
commerce: newCommerceClient(os.Getenv("CLOUD_COMMERCE_HTTP_URL"), os.Getenv("COMMERCE_SERVICE_TOKEN")),
|
||||
health: newHealthClient(o11yHealthURL()),
|
||||
adminOrg: adminOrgOf(deps),
|
||||
auditStore: deps.Audit,
|
||||
}
|
||||
|
||||
app.Get("/v1/admin/me", s.guard(s.me))
|
||||
app.Get("/v1/admin/overview", s.guard(s.overview))
|
||||
app.Get("/v1/admin/orgs", s.guard(s.orgs))
|
||||
app.Get("/v1/admin/users", s.guard(s.users))
|
||||
app.Get("/v1/admin/roles", s.guard(s.roles))
|
||||
app.Get("/v1/admin/applications", s.guard(s.applications))
|
||||
app.Get("/v1/admin/audit", s.guard(s.audit))
|
||||
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
|
||||
app.Get("/v1/admin/usage", s.guard(s.usage))
|
||||
app.Get("/v1/admin/products", s.guard(s.products))
|
||||
app.Post("/v1/admin/sync", s.guard(s.sync))
|
||||
|
||||
logger.Info("admin surface mounted",
|
||||
"prefix", "/v1/admin",
|
||||
"iam", s.iam.configured(),
|
||||
"commerce", s.commerce.configured(),
|
||||
"adminOrg", s.adminOrg,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// guard wraps a handler with the global-admin gate. Fail-closed: any request
|
||||
// whose validated identity is not a global admin (X-User-IsAdmin != "true",
|
||||
// which SanitizeIdentity sets only for owner == AdminOrg) is refused 403 before
|
||||
// the handler — no upstream is touched, no data leaks.
|
||||
func (s *svc) guard(h func(*zip.Ctx) error) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
}
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
// callerCreds captures the caller's replayed authorization context for the IAM
|
||||
// fan-out: the raw Cookie header (session model) and the Authorization bearer.
|
||||
func callerCreds(c *zip.Ctx) creds {
|
||||
return creds{
|
||||
cookie: string(c.Fiber().Request().Header.Peek("Cookie")),
|
||||
auth: c.Header("Authorization"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── casibase envelope writers ────────────────────────────────────────────────
|
||||
|
||||
// ok writes a { status:"ok", data } envelope (the get<T> shape).
|
||||
func ok(c *zip.Ctx, data any) error {
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": data})
|
||||
}
|
||||
|
||||
// okList writes a { status:"ok", data:[...], data2:total } envelope (getList<T>).
|
||||
func okList(c *zip.Ctx, rows any, total int) error {
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": rows, "data2": total})
|
||||
}
|
||||
|
||||
// okRaw writes a { status:"ok", data:<raw>, data2:total } envelope, forwarding an
|
||||
// IAM payload verbatim so its exact wire shape (Role, Application, Record, User)
|
||||
// reaches the operator field-for-field.
|
||||
func okRaw(c *zip.Ctx, rows json.RawMessage, total int) error {
|
||||
if len(rows) == 0 {
|
||||
rows = json.RawMessage("[]")
|
||||
}
|
||||
return c.JSON(200, map[string]any{"status": "ok", "msg": "", "data": rows, "data2": total})
|
||||
}
|
||||
|
||||
// fail writes a { status:"error", msg } envelope. The operator's transport maps
|
||||
// a non-ok envelope to a surfaced error (never a fabricated value).
|
||||
func fail(c *zip.Ctx, msg string) error {
|
||||
return c.JSON(200, map[string]any{"status": "error", "msg": msg, "data": nil})
|
||||
}
|
||||
|
||||
// ── /v1/admin/me — operator identity (AdminMe) ───────────────────────────────
|
||||
|
||||
// me answers with the validated operator identity. The gate already proved this
|
||||
// is a global admin, so the fields come from the sanitized identity headers —
|
||||
// authoritative and never client-forgeable.
|
||||
func (s *svc) me(c *zip.Ctx) error {
|
||||
owner := s.adminOrg
|
||||
if o := strings.TrimSpace(c.Org()); o != "" {
|
||||
owner = o
|
||||
}
|
||||
name := strings.TrimSpace(c.User())
|
||||
return ok(c, adminMe{
|
||||
Owner: owner,
|
||||
Name: name,
|
||||
Email: strings.TrimSpace(c.UserEmail()),
|
||||
DisplayName: name,
|
||||
IsGlobalAdmin: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ── /v1/admin/orgs — tenant directory (OrgRow[]) ─────────────────────────────
|
||||
|
||||
func (s *svc) orgs(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
orgs, err := s.listOrgs(ctx, cr)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
rows := make([]orgRow, 0, len(orgs))
|
||||
for _, o := range orgs {
|
||||
users := s.orgUserCount(ctx, cr, o.Name)
|
||||
spend, credits := s.orgMoney(ctx, o.Name)
|
||||
rows = append(rows, orgRow{
|
||||
Org: o.Name,
|
||||
Display: display(o.DisplayName, o.Name),
|
||||
Users: users,
|
||||
Products: 0, // workload registry feed pending (platform apps table)
|
||||
SpendCents: spend,
|
||||
CreditsCents: credits,
|
||||
Tokens: 0, // fleet token counters pending (insights/datastore)
|
||||
Created: o.CreatedTime,
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Org < rows[j].Org })
|
||||
return okList(c, rows, len(rows))
|
||||
}
|
||||
|
||||
// ── /v1/admin/users — cross-org directory (OperatorUser[]) ───────────────────
|
||||
|
||||
func (s *svc) users(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
q := url.Values{}
|
||||
if owner := strings.TrimSpace(c.Query("org")); owner != "" {
|
||||
q.Set("owner", owner)
|
||||
}
|
||||
if p := strings.TrimSpace(c.Query("p")); p != "" {
|
||||
q.Set("p", p)
|
||||
}
|
||||
if ps := strings.TrimSpace(c.Query("pageSize")); ps != "" {
|
||||
q.Set("pageSize", ps)
|
||||
}
|
||||
if term := strings.TrimSpace(c.Query("q")); term != "" {
|
||||
// IAM's list uses field/value contains-matching for the free-text filter.
|
||||
q.Set("field", "name")
|
||||
q.Set("value", term)
|
||||
}
|
||||
res, err := s.iam.getList(ctx, cr, "/v1/iam/get-users", q)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
var raw []iamUser
|
||||
if len(res.rows) > 0 {
|
||||
if err := json.Unmarshal(res.rows, &raw); err != nil {
|
||||
return fail(c, "users decode: "+err.Error())
|
||||
}
|
||||
}
|
||||
rows := make([]operatorUser, 0, len(raw))
|
||||
for _, u := range raw {
|
||||
rows = append(rows, operatorUser{
|
||||
Owner: u.Owner,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
DisplayName: u.DisplayName,
|
||||
IsAdmin: u.IsAdmin,
|
||||
IsGlobalAdmin: u.Owner == s.adminOrg,
|
||||
Tag: u.Tag,
|
||||
Created: u.CreatedTime,
|
||||
LastSignin: u.LastSigninTime,
|
||||
Forbidden: u.IsForbidden,
|
||||
})
|
||||
}
|
||||
total := res.total
|
||||
if total < len(rows) {
|
||||
total = len(rows)
|
||||
}
|
||||
return okList(c, rows, total)
|
||||
}
|
||||
|
||||
// ── /v1/admin/roles and /applications — verbatim IAM passthrough ─────────────
|
||||
|
||||
func (s *svc) roles(c *zip.Ctx) error {
|
||||
return s.iamPassthrough(c, "/v1/iam/get-roles")
|
||||
}
|
||||
|
||||
func (s *svc) applications(c *zip.Ctx) error {
|
||||
return s.iamPassthrough(c, "/v1/iam/get-applications")
|
||||
}
|
||||
|
||||
// iamPassthrough forwards a paginated IAM read verbatim (the operator decodes
|
||||
// Role / Application as the raw IAM wire shape). `owner` defaults to the admin
|
||||
// org, which owns the platform applications.
|
||||
func (s *svc) iamPassthrough(c *zip.Ctx, path string) error {
|
||||
q := url.Values{}
|
||||
owner := strings.TrimSpace(c.Query("owner"))
|
||||
if owner == "" {
|
||||
owner = s.adminOrg
|
||||
}
|
||||
q.Set("owner", owner)
|
||||
if p := strings.TrimSpace(c.Query("p")); p != "" {
|
||||
q.Set("p", p)
|
||||
}
|
||||
if ps := strings.TrimSpace(c.Query("pageSize")); ps != "" {
|
||||
q.Set("pageSize", ps)
|
||||
}
|
||||
res, err := s.iam.getList(c.Context(), callerCreds(c), path, q)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
return okRaw(c, res.rows, res.total)
|
||||
}
|
||||
|
||||
// ── /v1/admin/audit — records directory (AuditRow[]) ─────────────────────────
|
||||
//
|
||||
// The handler lives in audit.go (it reads cloud's OWN tamper-evident store).
|
||||
// iamAuditQuery builds the IAM get-records query for the federated fallback
|
||||
// auditFromIAM uses when no local store is configured.
|
||||
|
||||
func iamAuditQuery(c *zip.Ctx) url.Values {
|
||||
q := url.Values{}
|
||||
if org := strings.TrimSpace(c.Query("org")); org != "" {
|
||||
q.Set("organizationName", org)
|
||||
}
|
||||
q.Set("p", "1")
|
||||
ps := strings.TrimSpace(c.Query("pageSize"))
|
||||
if ps == "" {
|
||||
ps = "100"
|
||||
}
|
||||
q.Set("pageSize", ps)
|
||||
q.Set("sortField", "createdTime")
|
||||
q.Set("sortOrder", "descend")
|
||||
return q
|
||||
}
|
||||
|
||||
// ── /v1/admin/usage — fleet usage roll-up (UsageData) ────────────────────────
|
||||
|
||||
// usage returns the real fleet money totals from commerce. The daily series and
|
||||
// the per-product breakdown are NOT derivable from the commerce billing API
|
||||
// (they live in insights/datastore, owned separately); admin returns the
|
||||
// honest empty series/byProduct rather than fabricating a trend — the operator
|
||||
// renders that as an empty chart, never a fake line.
|
||||
func (s *svc) usage(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
org := strings.TrimSpace(c.Query("org"))
|
||||
|
||||
var spend int64
|
||||
if org != "" {
|
||||
r, err := s.commerce.usageRollup(ctx, org, orgSubject(org))
|
||||
if err == nil {
|
||||
spend = r.ConsumedCents
|
||||
}
|
||||
} else {
|
||||
// Fleet: sum month-to-date consumption across every org.
|
||||
orgs, err := s.listOrgs(ctx, cr)
|
||||
if err == nil {
|
||||
for _, o := range orgs {
|
||||
if r, e := s.commerce.usageRollup(ctx, o.Name, orgSubject(o.Name)); e == nil {
|
||||
spend += r.ConsumedCents
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ok(c, usageData{
|
||||
Totals: usageTotals{SpendCents: spend, Tokens: 0, Requests: 0},
|
||||
Series: []usagePoint{},
|
||||
ByProduct: []usageByProduct{},
|
||||
})
|
||||
}
|
||||
|
||||
// ── /v1/admin/products — workload registry (ProductRow[]) ────────────────────
|
||||
|
||||
// products is the workload/drift registry (declared vs running tag, health).
|
||||
// That inventory is the platform.hanzo.ai apps table / operator reconcile state,
|
||||
// NOT an in-binary source. admin exposes the gated endpoint and returns the
|
||||
// real empty registry until that feed is wired — it never fabricates workload
|
||||
// rows. The operator renders an empty table, not fake products.
|
||||
func (s *svc) products(c *zip.Ctx) error {
|
||||
return okList(c, []productRow{}, 0)
|
||||
}
|
||||
|
||||
// ── /v1/admin/overview — Platform Overview tiles (OverviewData) ───────────────
|
||||
|
||||
func (s *svc) overview(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
cr := callerCreds(c)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
var sources []sourceStatus
|
||||
orgCount, userCount, spend, credits := 0, 0, int64(0), int64(0)
|
||||
|
||||
orgs, orgErr := s.listOrgs(ctx, cr)
|
||||
sources = append(sources, srcOf("iam", orgErr, len(orgs), now))
|
||||
if orgErr == nil {
|
||||
orgCount = len(orgs)
|
||||
for _, o := range orgs {
|
||||
userCount += s.orgUserCount(ctx, cr, o.Name)
|
||||
sp, cr2 := s.orgMoney(ctx, o.Name)
|
||||
spend += sp
|
||||
credits += cr2
|
||||
}
|
||||
}
|
||||
|
||||
// Commerce freshness: probe one org's rollup so the tile reflects a real read.
|
||||
commerceRows := 0
|
||||
var commerceErr error
|
||||
if s.commerce.configured() {
|
||||
probe := s.adminOrg
|
||||
if len(orgs) > 0 {
|
||||
probe = orgs[0].Name
|
||||
}
|
||||
if _, err := s.commerce.usageRollup(ctx, probe, orgSubject(probe)); err != nil {
|
||||
commerceErr = err
|
||||
} else {
|
||||
commerceRows = 1
|
||||
}
|
||||
} else {
|
||||
commerceErr = fmt.Errorf("commerce endpoint not configured")
|
||||
}
|
||||
sources = append(sources, srcOf("commerce", commerceErr, commerceRows, now))
|
||||
|
||||
// o11y System Health.
|
||||
o11yRows := 0
|
||||
oOK, oErr := s.health.ok(ctx)
|
||||
if oOK {
|
||||
o11yRows = 1
|
||||
}
|
||||
sources = append(sources, srcOf("o11y", oErr, o11yRows, now))
|
||||
|
||||
return ok(c, overviewData{
|
||||
Orgs: orgCount,
|
||||
Users: userCount,
|
||||
Products: 0, // workload registry feed pending (platform apps table)
|
||||
ActiveProducts: 0,
|
||||
Drift: 0,
|
||||
SpendCents30d: spend,
|
||||
Tokens30d: 0, // fleet token counters pending (insights/datastore)
|
||||
CreditsCents: credits,
|
||||
LastSync: now,
|
||||
Sources: sources,
|
||||
})
|
||||
}
|
||||
|
||||
// ── /v1/admin/sync — refresh trigger ─────────────────────────────────────────
|
||||
|
||||
// sync answers the operator's "Sync now" button. admin aggregates LIVE on
|
||||
// every read (there is no cached fleet snapshot in-binary), so there is no batch
|
||||
// job to kick — the button simply re-reads. We acknowledge honestly with
|
||||
// { started: true } so the UI re-fetches the (freshly-computed) overview.
|
||||
func (s *svc) sync(c *zip.Ctx) error {
|
||||
return ok(c, map[string]bool{"started": true})
|
||||
}
|
||||
|
||||
// ── aggregation helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// listOrgs reads the org directory (owner = admin org) as the typed shape the
|
||||
// overview/orgs/usage aggregators fold over.
|
||||
func (s *svc) listOrgs(ctx context.Context, cr creds) ([]iamOrg, error) {
|
||||
q := url.Values{}
|
||||
q.Set("owner", s.adminOrg)
|
||||
res, err := s.iam.getList(ctx, cr, "/v1/iam/get-organizations", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var orgs []iamOrg
|
||||
if len(res.rows) > 0 {
|
||||
if err := json.Unmarshal(res.rows, &orgs); err != nil {
|
||||
return nil, fmt.Errorf("orgs decode: %w", err)
|
||||
}
|
||||
}
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
// orgUserCount returns the member count for one org from the IAM list total
|
||||
// (data2). Best-effort: an error yields 0 rather than failing the whole row.
|
||||
func (s *svc) orgUserCount(ctx context.Context, cr creds, org string) int {
|
||||
q := url.Values{}
|
||||
q.Set("owner", org)
|
||||
q.Set("p", "1")
|
||||
q.Set("pageSize", "1")
|
||||
res, err := s.iam.getList(ctx, cr, "/v1/iam/get-users", q)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return res.total
|
||||
}
|
||||
|
||||
// orgMoney returns (spendCents, creditsCents) for one org from commerce.
|
||||
// Best-effort: unreachable/unconfigured commerce yields zeros.
|
||||
func (s *svc) orgMoney(ctx context.Context, org string) (int64, int64) {
|
||||
subj := orgSubject(org)
|
||||
var spend, credits int64
|
||||
if r, err := s.commerce.usageRollup(ctx, org, subj); err == nil {
|
||||
spend = r.ConsumedCents
|
||||
}
|
||||
if c, err := s.commerce.creditsCents(ctx, org, subj); err == nil {
|
||||
credits = c
|
||||
}
|
||||
return spend, credits
|
||||
}
|
||||
|
||||
// orgSubject is the billing subject commerce keys an org's aggregate on. Commerce
|
||||
// meters per "org/user"; the org-level roll-up uses the org's own slug as the
|
||||
// subject namespace (X-IAM-Org-Id) with the org name as the user key.
|
||||
func orgSubject(org string) string { return org + "/" + org }
|
||||
|
||||
// srcOf builds a SourceStatus freshness row for the overview.
|
||||
func srcOf(name string, err error, rows int, at string) sourceStatus {
|
||||
s := sourceStatus{Name: name, OK: err == nil, Rows: rows, At: at}
|
||||
if err != nil {
|
||||
s.Error = err.Error()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func display(displayName, fallback string) string {
|
||||
if strings.TrimSpace(displayName) != "" {
|
||||
return displayName
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ── config resolution ────────────────────────────────────────────────────────
|
||||
|
||||
// iamBase resolves the IAM management HTTP base. CLOUD_IAM_HTTP_URL wins (the
|
||||
// in-cluster Service, e.g. http://iam.hanzo.svc.cluster.local:8000); otherwise
|
||||
// the public issuer (deps.IAMIssuer, e.g. https://hanzo.id) which also serves
|
||||
// /v1/iam/*. Empty only when neither is set (endpoint reports not-configured).
|
||||
func iamBase(deps cloud.Deps) string {
|
||||
if v := strings.TrimSpace(os.Getenv("CLOUD_IAM_HTTP_URL")); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(deps.IAMIssuer)
|
||||
}
|
||||
|
||||
// o11yHealthURL resolves the o11y health probe URL for the System Health source.
|
||||
// CLOUD_O11Y_HEALTH_URL wins; else the in-cluster o11y Service default.
|
||||
func o11yHealthURL() string {
|
||||
if v := strings.TrimSpace(os.Getenv("CLOUD_O11Y_HEALTH_URL")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "http://o11y.hanzo.svc.cluster.local:80/v1/o11y/health"
|
||||
}
|
||||
|
||||
// adminOrgOf resolves the admin org slug (IAM's IsGlobalAdmin owner). IAM_ADMIN_ORG
|
||||
// mirrors config.go's default; "admin" is the fleet-wide default.
|
||||
func adminOrgOf(_ cloud.Deps) string {
|
||||
if v := strings.TrimSpace(os.Getenv("IAM_ADMIN_ORG")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Order 146: after productsvc (145); the admin surface has no ordering
|
||||
// dependency (it fans out over HTTP), placed adjacent to the other console
|
||||
// read facades.
|
||||
cloud.Register("admin", 146, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("admin.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v3"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// mount builds a zip app with admin mounted against the given upstream bases,
|
||||
// and returns a `do` helper that issues test requests through the whole app.
|
||||
func mount(t *testing.T, iamURL, commerceURL, healthURL string) func(method, path string, hdr map[string]string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &svc{
|
||||
iam: newIAMClient(iamURL),
|
||||
commerce: newCommerceClient(commerceURL, "test-token"),
|
||||
health: newHealthClient(healthURL),
|
||||
adminOrg: "admin",
|
||||
}
|
||||
app.Get("/v1/admin/me", s.guard(s.me))
|
||||
app.Get("/v1/admin/overview", s.guard(s.overview))
|
||||
app.Get("/v1/admin/orgs", s.guard(s.orgs))
|
||||
app.Get("/v1/admin/users", s.guard(s.users))
|
||||
app.Get("/v1/admin/roles", s.guard(s.roles))
|
||||
app.Get("/v1/admin/applications", s.guard(s.applications))
|
||||
app.Get("/v1/admin/audit", s.guard(s.audit))
|
||||
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
|
||||
app.Get("/v1/admin/usage", s.guard(s.usage))
|
||||
app.Get("/v1/admin/products", s.guard(s.products))
|
||||
app.Post("/v1/admin/sync", s.guard(s.sync))
|
||||
fa := app.Fiber()
|
||||
|
||||
return func(method, path string, hdr map[string]string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
}
|
||||
|
||||
// adminRoutes is every mounted /v1/admin route + its method — the full god-mode
|
||||
// surface the gate must fail-close on for a non-global-admin.
|
||||
var adminRoutes = []struct{ method, path string }{
|
||||
{"GET", "/v1/admin/me"},
|
||||
{"GET", "/v1/admin/overview"},
|
||||
{"GET", "/v1/admin/orgs"},
|
||||
{"GET", "/v1/admin/users"},
|
||||
{"GET", "/v1/admin/roles"},
|
||||
{"GET", "/v1/admin/applications"},
|
||||
{"GET", "/v1/admin/audit"},
|
||||
{"GET", "/v1/admin/audit/verify"},
|
||||
{"GET", "/v1/admin/usage"},
|
||||
{"GET", "/v1/admin/products"},
|
||||
{"POST", "/v1/admin/sync"},
|
||||
}
|
||||
|
||||
// TestGate_DeniesEveryRoute proves the non-negotiable: EVERY /v1/admin/* route is
|
||||
// global-admin only, fail-closed. An anonymous caller and a tenant-admin (whose
|
||||
// identity carries an org but NOT the sanitizer-minted X-User-IsAdmin) are BOTH
|
||||
// denied 403 on every route — no upstream is even reached. admin mirrors the
|
||||
// gateway's admin-guard: SanitizeIdentity sets X-User-IsAdmin only for a
|
||||
// validated principal whose owner == AdminOrg, so a forged header never survives
|
||||
// ingress and the c.IsAdmin() read here is authoritative.
|
||||
func TestGate_DeniesEveryRoute(t *testing.T) {
|
||||
// Upstreams point nowhere reachable; the gate must reject BEFORE any call.
|
||||
do := mount(t, "http://127.0.0.1:0", "http://127.0.0.1:0", "http://127.0.0.1:0")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
hdr map[string]string
|
||||
}{
|
||||
{"anonymous", nil},
|
||||
{"tenant-admin (owner set, not global-admin)", map[string]string{"X-Org-Id": "acme"}},
|
||||
{"tenant-user with email but no admin", map[string]string{"X-Org-Id": "acme", "X-User-Id": "acme/bob", "X-User-Email": "bob@acme.test"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
for _, r := range adminRoutes {
|
||||
resp, body := do(r.method, r.path, tc.hdr)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s [%s]: got %d, want 403 (body=%s)", r.method, r.path, tc.name, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_AllowsGlobalAdmin proves the flip side: a validated global admin
|
||||
// (X-User-IsAdmin=true, minted only for owner==AdminOrg) is admitted — the gate
|
||||
// is not vacuously closed. Reaches /v1/admin/me, which needs no upstream.
|
||||
func TestGate_AllowsGlobalAdmin(t *testing.T) {
|
||||
do := mount(t, "http://127.0.0.1:0", "http://127.0.0.1:0", "http://127.0.0.1:0")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "admin/z", "X-User-Email": "z@hanzo.ai"}
|
||||
resp, body := do("GET", "/v1/admin/me", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("global-admin GET /v1/admin/me: got %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Status string `json:"status"`
|
||||
Data adminMe `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode me envelope: %v", err)
|
||||
}
|
||||
if env.Status != "ok" {
|
||||
t.Fatalf("me status = %q, want ok", env.Status)
|
||||
}
|
||||
if env.Data.Owner != "admin" || env.Data.Email != "z@hanzo.ai" || !env.Data.IsGlobalAdmin {
|
||||
t.Errorf("me identity wrong: %+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeIAM stands in for the IAM management surface. It records whether the
|
||||
// caller's credential was replayed and returns casibase envelopes.
|
||||
type fakeIAM struct {
|
||||
server *httptest.Server
|
||||
gotAuth string
|
||||
gotCook string
|
||||
}
|
||||
|
||||
func newFakeIAM() *fakeIAM {
|
||||
f := &fakeIAM{}
|
||||
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.gotAuth = r.Header.Get("Authorization")
|
||||
f.gotCook = r.Header.Get("Cookie")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/get-organizations"):
|
||||
io.WriteString(w, `{"status":"ok","msg":"","data":[
|
||||
{"owner":"admin","name":"hanzo","displayName":"Hanzo","createdTime":"2020-01-01T00:00:00Z"},
|
||||
{"owner":"admin","name":"acme","displayName":"Acme Inc","createdTime":"2021-02-02T00:00:00Z"}
|
||||
],"data2":2}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/get-users"):
|
||||
// A single-page count probe (pageSize=1) still reports data2 total.
|
||||
io.WriteString(w, `{"status":"ok","msg":"","data":[
|
||||
{"owner":"hanzo","name":"alice","email":"alice@hanzo.ai","displayName":"Alice","tag":"staff","createdTime":"2020-03-01T00:00:00Z","lastSigninTime":"2026-06-01T00:00:00Z","isAdmin":true,"isForbidden":false}
|
||||
],"data2":7}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/get-roles"):
|
||||
io.WriteString(w, `{"status":"ok","msg":"","data":[{"owner":"admin","name":"ops","displayName":"Ops"}],"data2":1}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/get-applications"):
|
||||
io.WriteString(w, `{"status":"ok","msg":"","data":[{"owner":"admin","name":"hanzo-cloud","clientId":"cid"}],"data2":1}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/get-records"):
|
||||
io.WriteString(w, `{"status":"ok","msg":"","data":[{"createdTime":"2026-06-29T00:00:00Z","organization":"hanzo","user":"alice","clientIp":"1.2.3.4","method":"POST","action":"login","requestUri":"/v1/iam/login"}],"data2":1}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
io.WriteString(w, `{"status":"error","msg":"not found"}`)
|
||||
}
|
||||
}))
|
||||
return f
|
||||
}
|
||||
|
||||
// newFakeCommerce serves the billing reads with fixed cents so the money
|
||||
// aggregation is deterministic.
|
||||
func newFakeCommerce() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/usage-rollup"):
|
||||
io.WriteString(w, `{"consumedCents":1500,"overageCents":0,"balance":{"balanceCents":5000,"availableCents":5000}}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/balance"):
|
||||
io.WriteString(w, `{"user":"x","currency":"usd","balance":5000,"holds":0,"available":5000}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// TestOrgs_RealAggregation drives /v1/admin/orgs against fake IAM + commerce and
|
||||
// verifies the envelope, the field mapping, the per-org user count (from IAM
|
||||
// data2), the money (from commerce), and that the caller's credential is
|
||||
// replayed to IAM (admin never forges a service credential for the fan-out).
|
||||
func TestOrgs_RealAggregation(t *testing.T) {
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
commerce := newFakeCommerce()
|
||||
defer commerce.Close()
|
||||
|
||||
do := mount(t, iam.server.URL, commerce.URL, "")
|
||||
admin := map[string]string{
|
||||
"X-User-IsAdmin": "true", "X-Org-Id": "admin",
|
||||
"Authorization": "Bearer operator-jwt", "Cookie": "iam_access_token=operator-jwt",
|
||||
}
|
||||
resp, body := do("GET", "/v1/admin/orgs", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("orgs: got %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Status string `json:"status"`
|
||||
Data []orgRow `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if env.Status != "ok" || env.Data2 != 2 || len(env.Data) != 2 {
|
||||
t.Fatalf("orgs envelope wrong: status=%q data2=%d rows=%d", env.Status, env.Data2, len(env.Data))
|
||||
}
|
||||
// Rows are sorted by org name: acme, hanzo.
|
||||
acme := env.Data[0]
|
||||
if acme.Org != "acme" || acme.Display != "Acme Inc" {
|
||||
t.Errorf("org row[0] = %+v, want acme/Acme Inc", acme)
|
||||
}
|
||||
if acme.Users != 7 {
|
||||
t.Errorf("org acme users = %d, want 7 (IAM data2)", acme.Users)
|
||||
}
|
||||
if acme.SpendCents != 1500 || acme.CreditsCents != 5000 {
|
||||
t.Errorf("org acme money = spend %d credits %d, want 1500/5000", acme.SpendCents, acme.CreditsCents)
|
||||
}
|
||||
// The operator's own credential MUST have been replayed to IAM.
|
||||
if iam.gotAuth != "Bearer operator-jwt" {
|
||||
t.Errorf("IAM did not receive the caller's Authorization: got %q", iam.gotAuth)
|
||||
}
|
||||
if !strings.Contains(iam.gotCook, "operator-jwt") {
|
||||
t.Errorf("IAM did not receive the caller's Cookie: got %q", iam.gotCook)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsers_MapsIAMToOperatorUser verifies the cross-org directory mapping,
|
||||
// including the derived isGlobalAdmin (owner == adminOrg) and the data2 total.
|
||||
func TestUsers_MapsIAMToOperatorUser(t *testing.T) {
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
do := mount(t, iam.server.URL, "", "")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
resp, body := do("GET", "/v1/admin/users?org=hanzo", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("users: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data []operatorUser `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if env.Data2 != 7 || len(env.Data) != 1 {
|
||||
t.Fatalf("users total=%d rows=%d, want 7/1", env.Data2, len(env.Data))
|
||||
}
|
||||
u := env.Data[0]
|
||||
if u.Name != "alice" || u.Email != "alice@hanzo.ai" || !u.IsAdmin || u.LastSignin == "" {
|
||||
t.Errorf("user mapping wrong: %+v", u)
|
||||
}
|
||||
// owner "hanzo" != adminOrg "admin" → not a global admin.
|
||||
if u.IsGlobalAdmin {
|
||||
t.Errorf("user owner=hanzo must not be flagged global admin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRolesAndApplications_PassthroughShape verifies the verbatim IAM passthrough
|
||||
// keeps the exact wire fields (clientId on Application, etc.) the operator decodes.
|
||||
func TestRolesAndApplications_PassthroughShape(t *testing.T) {
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
do := mount(t, iam.server.URL, "", "")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
_, appsBody := do("GET", "/v1/admin/applications", admin)
|
||||
var appsEnv struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
ClientId string `json:"clientId"`
|
||||
} `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
}
|
||||
if err := json.Unmarshal(appsBody, &appsEnv); err != nil {
|
||||
t.Fatalf("apps decode: %v", err)
|
||||
}
|
||||
if len(appsEnv.Data) != 1 || appsEnv.Data[0].ClientId != "cid" {
|
||||
t.Errorf("applications passthrough lost clientId: %+v", appsEnv.Data)
|
||||
}
|
||||
|
||||
_, rolesBody := do("GET", "/v1/admin/roles", admin)
|
||||
if !strings.Contains(string(rolesBody), `"ops"`) {
|
||||
t.Errorf("roles passthrough missing role name: %s", rolesBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudit_MapsRecords verifies the audit directory returns the IAM Record wire
|
||||
// shape the operator's AuditRow decodes.
|
||||
func TestAudit_MapsRecords(t *testing.T) {
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
do := mount(t, iam.server.URL, "", "")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("audit: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data []struct {
|
||||
CreatedTime string `json:"createdTime"`
|
||||
Organization string `json:"organization"`
|
||||
RequestUri string `json:"requestUri"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(env.Data) != 1 || env.Data[0].Organization != "hanzo" || env.Data[0].RequestUri != "/v1/iam/login" {
|
||||
t.Errorf("audit record shape wrong: %+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverview_RealTilesAndSources verifies the Platform Overview: real org/user
|
||||
// counts + money from the upstreams, and a per-source freshness row that reports
|
||||
// the honest state of each feed (iam ok, commerce ok, o11y not-configured here).
|
||||
func TestOverview_RealTilesAndSources(t *testing.T) {
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
commerce := newFakeCommerce()
|
||||
defer commerce.Close()
|
||||
|
||||
do := mount(t, iam.server.URL, commerce.URL, "") // no o11y health → source not-ok
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
resp, body := do("GET", "/v1/admin/overview", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("overview: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data overviewData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
d := env.Data
|
||||
if d.Orgs != 2 {
|
||||
t.Errorf("overview orgs = %d, want 2", d.Orgs)
|
||||
}
|
||||
// 2 orgs × 7 users each (both count probes return data2=7).
|
||||
if d.Users != 14 {
|
||||
t.Errorf("overview users = %d, want 14", d.Users)
|
||||
}
|
||||
// 2 orgs × 1500 consumed cents.
|
||||
if d.SpendCents30d != 3000 {
|
||||
t.Errorf("overview spend = %d, want 3000", d.SpendCents30d)
|
||||
}
|
||||
if d.CreditsCents != 10000 {
|
||||
t.Errorf("overview credits = %d, want 10000", d.CreditsCents)
|
||||
}
|
||||
if d.LastSync == "" {
|
||||
t.Error("overview lastSync must be set")
|
||||
}
|
||||
// Source freshness: iam ok, commerce ok, o11y not-ok (unconfigured).
|
||||
src := map[string]sourceStatus{}
|
||||
for _, s := range d.Sources {
|
||||
src[s.Name] = s
|
||||
}
|
||||
if !src["iam"].OK || src["iam"].Rows != 2 {
|
||||
t.Errorf("iam source = %+v, want ok/2 rows", src["iam"])
|
||||
}
|
||||
if !src["commerce"].OK {
|
||||
t.Errorf("commerce source = %+v, want ok", src["commerce"])
|
||||
}
|
||||
if src["o11y"].OK || src["o11y"].Error == "" {
|
||||
t.Errorf("o11y source must be not-ok with an error when unconfigured: %+v", src["o11y"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsage_RealTotalsHonestEmptySeries proves the usage roll-up returns the REAL
|
||||
// fleet spend from commerce but an HONEST empty series/byProduct — the timeseries
|
||||
// feed lives in insights/datastore, and admin must never fabricate a trend.
|
||||
func TestUsage_RealTotalsHonestEmptySeries(t *testing.T) {
|
||||
iam := newFakeIAM()
|
||||
defer iam.server.Close()
|
||||
commerce := newFakeCommerce()
|
||||
defer commerce.Close()
|
||||
|
||||
do := mount(t, iam.server.URL, commerce.URL, "")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
resp, body := do("GET", "/v1/admin/usage", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("usage: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data usageData `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if env.Data.Totals.SpendCents != 3000 { // 2 orgs × 1500
|
||||
t.Errorf("usage total spend = %d, want 3000", env.Data.Totals.SpendCents)
|
||||
}
|
||||
// Honest empty — NOT nil (the JSON must be [], which the operator renders as
|
||||
// an empty chart), and NEVER a fabricated point.
|
||||
if env.Data.Series == nil || len(env.Data.Series) != 0 {
|
||||
t.Errorf("usage series must be an empty array (no fabricated trend), got %v", env.Data.Series)
|
||||
}
|
||||
if env.Data.ByProduct == nil || len(env.Data.ByProduct) != 0 {
|
||||
t.Errorf("usage byProduct must be an empty array, got %v", env.Data.ByProduct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProductsAndSync_HonestShapes verifies products returns the real empty
|
||||
// registry (no fabricated workloads) and sync acknowledges with {started:true}.
|
||||
func TestProductsAndSync_HonestShapes(t *testing.T) {
|
||||
do := mount(t, "", "", "")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
_, pBody := do("GET", "/v1/admin/products", admin)
|
||||
var pEnv struct {
|
||||
Data []productRow `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
}
|
||||
if err := json.Unmarshal(pBody, &pEnv); err != nil {
|
||||
t.Fatalf("products decode: %v", err)
|
||||
}
|
||||
if pEnv.Data == nil || len(pEnv.Data) != 0 || pEnv.Data2 != 0 {
|
||||
t.Errorf("products must be an empty registry (no fabricated rows): %+v", pEnv)
|
||||
}
|
||||
|
||||
_, sBody := do("POST", "/v1/admin/sync", admin)
|
||||
var sEnv struct {
|
||||
Status string `json:"status"`
|
||||
Data map[string]bool `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(sBody, &sEnv); err != nil {
|
||||
t.Fatalf("sync decode: %v", err)
|
||||
}
|
||||
if sEnv.Status != "ok" || !sEnv.Data["started"] {
|
||||
t.Errorf("sync must ack {started:true}: %+v", sEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIAMError_SurfacedNotFabricated proves a failing upstream yields a real
|
||||
// error envelope (status:error), NOT a stubbed/zero success — the operator shows
|
||||
// the error state, honoring the api.ts "nothing here fabricates data" contract.
|
||||
func TestIAMError_SurfacedNotFabricated(t *testing.T) {
|
||||
// IAM that always 500s.
|
||||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
io.WriteString(w, `{"status":"error","msg":"iam boom"}`)
|
||||
}))
|
||||
defer bad.Close()
|
||||
|
||||
do := mount(t, bad.URL, "", "")
|
||||
admin := map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin"}
|
||||
|
||||
_, body := do("GET", "/v1/admin/orgs", admin)
|
||||
var env struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if env.Status != "error" || env.Msg == "" {
|
||||
t.Errorf("failing IAM must surface an error envelope, got %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMount_NilGuards keeps the Mount contract honest (nil app / nil logger).
|
||||
func TestMount_NilGuards(t *testing.T) {
|
||||
if err := Mount(nil, cloud.Deps{Logger: luxlog.New("test")}); err == nil {
|
||||
t.Error("Mount(nil app) must error")
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{}); err == nil {
|
||||
t.Error("Mount(nil logger) must error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package admin
|
||||
|
||||
// The /v1/admin/audit query surface, wired to cloud's REAL tamper-evident audit
|
||||
// store (the audit.Recorder Serve builds and hands over via deps.Audit).
|
||||
//
|
||||
// This REPLACES the previous behavior — proxying IAM get-records — as the primary
|
||||
// source: cloud now keeps its OWN append-only, hash-chained trail of every
|
||||
// security-relevant request against this binary, and that is what a compliance
|
||||
// auditor queries here. IAM's own login/session records remain available in IAM;
|
||||
// they are a DIFFERENT trail (IAM's request surface), and admin still federates
|
||||
// them as a fallback when cloud's local store is not configured, so no capability
|
||||
// is lost.
|
||||
//
|
||||
// SECURITY. Both handlers are registered behind the SAME s.guard as every other
|
||||
// /v1/admin/* route (global-admin only, fail-closed). They are READ-ONLY (Query
|
||||
// and Verify issue SELECT only), so exposing them cannot weaken the append-only
|
||||
// property. The verify endpoint returns integrity STATUS, never a way to mutate.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// auditRow is one record in the operator's audit table (AuditRow). The JSON tags
|
||||
// are the operator contract. It is cloud's OWN record shape — richer than the IAM
|
||||
// Record it supersedes: it carries the outcome, the validated auth context, and
|
||||
// the hash-chain linkage so the console can show integrity per row.
|
||||
type auditRow struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Time string `json:"time"`
|
||||
Org string `json:"org"`
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID string `json:"resourceId,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Result string `json:"result"`
|
||||
Status int `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
SourceIP string `json:"sourceIp,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Auth string `json:"authMethod,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
PrevHash string `json:"prevHash"`
|
||||
}
|
||||
|
||||
// audit answers GET /v1/admin/audit from cloud's local tamper-evident store when
|
||||
// configured, else falls back to the IAM get-records proxy (federated view).
|
||||
// Filters: org, sub, action, resource, result, since, until, pageSize, p (page).
|
||||
// The response is the casibase list envelope { data:[rows], data2:total } the
|
||||
// operator decodes, with the current chain integrity summary attached.
|
||||
func (s *svc) audit(c *zip.Ctx) error {
|
||||
// No local store configured → preserve the legacy federated IAM view so the
|
||||
// endpoint never regresses to empty.
|
||||
if s.auditStore == nil {
|
||||
return s.auditFromIAM(c)
|
||||
}
|
||||
|
||||
f := auditFilterFromQuery(c)
|
||||
rows, total, err := s.auditStore.Query(c.Context(), f)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
|
||||
out := make([]auditRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toAuditRow(r))
|
||||
}
|
||||
|
||||
// Attach the live integrity summary so the console can badge the trail as
|
||||
// verified. Best-effort: a verify error must not fail the listing.
|
||||
integrity, ivErr := s.auditStore.Verify(c.Context())
|
||||
var integrityPayload any
|
||||
if ivErr == nil {
|
||||
integrityPayload = integrity
|
||||
}
|
||||
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok",
|
||||
"msg": "",
|
||||
"data": out,
|
||||
"data2": total,
|
||||
"integrity": integrityPayload,
|
||||
})
|
||||
}
|
||||
|
||||
// auditVerify answers GET /v1/admin/audit/verify — the tamper-evidence check. It
|
||||
// walks the whole hash chain and returns the integrity result (ok, count, head,
|
||||
// and the seq where the chain first breaks if tampered). Global-admin gated like
|
||||
// every admin route.
|
||||
func (s *svc) auditVerify(c *zip.Ctx) error {
|
||||
if s.auditStore == nil {
|
||||
return fail(c, "audit store not configured")
|
||||
}
|
||||
integrity, err := s.auditStore.Verify(c.Context())
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
return ok(c, integrity)
|
||||
}
|
||||
|
||||
// auditFilterFromQuery builds an audit.Filter from the request query params. Time
|
||||
// bounds accept RFC3339. pageSize (default 100, cap 1000) and p (1-based page)
|
||||
// drive Limit/Offset. Unknown/blank params are simply not applied.
|
||||
func auditFilterFromQuery(c *zip.Ctx) audit.Filter {
|
||||
f := audit.Filter{
|
||||
Org: strings.TrimSpace(c.Query("org")),
|
||||
Sub: strings.TrimSpace(c.Query("sub")),
|
||||
Action: strings.TrimSpace(c.Query("action")),
|
||||
Resource: strings.TrimSpace(c.Query("resource")),
|
||||
Result: strings.TrimSpace(c.Query("result")),
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("since")); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Since = t
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("until")); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
f.Until = t
|
||||
}
|
||||
}
|
||||
pageSize := 100
|
||||
if v := strings.TrimSpace(c.Query("pageSize")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
pageSize = n
|
||||
}
|
||||
}
|
||||
f.Limit = pageSize
|
||||
if v := strings.TrimSpace(c.Query("p")); v != "" {
|
||||
if page, err := strconv.Atoi(v); err == nil && page > 1 {
|
||||
f.Offset = (page - 1) * pageSize
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// toAuditRow maps a stored audit.Record to the operator wire row.
|
||||
func toAuditRow(r audit.Record) auditRow {
|
||||
return auditRow{
|
||||
Seq: r.Seq,
|
||||
Time: r.Time.UTC().Format(time.RFC3339Nano),
|
||||
Org: r.Actor.Org,
|
||||
Sub: r.Actor.Sub,
|
||||
Email: r.Actor.Email,
|
||||
Action: r.Action,
|
||||
Resource: r.Resource.Type,
|
||||
ResourceID: r.Resource.ID,
|
||||
Method: r.Method,
|
||||
Path: r.Path,
|
||||
Result: r.Outcome.Result,
|
||||
Status: r.Outcome.Status,
|
||||
Reason: r.Outcome.Reason,
|
||||
SourceIP: r.SourceIP,
|
||||
UserAgent: r.UserAgent,
|
||||
RequestID: r.RequestID,
|
||||
IsAdmin: r.Auth.IsAdmin,
|
||||
Auth: r.Auth.Method,
|
||||
Hash: r.Hash,
|
||||
PrevHash: r.PrevHash,
|
||||
}
|
||||
}
|
||||
|
||||
// auditFromIAM is the legacy federated view: when cloud has no local audit store,
|
||||
// forward the IAM get-records read verbatim (the prior behavior), so the endpoint
|
||||
// still surfaces IAM's own audit trail rather than an empty list.
|
||||
func (s *svc) auditFromIAM(c *zip.Ctx) error {
|
||||
q := iamAuditQuery(c)
|
||||
res, err := s.iam.getList(c.Context(), callerCreds(c), "/v1/iam/get-records", q)
|
||||
if err != nil {
|
||||
return fail(c, err.Error())
|
||||
}
|
||||
return okRaw(c, res.rows, res.total)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package admin
|
||||
|
||||
// Tests for the store-backed /v1/admin/audit + /v1/admin/audit/verify surface.
|
||||
// They wire admin against a REAL audit.Recorder (on-disk SQLite) seeded with
|
||||
// records, drive requests through the whole zip app, and assert the query
|
||||
// results, the integrity summary, and the global-admin gate.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v3"
|
||||
"github.com/hanzoai/cloud/audit"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// mountWithStore builds a zip app with admin's audit routes wired to a real audit
|
||||
// store, and returns the store + a request helper. Only the audit routes are
|
||||
// mounted here (the rest are covered by mount()); this keeps the store-backed
|
||||
// tests focused.
|
||||
func mountWithStore(t *testing.T) (*audit.Recorder, func(method, path string, hdr map[string]string) (*http.Response, []byte)) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := audit.Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("audit.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rec.Close() })
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &svc{adminOrg: "admin", auditStore: rec}
|
||||
app.Get("/v1/admin/audit", s.guard(s.audit))
|
||||
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
|
||||
fa := app.Fiber()
|
||||
|
||||
do := func(method, p string, hdr map[string]string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, p, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, p, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
return rec, do
|
||||
}
|
||||
|
||||
func seedAudit(t *testing.T, rec *audit.Recorder, n int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < n; i++ {
|
||||
_, err := rec.Append(ctx, audit.Record{
|
||||
Time: time.Now().UTC(),
|
||||
Actor: audit.Actor{Org: "admin", Sub: "z@hanzo.ai"},
|
||||
Action: "DELETE /v1/admin/orgs",
|
||||
Resource: audit.Resource{Type: "org", ID: "acme"},
|
||||
Auth: audit.AuthContext{Method: "jwt", IsAdmin: true},
|
||||
Outcome: audit.Outcome{Result: "success", Status: 200},
|
||||
Method: "DELETE",
|
||||
Path: "/v1/admin/orgs/acme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var globalAdmin = map[string]string{"X-User-IsAdmin": "true", "X-Org-Id": "admin", "X-User-Id": "z@hanzo.ai"}
|
||||
|
||||
// TestAdminAudit_ReturnsRealRecords proves GET /v1/admin/audit returns the
|
||||
// store's records (newest-first) with an accurate total and an integrity summary.
|
||||
func TestAdminAudit_ReturnsRealRecords(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 5)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit", globalAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("audit: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data []struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Action string `json:"action"`
|
||||
Hash string `json:"hash"`
|
||||
Result string `json:"result"`
|
||||
} `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
Integrity struct {
|
||||
OK bool `json:"ok"`
|
||||
Count uint64 `json:"count"`
|
||||
} `json:"integrity"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||
}
|
||||
if env.Data2 != 5 || len(env.Data) != 5 {
|
||||
t.Fatalf("got %d rows / total %d, want 5/5", len(env.Data), env.Data2)
|
||||
}
|
||||
if env.Data[0].Seq < env.Data[len(env.Data)-1].Seq {
|
||||
t.Errorf("not newest-first: %d..%d", env.Data[0].Seq, env.Data[len(env.Data)-1].Seq)
|
||||
}
|
||||
if env.Data[0].Hash == "" {
|
||||
t.Error("row has no hash — chain linkage not surfaced")
|
||||
}
|
||||
if !env.Integrity.OK || env.Integrity.Count != 5 {
|
||||
t.Errorf("integrity summary = %+v, want ok/count=5", env.Integrity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_Filters proves the query filters (result) reach the store.
|
||||
func TestAdminAudit_Filters(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
ctx := context.Background()
|
||||
// One deny among successes.
|
||||
_, _ = rec.Append(ctx, audit.Record{Action: "POST /v1/admin/roles", Actor: audit.Actor{Org: "admin"}, Outcome: audit.Outcome{Result: "deny", Status: 403}})
|
||||
seedAudit(t, rec, 3)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit?result=deny", globalAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
Data2 int `json:"data2"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &env)
|
||||
if env.Data2 != 1 || len(env.Data) != 1 {
|
||||
t.Fatalf("result=deny returned %d/%d, want 1/1", len(env.Data), env.Data2)
|
||||
}
|
||||
if env.Data[0]["result"] != "deny" {
|
||||
t.Errorf("filtered row result = %v, want deny", env.Data[0]["result"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_VerifyEndpoint proves GET /v1/admin/audit/verify returns the
|
||||
// integrity result for the chain.
|
||||
func TestAdminAudit_VerifyEndpoint(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 8)
|
||||
|
||||
resp, body := do("GET", "/v1/admin/audit/verify", globalAdmin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("verify: got %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
OK bool `json:"ok"`
|
||||
Count uint64 `json:"count"`
|
||||
BrokenAt int64 `json:"brokenAt"`
|
||||
HeadHash string `json:"headHash"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||
}
|
||||
if !env.Data.OK || env.Data.Count != 8 || env.Data.BrokenAt != -1 {
|
||||
t.Errorf("verify result = %+v, want ok/count=8/brokenAt=-1", env.Data)
|
||||
}
|
||||
if env.Data.HeadHash == "" {
|
||||
t.Error("verify returned no head hash")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_DeniedWithoutGlobalAdmin proves BOTH audit endpoints fail-closed
|
||||
// 403 for a non-global-admin, and — critically — the store is NEVER read on a
|
||||
// denied request (the gate runs before the handler, so no records leak to an
|
||||
// unauthorized caller). We assert non-leakage by seeding records and confirming
|
||||
// the denied response body contains none of them.
|
||||
func TestAdminAudit_DeniedWithoutGlobalAdmin(t *testing.T) {
|
||||
rec, do := mountWithStore(t)
|
||||
seedAudit(t, rec, 3)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
hdr map[string]string
|
||||
}{
|
||||
{"no identity", map[string]string{}},
|
||||
{"tenant admin (org != adminOrg, no minted IsAdmin)", map[string]string{"X-Org-Id": "acme", "X-User-Id": "mallory"}},
|
||||
{"forged-looking but non-admin", map[string]string{"X-Org-Id": "acme"}},
|
||||
}
|
||||
for _, ep := range []string{"/v1/admin/audit", "/v1/admin/audit/verify"} {
|
||||
for _, tc := range cases {
|
||||
resp, body := do("GET", ep, tc.hdr)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s [%s]: got %d, want 403 (body=%s)", ep, tc.name, resp.StatusCode, body)
|
||||
}
|
||||
// No record content must appear in a denied response.
|
||||
if len(body) > 0 && (contains(body, "DELETE /v1/admin/orgs") || contains(body, `"hash"`)) {
|
||||
t.Errorf("%s [%s]: denied response leaked audit data: %s", ep, tc.name, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAudit_FallsBackToIAMWhenNoStore proves that when no local store is
|
||||
// configured (auditStore == nil), /v1/admin/audit still serves the federated IAM
|
||||
// view rather than erroring — preserving the prior capability. Covered by the
|
||||
// existing TestAudit_MapsRecords (IAM proxy path); here we assert the nil-store
|
||||
// verify endpoint reports "not configured" rather than panicking.
|
||||
func TestAdminAudit_VerifyWithoutStore(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
s := &svc{adminOrg: "admin"} // no auditStore
|
||||
app.Get("/v1/admin/audit/verify", s.guard(s.auditVerify))
|
||||
req := httptest.NewRequest("GET", "/v1/admin/audit/verify", nil)
|
||||
for k, v := range globalAdmin {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// A well-formed error envelope, not a 500/panic.
|
||||
if resp.StatusCode != http.StatusOK || !contains(body, "not configured") {
|
||||
t.Errorf("nil-store verify = %d %s, want an ok-envelope 'not configured' error", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(b []byte, sub string) bool {
|
||||
s := string(b)
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commerceClient reads the commerce billing S2S surface (/v1/billing/*) for the
|
||||
// money panels (spend, tokens, credits). Commerce runs as its own deployment;
|
||||
// these are HTTP calls authenticated with the admin-scoped COMMERCE_SERVICE_TOKEN
|
||||
// (a KMS-sourced secret already on the cloud env — never hard-coded here) and the
|
||||
// per-org X-IAM-Org-Id header commerce resolves its namespace from.
|
||||
type commerceClient struct {
|
||||
base string // e.g. http://commerce.hanzo.svc.cluster.local:8001
|
||||
token string // admin S2S bearer (secret; never logged)
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newCommerceClient(base, token string) *commerceClient {
|
||||
return &commerceClient{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
token: strings.TrimSpace(token),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *commerceClient) configured() bool { return c != nil && c.base != "" }
|
||||
|
||||
// rollup is the org-scoped billing view commerce serves at /v1/billing/usage-rollup.
|
||||
// Cents are the canonical unit; consumedCents is the org's month-to-date spend.
|
||||
type rollup struct {
|
||||
ConsumedCents int64 `json:"consumedCents"`
|
||||
OverageCents int64 `json:"overageCents"`
|
||||
Balance struct {
|
||||
BalanceCents int64 `json:"balanceCents"`
|
||||
AvailableCents int64 `json:"availableCents"`
|
||||
} `json:"balance"`
|
||||
}
|
||||
|
||||
// usageRollup fetches the current-month rollup for one billing subject (an IAM
|
||||
// "org/user" identity) in org `org`. commerce keys usage per user; the operator
|
||||
// aggregates across an org's users when a full breakdown is needed. Returns a
|
||||
// zero rollup (not an error) when commerce is not configured so a partial deploy
|
||||
// degrades to honest zeros rather than a 5xx.
|
||||
func (c *commerceClient) usageRollup(ctx context.Context, org, user string) (rollup, error) {
|
||||
var out rollup
|
||||
if !c.configured() {
|
||||
return out, nil
|
||||
}
|
||||
q := url.Values{"user": {user}}
|
||||
body, err := c.get(ctx, "/v1/billing/usage-rollup", q, org)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("commerce rollup decode: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// balanceAll is the org's prepaid credit balance across currencies (cents).
|
||||
// Sourced from /v1/billing/balance/all for the "Credits" tile.
|
||||
type balanceAll struct {
|
||||
Balances map[string]struct {
|
||||
Available int64 `json:"available"`
|
||||
Balance int64 `json:"balance"`
|
||||
} `json:"balances"`
|
||||
}
|
||||
|
||||
// creditsCents returns the org's available credit balance in USD cents. Zero
|
||||
// (not an error) when commerce is unconfigured.
|
||||
func (c *commerceClient) creditsCents(ctx context.Context, org, user string) (int64, error) {
|
||||
if !c.configured() {
|
||||
return 0, nil
|
||||
}
|
||||
q := url.Values{"user": {user}, "currency": {"usd"}}
|
||||
body, err := c.get(ctx, "/v1/billing/balance", q, org)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var b struct {
|
||||
Available int64 `json:"available"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
return 0, fmt.Errorf("commerce balance decode: %w", err)
|
||||
}
|
||||
return b.Available, nil
|
||||
}
|
||||
|
||||
// get performs one admin-authenticated commerce GET and returns the raw body.
|
||||
func (c *commerceClient) get(ctx context.Context, path string, q url.Values, org string) ([]byte, error) {
|
||||
u := c.base + path
|
||||
if enc := q.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if org != "" {
|
||||
req.Header.Set("X-IAM-Org-Id", org)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commerce unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, 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 body, nil
|
||||
}
|
||||
|
||||
// healthClient probes an upstream's /v1/o11y/health (or any health path) so the
|
||||
// overview can report System Health honestly. A non-2xx or unreachable upstream
|
||||
// is reported as not-ok — never masked.
|
||||
type healthClient struct {
|
||||
url string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newHealthClient(u string) *healthClient {
|
||||
return &healthClient{url: strings.TrimSpace(u), http: &http.Client{Timeout: 8 * time.Second}}
|
||||
}
|
||||
|
||||
func (h *healthClient) configured() bool { return h != nil && h.url != "" }
|
||||
|
||||
// ok reports whether the o11y health endpoint answers 2xx.
|
||||
func (h *healthClient) ok(ctx context.Context) (bool, error) {
|
||||
if !h.configured() {
|
||||
return false, fmt.Errorf("o11y health not configured")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resp, err := h.http.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("o11y unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return false, fmt.Errorf("o11y health %d", resp.StatusCode)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// iamClient reads the IAM management surface (/v1/iam/get-*) on behalf of a
|
||||
// verified global-admin caller. IAM runs as its own deployment (not fused into
|
||||
// this binary — see subsystems.go), so these are HTTP calls, not Go method
|
||||
// dispatch. Every call REPLAYS THE CALLER'S OWN credential (session cookie +
|
||||
// Authorization), so IAM authorizes the read as the same principal the gateway
|
||||
// already validated as a global admin. admin adds NO service credential of
|
||||
// its own here: it never widens what the caller could read directly, and IAM's
|
||||
// own IsGlobalAdmin gate stays the second line of defense.
|
||||
type iamClient struct {
|
||||
base string // e.g. http://iam.hanzo.svc.cluster.local:8000
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newIAMClient(base string) *iamClient {
|
||||
return &iamClient{
|
||||
base: strings.TrimRight(strings.TrimSpace(base), "/"),
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *iamClient) configured() bool { return c != nil && c.base != "" }
|
||||
|
||||
// creds is the caller's replayed authorization context: the raw Cookie header
|
||||
// and Authorization bearer captured off the inbound request. IAM authenticates
|
||||
// exactly as it does for the browser (credentials: 'include').
|
||||
type creds struct {
|
||||
cookie string
|
||||
auth string
|
||||
}
|
||||
|
||||
// envelope is the uniform casibase response shape every /v1/iam handler returns.
|
||||
// data is the payload; data2 the list total (paginated reads).
|
||||
type envelope struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Data2 json.RawMessage `json:"data2"`
|
||||
}
|
||||
|
||||
// listResult is a decoded paginated read: the raw rows and the backend total.
|
||||
type listResult struct {
|
||||
rows json.RawMessage
|
||||
total int
|
||||
}
|
||||
|
||||
// getList calls an IAM get-* endpoint and returns the raw data array + data2
|
||||
// total. A non-ok envelope is an error (surfaced honestly to the operator).
|
||||
func (c *iamClient) getList(ctx context.Context, cr creds, path string, q url.Values) (listResult, error) {
|
||||
env, err := c.get(ctx, cr, path, q)
|
||||
if err != nil {
|
||||
return listResult{}, err
|
||||
}
|
||||
total := envTotal(env.Data2, env.Data)
|
||||
return listResult{rows: env.Data, total: total}, nil
|
||||
}
|
||||
|
||||
// get performs one authenticated GET and decodes the casibase envelope.
|
||||
func (c *iamClient) get(ctx context.Context, cr creds, path string, q url.Values) (envelope, error) {
|
||||
if !c.configured() {
|
||||
return envelope{}, fmt.Errorf("iam endpoint not configured")
|
||||
}
|
||||
u := c.base + path
|
||||
if enc := q.Encode(); enc != "" {
|
||||
u += "?" + enc
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return envelope{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if cr.cookie != "" {
|
||||
req.Header.Set("Cookie", cr.cookie)
|
||||
}
|
||||
if cr.auth != "" {
|
||||
req.Header.Set("Authorization", cr.auth)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return envelope{}, fmt.Errorf("iam unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if err != nil {
|
||||
return envelope{}, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return envelope{}, fmt.Errorf("iam denied (%d)", resp.StatusCode)
|
||||
}
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return envelope{}, fmt.Errorf("iam non-envelope response (%d)", resp.StatusCode)
|
||||
}
|
||||
if env.Status != "ok" {
|
||||
msg := env.Msg
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("iam status %d", resp.StatusCode)
|
||||
}
|
||||
return envelope{}, fmt.Errorf("iam: %s", msg)
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// envTotal reads data2 as the list total when present, else counts data rows.
|
||||
func envTotal(data2, data json.RawMessage) int {
|
||||
if n, ok := asInt(data2); ok {
|
||||
return n
|
||||
}
|
||||
var rows []json.RawMessage
|
||||
if json.Unmarshal(data, &rows) == nil {
|
||||
return len(rows)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// asInt decodes a JSON number (casibase data2 may arrive as a bare int).
|
||||
func asInt(raw json.RawMessage) (int, bool) {
|
||||
t := strings.TrimSpace(string(raw))
|
||||
if t == "" || t == "null" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.Atoi(t); err == nil {
|
||||
return n, true
|
||||
}
|
||||
var f float64
|
||||
if json.Unmarshal(raw, &f) == nil {
|
||||
return int(f), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package admin
|
||||
|
||||
// Response shapes for /v1/admin/*. Each mirrors the operator's api.ts contract
|
||||
// (admin/apps/operator/src/lib/api.ts) field-for-field — the JSON tags ARE the
|
||||
// contract, so the operator's TypeScript types decode these one-to-one.
|
||||
|
||||
// adminMe is the operator identity (AdminMe / GET /v1/admin/me).
|
||||
type adminMe struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsGlobalAdmin bool `json:"isGlobalAdmin"`
|
||||
}
|
||||
|
||||
// sourceStatus is the freshness of one upstream the aggregator pulls from
|
||||
// (SourceStatus / overview.sources[]).
|
||||
type sourceStatus struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Rows int `json:"rows"`
|
||||
Error string `json:"error"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// overviewData is the fleet overview tiles (OverviewData / GET /v1/admin/overview).
|
||||
type overviewData struct {
|
||||
Orgs int `json:"orgs"`
|
||||
Users int `json:"users"`
|
||||
Products int `json:"products"`
|
||||
ActiveProducts int `json:"activeProducts"`
|
||||
Drift int `json:"drift"`
|
||||
SpendCents30d int64 `json:"spendCents30d"`
|
||||
Tokens30d int64 `json:"tokens30d"`
|
||||
CreditsCents int64 `json:"creditsCents"`
|
||||
LastSync string `json:"lastSync"`
|
||||
Sources []sourceStatus `json:"sources"`
|
||||
}
|
||||
|
||||
// orgRow is one tenant row (OrgRow / GET /v1/admin/orgs).
|
||||
type orgRow struct {
|
||||
Org string `json:"org"`
|
||||
Display string `json:"display"`
|
||||
Users int `json:"users"`
|
||||
Products int `json:"products"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
CreditsCents int64 `json:"creditsCents"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// operatorUser is one user in the cross-org directory (OperatorUser / GET
|
||||
// /v1/admin/users).
|
||||
type operatorUser struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
IsGlobalAdmin bool `json:"isGlobalAdmin"`
|
||||
Tag string `json:"tag"`
|
||||
Created string `json:"created"`
|
||||
LastSignin string `json:"lastSignin"`
|
||||
Forbidden bool `json:"forbidden"`
|
||||
}
|
||||
|
||||
// usage roll-up (UsageData / GET /v1/admin/usage).
|
||||
type usageTotals struct {
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
type usagePoint struct {
|
||||
Date string `json:"date"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
type usageByProduct struct {
|
||||
Product string `json:"product"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
}
|
||||
|
||||
type usageData struct {
|
||||
Totals usageTotals `json:"totals"`
|
||||
Series []usagePoint `json:"series"`
|
||||
ByProduct []usageByProduct `json:"byProduct"`
|
||||
}
|
||||
|
||||
// productRow is one product/workload row (ProductRow / GET /v1/admin/products).
|
||||
type productRow struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Org string `json:"org"`
|
||||
Cluster string `json:"cluster"`
|
||||
DeclaredTag string `json:"declaredTag"`
|
||||
RunningTag string `json:"runningTag"`
|
||||
Health string `json:"health"`
|
||||
Drift bool `json:"drift"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
// ── IAM wire shapes (the subset admin decodes from get-* payloads) ─────────
|
||||
|
||||
// iamOrg is the IAM Organization subset the aggregators fold over.
|
||||
type iamOrg struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
}
|
||||
|
||||
// iamUser is the IAM User subset mapped into OperatorUser.
|
||||
type iamUser struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Tag string `json:"tag"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
LastSigninTime string `json:"lastSigninTime"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
IsForbidden bool `json:"isForbidden"`
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package botsvc mounts /v1/bot/* — a reverse proxy to the in-cluster
|
||||
// bot-gateway (the OpenAI-compatible agent gateway that owns channels, skills,
|
||||
// and the agent API). The console2 Bot module probes /v1/bot/health and links
|
||||
// out to the operational surfaces; without this the route 404s "not routed on
|
||||
// this host". No bot logic is reimplemented here — bot-gateway owns it.
|
||||
//
|
||||
// Path mapping: bot-gateway serves bare paths (/health, /v1/chat/completions),
|
||||
// NOT the /v1/bot/* prefix — the edge strips it. So this facade strips /v1/bot
|
||||
// too: /v1/bot/<rest> → {bot-gateway}/<rest> (e.g. /v1/bot/health → /health).
|
||||
//
|
||||
// Order 143 — binds /v1/bot/* before the AI subsystem's /v1/* catch-all (150).
|
||||
package bot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// identityHeaders are forwarded so bot-gateway sees the gateway-minted tenant
|
||||
// context (already sanitized + re-injected by middleware_identity upstream).
|
||||
var identityHeaders = []string{
|
||||
"Authorization", "X-Org-Id", "X-User-Id", "X-User-Email", "X-Project-Id", "X-Environment",
|
||||
}
|
||||
|
||||
type service struct {
|
||||
target string // bot-gateway base, no trailing slash
|
||||
log luxlog.Logger
|
||||
cc *http.Client
|
||||
}
|
||||
|
||||
func botURL() string {
|
||||
return strings.TrimRight(firstNonEmpty(getenv("BOT_GATEWAY_URL"), "http://bot-gateway.hanzo.svc"), "/")
|
||||
}
|
||||
|
||||
// Mount registers the /v1/bot/* surface on app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("bot.Mount: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("bot.Mount: nil deps.Logger")
|
||||
}
|
||||
s := &service{
|
||||
target: botURL(),
|
||||
log: deps.Logger.New("subsystem", "bot"),
|
||||
cc: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
app.All("/v1/bot/*", s.proxy)
|
||||
s.log.Info("bot surface mounted", "target", s.target, "brand", deps.Brand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) proxy(c *zip.Ctx) error {
|
||||
// Strip the /v1/bot prefix — bot-gateway serves bare paths.
|
||||
rest := strings.TrimPrefix(c.Fiber().Params("*"), "/")
|
||||
target := s.target + "/" + rest
|
||||
if q := c.Fiber().Request().URI().QueryString(); len(q) > 0 {
|
||||
target += "?" + string(q)
|
||||
}
|
||||
|
||||
method := c.Fiber().Method()
|
||||
var body io.Reader
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
body = bytes.NewReader(c.Body())
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.Context(), method, target, body)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "bot: build request: %v", err)
|
||||
}
|
||||
if ct := c.Header("Content-Type"); ct != "" {
|
||||
req.Header.Set("Content-Type", ct)
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for _, h := range identityHeaders {
|
||||
if v := c.Header(h); v != "" {
|
||||
req.Header.Set(h, v)
|
||||
}
|
||||
}
|
||||
resp, err := s.cc.Do(req)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "bot: gateway unreachable: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
c.SetHeader("Content-Type", ct)
|
||||
}
|
||||
return c.Bytes(resp.StatusCode, rb)
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getenv(key string) string { return strings.TrimSpace(os.Getenv(key)) }
|
||||
|
||||
func init() {
|
||||
cloud.Register("bot", 143, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("bot.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// disabledErr is the error every "dep not wired" client returns. The
|
||||
// subsystem name is the field of cloud.Deps that resolved to a
|
||||
// disabled client; the caller is the subsystem that asked for the dep
|
||||
// (used for the "X needs Y but Y isn't enabled" log message at mount
|
||||
// time).
|
||||
type disabledErr struct{ subsystem string }
|
||||
|
||||
func (e *disabledErr) Error() string {
|
||||
return fmt.Sprintf("cloud: dep %q is disabled — enable the subsystem or configure its RPC endpoint", e.subsystem)
|
||||
}
|
||||
|
||||
// IsDisabled reports whether err originated from a disabled client.
|
||||
// Subsystem mount code can use this to log a friendly warning instead
|
||||
// of cascading a 500.
|
||||
func IsDisabled(err error) bool {
|
||||
_, ok := err.(*disabledErr)
|
||||
return ok
|
||||
}
|
||||
|
||||
// --- one type per disabled client ----------------------------------------
|
||||
|
||||
type disabledIAM struct{}
|
||||
|
||||
func (disabledIAM) VerifyJWT(_ context.Context, _ string) (types.Claims, error) {
|
||||
return types.Claims{}, &disabledErr{"iam"}
|
||||
}
|
||||
func (disabledIAM) GetUser(_ context.Context, _ string) (*types.User, error) {
|
||||
return nil, &disabledErr{"iam"}
|
||||
}
|
||||
func (disabledIAM) GetOrg(_ context.Context, _ string) (*types.Org, error) {
|
||||
return nil, &disabledErr{"iam"}
|
||||
}
|
||||
|
||||
type disabledKMS struct{}
|
||||
|
||||
func (disabledKMS) GetSecret(_ context.Context, _ string) ([]byte, error) {
|
||||
return nil, &disabledErr{"kms"}
|
||||
}
|
||||
func (disabledKMS) PutSecret(_ context.Context, _ string, _ []byte) error {
|
||||
return &disabledErr{"kms"}
|
||||
}
|
||||
func (disabledKMS) Sign(_ context.Context, _ string, _ []byte) ([]byte, error) {
|
||||
return nil, &disabledErr{"kms"}
|
||||
}
|
||||
|
||||
type disabledBase struct{}
|
||||
|
||||
func (disabledBase) Open(_ context.Context, _, _ string) (types.DBHandle, error) {
|
||||
return nil, &disabledErr{"base"}
|
||||
}
|
||||
|
||||
type disabledCommerce struct{}
|
||||
|
||||
func (disabledCommerce) GetTenantConfig(_ context.Context, _ string) (*types.TenantConfig, error) {
|
||||
return nil, &disabledErr{"commerce"}
|
||||
}
|
||||
func (disabledCommerce) CheckEntitlement(_ context.Context, _, _ string) (*types.LicenseEntitlement, error) {
|
||||
return nil, &disabledErr{"commerce"}
|
||||
}
|
||||
|
||||
type disabledAI struct{}
|
||||
|
||||
func (disabledAI) ChatCompletion(_ context.Context, _ *types.ChatRequest) (*types.ChatResponse, error) {
|
||||
return nil, &disabledErr{"ai"}
|
||||
}
|
||||
|
||||
type disabledO11y struct{}
|
||||
|
||||
func (disabledO11y) Counter(_ string, _ ...string) types.Counter { return noopCounter{} }
|
||||
func (disabledO11y) Timing(_ string, _ ...string) types.Timing { return noopTiming{} }
|
||||
func (disabledO11y) Span(ctx context.Context, _ string) (context.Context, types.Span) {
|
||||
return ctx, noopSpan{}
|
||||
}
|
||||
|
||||
type disabledVFS struct{}
|
||||
|
||||
func (disabledVFS) Put(_ context.Context, _ string, _ []byte) error {
|
||||
return &disabledErr{"vfs"}
|
||||
}
|
||||
func (disabledVFS) Get(_ context.Context, _ string) ([]byte, error) {
|
||||
return nil, &disabledErr{"vfs"}
|
||||
}
|
||||
|
||||
type disabledMQ struct{}
|
||||
|
||||
func (disabledMQ) Publish(_ context.Context, _ string, _ []byte) error {
|
||||
return &disabledErr{"mq"}
|
||||
}
|
||||
func (disabledMQ) Subscribe(_ context.Context, _ string, _ func([]byte) error) error {
|
||||
return &disabledErr{"mq"}
|
||||
}
|
||||
|
||||
type disabledPayments struct{}
|
||||
|
||||
func (disabledPayments) CreateIntent(_ context.Context, _ *types.IntentRequest) (*types.IntentResponse, error) {
|
||||
return nil, &disabledErr{"payments"}
|
||||
}
|
||||
func (disabledPayments) ConfirmIntent(_ context.Context, _ string) (*types.IntentResponse, error) {
|
||||
return nil, &disabledErr{"payments"}
|
||||
}
|
||||
func (disabledPayments) GetIntentStatus(_ context.Context, _ string) (*types.IntentStatus, error) {
|
||||
return nil, &disabledErr{"payments"}
|
||||
}
|
||||
|
||||
type disabledVault struct{}
|
||||
|
||||
func (disabledVault) Charge(_ context.Context, _ *types.VaultChargeRequest) (*types.VaultChargeResponse, error) {
|
||||
return nil, &disabledErr{"vault"}
|
||||
}
|
||||
|
||||
// --- noop telemetry handles so callers don't have to nil-check ----------
|
||||
|
||||
type noopCounter struct{}
|
||||
|
||||
func (noopCounter) Inc(_ int64) {}
|
||||
|
||||
type noopTiming struct{}
|
||||
|
||||
func (noopTiming) Observe(_ float64) {}
|
||||
|
||||
type noopSpan struct{}
|
||||
|
||||
func (noopSpan) End() {}
|
||||
|
||||
// --- constructors --------------------------------------------------------
|
||||
|
||||
// DisabledIAM returns a fail-closed IAM client.
|
||||
func DisabledIAM() types.IAMClient { return disabledIAM{} }
|
||||
|
||||
// DisabledKMS returns a fail-closed KMS client.
|
||||
func DisabledKMS() types.KMSClient { return disabledKMS{} }
|
||||
|
||||
// DisabledBase returns a fail-closed Base client.
|
||||
func DisabledBase() types.BaseClient { return disabledBase{} }
|
||||
|
||||
// DisabledCommerce returns a fail-closed Commerce client.
|
||||
func DisabledCommerce() types.CommerceClient { return disabledCommerce{} }
|
||||
|
||||
// DisabledAI returns a fail-closed AI client.
|
||||
func DisabledAI() types.AIClient { return disabledAI{} }
|
||||
|
||||
// DisabledO11y returns an O11y client that emits to /dev/null. Used
|
||||
// when o11y isn't mounted; subsystems get no-op metrics rather than
|
||||
// nil deref or error spam.
|
||||
func DisabledO11y() types.O11yClient { return disabledO11y{} }
|
||||
|
||||
// DisabledVFS returns a fail-closed VFS client.
|
||||
func DisabledVFS() types.VFSClient { return disabledVFS{} }
|
||||
|
||||
// DisabledMQ returns a fail-closed MQ client.
|
||||
func DisabledMQ() types.MQClient { return disabledMQ{} }
|
||||
|
||||
// DisabledPayments returns a fail-closed Payments client.
|
||||
func DisabledPayments() types.PaymentsClient { return disabledPayments{} }
|
||||
|
||||
// DisabledVault returns a fail-closed Vault client.
|
||||
func DisabledVault() types.VaultClient { return disabledVault{} }
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package clients holds the canonical ZAP-typed inter-subsystem
|
||||
// clients used by cloud.Deps.
|
||||
//
|
||||
// Per HIP-0106 "Inter-subsystem contract": ZAP (the Hanzo native binary
|
||||
// protocol). Every subsystem ships its public interface as a .zap
|
||||
// schema; zapc generates Go bindings; cloud wires the in-process
|
||||
// ZAP-typed Go interfaces when subsystems are co-resident, falls
|
||||
// back to ZAP RPC over the wire when split.
|
||||
//
|
||||
// This package provides three factories per subsystem:
|
||||
//
|
||||
// - <Subsystem>InProcess(impl): wraps a co-resident implementation
|
||||
// as a ZAP-typed client. Direct Go method calls. No marshalling,
|
||||
// no network.
|
||||
//
|
||||
// - <Subsystem>RPC(addr): builds a ZAP-RPC client targeting a
|
||||
// remote endpoint (used in split deployments).
|
||||
//
|
||||
// - Disabled<Subsystem>(): returns a typed nil that fails closed
|
||||
// with a clear error message when called. Lets subsystem mount
|
||||
// code defensively detect "the dep isn't wired" without nil
|
||||
// dereferences.
|
||||
//
|
||||
// cloud.BuildDeps picks the right one for each subsystem based on
|
||||
// cfg.Enabled(name) and the configured RPC endpoint.
|
||||
//
|
||||
// Note (zapc): the ZAP RPC wire format is exercised by hanzoai/zap
|
||||
// (Rust impl) and hanzoai/zap-go (Go bindings). The current Go
|
||||
// scaffolding here ships stubs sufficient to enforce the contract;
|
||||
// the actual RPC dispatch sits behind a transport layer that
|
||||
// subsystems will swap in as each subsystem ships its .zap schema +
|
||||
// zapc-generated client. TODO(zapc-gen) markers identify the
|
||||
// expansion points.
|
||||
package clients
|
||||
@@ -0,0 +1,657 @@
|
||||
// Package evalsvc mounts the Hanzo Cloud /v1/evals/* surface: a thin facade
|
||||
// that composes two systems that ALREADY work — the console eval engine (the
|
||||
// Langfuse v3 fork at console.hanzo.svc, whose public REST API owns Datasets,
|
||||
// DatasetItems, Evaluators, DatasetRuns and Scores) and the in-process model
|
||||
// gateway (the AI subsystem's OpenAI-compatible /v1/chat/completions) — into
|
||||
// one OpenAI-style evals API.
|
||||
//
|
||||
// No eval logic is reimplemented here. Datasets, dataset-items, evaluators and
|
||||
// score reads are proxied verbatim to the console's public API (HTTP Basic
|
||||
// auth, a public/secret key pair that is itself project-scoped — so the key
|
||||
// pair IS the IAM-org → console-project binding; there is no separate
|
||||
// projectId to thread). POST /v1/evals/runs orchestrates a REAL run: for each
|
||||
// dataset item it calls the in-process gateway for the model-under-test output,
|
||||
// records a trace + dataset-run-item in the console, then (synchronously) calls
|
||||
// the gateway again as LLM-as-judge and posts the score against that trace.
|
||||
//
|
||||
// Auth, two domains, kept orthogonal:
|
||||
// - console (datasets/scores/...) ← HTTP Basic, key pair from config/KMS.
|
||||
// - model gateway (chat) ← the CALLER's own Authorization bearer,
|
||||
// forwarded to the loopback /v1/chat/completions. The run therefore
|
||||
// executes with the caller's identity and model entitlements; no privilege
|
||||
// escalation, and the gateway authenticates it exactly as a direct call.
|
||||
//
|
||||
// Order 145: binds /v1/evals/* BEFORE the AI subsystem's /v1/* catch-all (150),
|
||||
// the same slot productsvc uses. The composition root auto-registers
|
||||
// GET /v1/evals/health (serve.go) for every subsystem in the registry.
|
||||
package eval
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// Run sizing: a synchronous run is bounded so one request cannot fan out into
|
||||
// thousands of paired LLM calls. Larger sweeps belong on the async path (see
|
||||
// runItem) where the console's own judge worker scores at its own pace.
|
||||
const (
|
||||
defaultRunItems = 20
|
||||
maxRunItems = 100
|
||||
)
|
||||
|
||||
// config is resolved once at Mount from the canonical console env (the
|
||||
// console-keys / console-langfuse-keys KMS-synced secret) plus the cloud
|
||||
// listener for the in-process gateway loopback. Endpoint defaults to the real
|
||||
// in-cluster console Service DNS; keys have no default and fail closed.
|
||||
type config struct {
|
||||
consoleURL string // console base, no trailing slash
|
||||
publicKey string // console (Langfuse) public key — global fallback
|
||||
secretKey string // console (Langfuse) secret key — global fallback
|
||||
gatewayURL string // loopback base for the in-process gateway, e.g. http://127.0.0.1:8080
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
return config{
|
||||
consoleURL: strings.TrimRight(firstNonEmpty(
|
||||
getenv("CONSOLE_HOST"),
|
||||
getenv("consoleEndpoint"),
|
||||
"http://console.hanzo.svc.cluster.local",
|
||||
), "/"),
|
||||
publicKey: firstNonEmpty(getenv("CONSOLE_PUBLIC_KEY"), getenv("LANGFUSE_PUBLIC_KEY")),
|
||||
secretKey: firstNonEmpty(getenv("CONSOLE_SECRET_KEY"), getenv("LANGFUSE_SECRET_KEY")),
|
||||
gatewayURL: loopbackBase(firstNonEmpty(getenv("CLOUD_LISTEN"), ":8080")),
|
||||
}
|
||||
}
|
||||
|
||||
// service holds the resolved config + two HTTP clients (a short-timeout one for
|
||||
// the console control-plane calls, a long-timeout one for the LLM gateway).
|
||||
type service struct {
|
||||
cfg config
|
||||
log luxlog.Logger
|
||||
kms cloud.KMSClient // per-org key override; nil when KMS is not wired in-process
|
||||
cc *http.Client // console client
|
||||
gc *http.Client // gateway client (LLM latency)
|
||||
}
|
||||
|
||||
// Mount registers the /v1/evals/* surface on app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("eval.Mount: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("eval.Mount: nil deps.Logger")
|
||||
}
|
||||
s := &service{
|
||||
cfg: loadConfig(),
|
||||
log: deps.Logger.New("subsystem", "evals"),
|
||||
kms: deps.KMS,
|
||||
cc: &http.Client{Timeout: 30 * time.Second},
|
||||
gc: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
|
||||
// Thin proxies — pass the body/query through verbatim and return the
|
||||
// console's status + body unchanged, so console validation errors surface
|
||||
// honestly instead of being masked.
|
||||
app.Post("/v1/evals/datasets", s.proxy(http.MethodPost, "/api/public/v2/datasets"))
|
||||
app.Post("/v1/evals/dataset-items", s.proxy(http.MethodPost, "/api/public/dataset-items"))
|
||||
app.Post("/v1/evals/evaluators", s.proxy(http.MethodPost, "/api/public/unstable/evaluators"))
|
||||
app.Get("/v1/evals/scores", s.proxy(http.MethodGet, "/api/public/v2/scores"))
|
||||
|
||||
// Orchestration.
|
||||
app.Post("/v1/evals/runs", s.runHandler)
|
||||
|
||||
s.log.Info("evals surface mounted",
|
||||
"console", s.cfg.consoleURL,
|
||||
"gateway", s.cfg.gatewayURL,
|
||||
"consoleKey", s.cfg.publicKey != "",
|
||||
"brand", deps.Brand,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── proxy ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// proxy returns a handler that forwards method+path to the console under HTTP
|
||||
// Basic auth, resolving the project key pair from the request's tenant.
|
||||
func (s *service) proxy(method, consolePath string) func(c *zip.Ctx) error {
|
||||
return func(c *zip.Ctx) error {
|
||||
pk, sk, err := s.resolveKeys(c.Context(), tenant(c))
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "%s", err.Error())
|
||||
}
|
||||
target := s.cfg.consoleURL + consolePath
|
||||
if q := c.Fiber().Request().URI().QueryString(); len(q) > 0 {
|
||||
target += "?" + string(q)
|
||||
}
|
||||
var body io.Reader
|
||||
if method == http.MethodPost {
|
||||
body = bytes.NewReader(c.Body())
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.Context(), method, target, body)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "evals: build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Basic "+basic(pk, sk))
|
||||
resp, err := s.cc.Do(req)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "evals: console unreachable: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return c.Bytes(resp.StatusCode, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// ── run orchestration ────────────────────────────────────────────────────────
|
||||
|
||||
type judgeSpec struct {
|
||||
Model string `json:"model"`
|
||||
Criteria string `json:"criteria"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type runRequest struct {
|
||||
Dataset string `json:"dataset"`
|
||||
Model string `json:"model"`
|
||||
RunName string `json:"runName"`
|
||||
Limit int `json:"limit"`
|
||||
Judge *judgeSpec `json:"judge"`
|
||||
}
|
||||
|
||||
type itemResult struct {
|
||||
ItemID string `json:"itemId"`
|
||||
TraceID string `json:"traceId,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type runSummary struct {
|
||||
Dataset string `json:"dataset"`
|
||||
Model string `json:"model"`
|
||||
JudgeModel string `json:"judgeModel"`
|
||||
RunName string `json:"runName"`
|
||||
Items int `json:"items"`
|
||||
Scored int `json:"scored"`
|
||||
AvgScore float64 `json:"avgScore"`
|
||||
Results []itemResult `json:"results"`
|
||||
}
|
||||
|
||||
func (s *service) runHandler(c *zip.Ctx) error {
|
||||
// The model gateway needs the caller's own credential — fail closed rather
|
||||
// than run models anonymously or with a service identity.
|
||||
authz := c.Header("Authorization")
|
||||
if authz == "" {
|
||||
return zip.ErrUnauthorized("evals/runs: missing Authorization bearer; the model gateway needs the caller's key/JWT")
|
||||
}
|
||||
var rr runRequest
|
||||
if err := json.Unmarshal(c.Body(), &rr); err != nil {
|
||||
return zip.Errorf(http.StatusBadRequest, "evals/runs: invalid JSON body: %v", err)
|
||||
}
|
||||
if rr.Dataset == "" || rr.Model == "" {
|
||||
return zip.Errorf(http.StatusBadRequest, "evals/runs: 'dataset' and 'model' are required")
|
||||
}
|
||||
pk, sk, err := s.resolveKeys(c.Context(), tenant(c))
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "%s", err.Error())
|
||||
}
|
||||
basicAuth := basic(pk, sk)
|
||||
|
||||
limit := rr.Limit
|
||||
if limit <= 0 || limit > maxRunItems {
|
||||
limit = defaultRunItems
|
||||
}
|
||||
runName := rr.RunName
|
||||
if runName == "" {
|
||||
runName = "run-" + time.Now().UTC().Format("20060102T150405Z")
|
||||
}
|
||||
judge := normalizeJudge(rr.Judge, rr.Model)
|
||||
|
||||
items, err := s.fetchItems(c.Context(), basicAuth, rr.Dataset, limit)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "evals/runs: %v", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return zip.Errorf(http.StatusUnprocessableEntity, "evals/runs: dataset %q has no active items", rr.Dataset)
|
||||
}
|
||||
|
||||
summary := runSummary{Dataset: rr.Dataset, Model: rr.Model, JudgeModel: judge.Model, RunName: runName, Items: len(items)}
|
||||
var sum float64
|
||||
for _, it := range items {
|
||||
res := s.runItem(c.Context(), authz, basicAuth, runName, rr.Model, judge, it)
|
||||
summary.Results = append(summary.Results, res)
|
||||
if res.Error == "" {
|
||||
sum += res.Score
|
||||
summary.Scored++
|
||||
}
|
||||
}
|
||||
if summary.Scored > 0 {
|
||||
summary.AvgScore = sum / float64(summary.Scored)
|
||||
}
|
||||
|
||||
// Nothing scored is a real failure, not a fake 200.
|
||||
status := http.StatusOK
|
||||
if summary.Scored == 0 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
return c.JSON(status, summary)
|
||||
}
|
||||
|
||||
// runItem is the single per-item seam, run synchronously:
|
||||
// 1. model-under-test → gateway /v1/chat/completions
|
||||
// 2. trace → console ingestion (carries the model output)
|
||||
// 3. dataset-run-item → links item→trace under runName (creates the run)
|
||||
// 4. LLM-as-judge → gateway /v1/chat/completions
|
||||
// 5. score → console /scores (numeric, on the trace)
|
||||
//
|
||||
// ASYNC EXTENSION POINT: steps 4–5 are the only inline judge work. To scale,
|
||||
// skip them and instead register an evaluator once
|
||||
// (POST /v1/evals/evaluators → /api/public/unstable/evaluators) that targets
|
||||
// dataset runs; step 3 then enqueues the console's BullMQ LLM-as-judge worker
|
||||
// (addDatasetRunItemsToEvalQueue), which scores this run item asynchronously
|
||||
// using the project's stored LLM connection. Nothing else in this file changes.
|
||||
func (s *service) runItem(ctx context.Context, authz, basicAuth, runName, model string, judge judgeSpec, it datasetItem) itemResult {
|
||||
res := itemResult{ItemID: it.ID}
|
||||
|
||||
output, err := s.gatewayChat(ctx, authz, model, buildMessages(it.Input))
|
||||
if err != nil {
|
||||
res.Error = "model: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.Output = truncate(output, 2000)
|
||||
|
||||
traceID := uuidv4()
|
||||
res.TraceID = traceID
|
||||
if err := s.postTrace(ctx, basicAuth, traceID, runName, model, it, output); err != nil {
|
||||
res.Error = "trace: " + err.Error()
|
||||
return res
|
||||
}
|
||||
if err := s.linkRunItem(ctx, basicAuth, runName, it.ID, traceID); err != nil {
|
||||
res.Error = "run-item: " + err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
score, reasoning, err := s.judgeOutput(ctx, authz, judge, it, output)
|
||||
if err != nil {
|
||||
res.Error = "judge: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.Score = score
|
||||
if err := s.postScore(ctx, basicAuth, traceID, judge.Name, score, reasoning); err != nil {
|
||||
res.Error = "score: " + err.Error()
|
||||
return res
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ── gateway (model-under-test + judge) ───────────────────────────────────────
|
||||
|
||||
func (s *service) gatewayChat(ctx context.Context, authz, model string, messages []map[string]any) (string, error) {
|
||||
payload := map[string]any{"model": model, "messages": messages, "temperature": 0, "stream": false}
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
status, err := s.doJSON(ctx, s.gc, http.MethodPost, s.cfg.gatewayURL+"/v1/chat/completions",
|
||||
map[string]string{"Authorization": authz}, payload, &out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
if out.Error != nil && out.Error.Message != "" {
|
||||
return "", fmt.Errorf("gateway %d: %s", status, out.Error.Message)
|
||||
}
|
||||
return "", fmt.Errorf("gateway status %d", status)
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("gateway returned no choices")
|
||||
}
|
||||
return out.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
func (s *service) judgeOutput(ctx context.Context, authz string, judge judgeSpec, it datasetItem, output string) (float64, string, error) {
|
||||
sys := "You are a strict evaluator. Score the ASSISTANT OUTPUT from 0.0 to 1.0 for how well it satisfies the criteria and matches the expected output. " +
|
||||
`Reply ONLY with compact JSON: {"score": <number 0..1>, "reasoning": "<one sentence>"}.`
|
||||
user := fmt.Sprintf("CRITERIA:\n%s\n\nINPUT:\n%s\n\nEXPECTED OUTPUT:\n%s\n\nASSISTANT OUTPUT:\n%s",
|
||||
judge.Criteria, asText(it.Input), asText(it.Expected), output)
|
||||
content, err := s.gatewayChat(ctx, authz, judge.Model, []map[string]any{
|
||||
{"role": "system", "content": sys},
|
||||
{"role": "user", "content": user},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return parseJudge(content)
|
||||
}
|
||||
|
||||
// ── console writes (ingestion / run-items / scores) + reads (items) ──────────
|
||||
|
||||
func (s *service) fetchItems(ctx context.Context, basicAuth, dataset string, limit int) ([]datasetItem, error) {
|
||||
target := fmt.Sprintf("%s/api/public/dataset-items?datasetName=%s&limit=%d",
|
||||
s.cfg.consoleURL, url.QueryEscape(dataset), limit)
|
||||
var out struct {
|
||||
Data []datasetItem `json:"data"`
|
||||
}
|
||||
status, err := s.doJSON(ctx, s.cc, http.MethodGet, target, s.basicHeader(basicAuth), nil, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status >= 300 {
|
||||
return nil, fmt.Errorf("dataset-items status %d", status)
|
||||
}
|
||||
active := make([]datasetItem, 0, len(out.Data))
|
||||
for _, it := range out.Data {
|
||||
if it.Status == "" || it.Status == "ACTIVE" {
|
||||
active = append(active, it)
|
||||
}
|
||||
}
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func (s *service) postTrace(ctx context.Context, basicAuth, traceID, runName, model string, it datasetItem, output string) error {
|
||||
batch := map[string]any{"batch": []map[string]any{{
|
||||
"id": uuidv4(),
|
||||
"type": "trace-create",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"body": map[string]any{
|
||||
"id": traceID,
|
||||
"name": "eval:" + runName,
|
||||
"input": it.Input,
|
||||
"output": output,
|
||||
"metadata": map[string]any{
|
||||
"dataset": it.DatasetName,
|
||||
"datasetItemId": it.ID,
|
||||
"runName": runName,
|
||||
"model": model,
|
||||
},
|
||||
"tags": []string{"eval", "run:" + runName},
|
||||
},
|
||||
}}}
|
||||
// Ingestion returns 207 Multi-Status on success; treat <300 as accepted.
|
||||
status, err := s.doJSON(ctx, s.cc, http.MethodPost, s.cfg.consoleURL+"/api/public/ingestion", s.basicHeader(basicAuth), batch, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status >= 300 {
|
||||
return fmt.Errorf("ingestion status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) linkRunItem(ctx context.Context, basicAuth, runName, itemID, traceID string) error {
|
||||
body := map[string]any{"runName": runName, "datasetItemId": itemID, "traceId": traceID}
|
||||
status, err := s.doJSON(ctx, s.cc, http.MethodPost, s.cfg.consoleURL+"/api/public/dataset-run-items", s.basicHeader(basicAuth), body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status >= 300 {
|
||||
return fmt.Errorf("dataset-run-items status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) postScore(ctx context.Context, basicAuth, traceID, name string, value float64, comment string) error {
|
||||
// Console score validation requires exactly one target; we attach to the
|
||||
// trace (which the dataset-run-item links into the run).
|
||||
body := map[string]any{"name": name, "value": value, "dataType": "NUMERIC", "traceId": traceID}
|
||||
if comment != "" {
|
||||
body["comment"] = truncate(comment, 500)
|
||||
}
|
||||
status, err := s.doJSON(ctx, s.cc, http.MethodPost, s.cfg.consoleURL+"/api/public/scores", s.basicHeader(basicAuth), body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status >= 300 {
|
||||
return fmt.Errorf("scores status %d", status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type datasetItem struct {
|
||||
ID string `json:"id"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Status string `json:"status"`
|
||||
Input any `json:"input"`
|
||||
Expected any `json:"expectedOutput"`
|
||||
}
|
||||
|
||||
// ── key resolution ───────────────────────────────────────────────────────────
|
||||
|
||||
// resolveKeys returns the console key pair for the tenant. Per-org KMS keys
|
||||
// (console-pk-{org}/console-sk-{org}) win when KMS is wired in-process (the
|
||||
// extension point that lights up once deps.KMS is populated); otherwise the
|
||||
// global key pair from env (the console-keys secret) is used. Missing keys fail
|
||||
// closed with a precise, config-naming error — never a fake success.
|
||||
func (s *service) resolveKeys(ctx context.Context, org string) (publicKey, secretKey string, err error) {
|
||||
if s.kms != nil && org != "" {
|
||||
if pkb, e := s.kms.GetSecret(ctx, "console-pk-"+org); e == nil && len(pkb) > 0 {
|
||||
if skb, e2 := s.kms.GetSecret(ctx, "console-sk-"+org); e2 == nil && len(skb) > 0 {
|
||||
return string(pkb), string(skb), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.publicKey != "" && s.cfg.secretKey != "" {
|
||||
return s.cfg.publicKey, s.cfg.secretKey, nil
|
||||
}
|
||||
return "", "", fmt.Errorf(
|
||||
"evals: no console API key for org %q: set CONSOLE_PUBLIC_KEY/CONSOLE_SECRET_KEY (KMS-synced secret 'console-keys') or per-org KMS 'console-pk-%s'/'console-sk-%s'",
|
||||
org, org, org)
|
||||
}
|
||||
|
||||
func (s *service) basicHeader(basicAuth string) map[string]string {
|
||||
return map[string]string{"Authorization": "Basic " + basicAuth}
|
||||
}
|
||||
|
||||
// ── one HTTP+JSON round-trip ─────────────────────────────────────────────────
|
||||
|
||||
func (s *service) doJSON(ctx context.Context, client *http.Client, method, target string, headers map[string]string, payload, into any) (int, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
if into != nil && len(rb) > 0 {
|
||||
if err := json.Unmarshal(rb, into); err != nil {
|
||||
return resp.StatusCode, fmt.Errorf("decode %s: %v (%s)", target, err, truncate(string(rb), 200))
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// ── pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// tenant resolves the org slug used to scope console keys, preferring the
|
||||
// canonical X-Project-Id sub-scope (what console2 stamps) and falling back to
|
||||
// the gateway-minted X-Org-Id.
|
||||
func tenant(c *zip.Ctx) string {
|
||||
if v := c.Header("X-Project-Id"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := c.Header("X-Org-Id"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.Org() // X-Org-Id
|
||||
}
|
||||
|
||||
// loopbackBase turns a listen address (":8080", "0.0.0.0:8080") into a loopback
|
||||
// base URL for the in-process gateway.
|
||||
func loopbackBase(listen string) string {
|
||||
_, port, err := net.SplitHostPort(listen)
|
||||
if err != nil || port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
return "http://127.0.0.1:" + port
|
||||
}
|
||||
|
||||
// buildMessages turns a dataset item input (string, {messages:[...]}, or any
|
||||
// JSON) into OpenAI chat messages.
|
||||
func buildMessages(input any) []map[string]any {
|
||||
switch v := input.(type) {
|
||||
case string:
|
||||
return []map[string]any{{"role": "user", "content": v}}
|
||||
case map[string]any:
|
||||
if raw, ok := v["messages"].([]any); ok && len(raw) > 0 {
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, m := range raw {
|
||||
if mm, ok := m.(map[string]any); ok {
|
||||
out = append(out, mm)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return []map[string]any{{"role": "user", "content": asText(input)}}
|
||||
}
|
||||
|
||||
// parseJudge extracts {score, reasoning} from a judge model reply, tolerating
|
||||
// surrounding prose; falls back to a bare float. No score is invented on
|
||||
// failure — the caller records an item error instead.
|
||||
func parseJudge(content string) (float64, string, error) {
|
||||
if i := strings.IndexByte(content, '{'); i >= 0 {
|
||||
if j := strings.LastIndexByte(content, '}'); j > i {
|
||||
var v struct {
|
||||
Score float64 `json:"score"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content[i:j+1]), &v); err == nil {
|
||||
return clamp01(v.Score), v.Reasoning, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if f, err := strconv.ParseFloat(strings.TrimSpace(content), 64); err == nil {
|
||||
return clamp01(f), "", nil
|
||||
}
|
||||
return 0, "", fmt.Errorf("could not parse judge score from %q", truncate(content, 120))
|
||||
}
|
||||
|
||||
func normalizeJudge(j *judgeSpec, model string) judgeSpec {
|
||||
out := judgeSpec{
|
||||
Model: model,
|
||||
Name: "llm-judge",
|
||||
Criteria: "The output should be correct, relevant, and match the expected output.",
|
||||
}
|
||||
if j != nil {
|
||||
if j.Model != "" {
|
||||
out.Model = j.Model
|
||||
}
|
||||
if j.Name != "" {
|
||||
out.Name = j.Name
|
||||
}
|
||||
if j.Criteria != "" {
|
||||
out.Criteria = j.Criteria
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asText(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func basic(pk, sk string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(pk + ":" + sk))
|
||||
}
|
||||
|
||||
func uuidv4() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func clamp01(f float64) float64 {
|
||||
if f < 0 {
|
||||
return 0
|
||||
}
|
||||
if f > 1 {
|
||||
return 1
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getenv(key string) string { return strings.TrimSpace(os.Getenv(key)) }
|
||||
|
||||
func init() {
|
||||
cloud.Register("evals", 145, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("eval.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoopbackBase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
":8080": "http://127.0.0.1:8080",
|
||||
"0.0.0.0:9000": "http://127.0.0.1:9000",
|
||||
"127.0.0.1:8000": "http://127.0.0.1:8000",
|
||||
"": "http://127.0.0.1:8080",
|
||||
"garbage": "http://127.0.0.1:8080",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := loopbackBase(in); got != want {
|
||||
t.Errorf("loopbackBase(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessages(t *testing.T) {
|
||||
// string input → single user message
|
||||
got := buildMessages("hello")
|
||||
if len(got) != 1 || got[0]["role"] != "user" || got[0]["content"] != "hello" {
|
||||
t.Fatalf("string input: got %+v", got)
|
||||
}
|
||||
|
||||
// object with messages → passthrough
|
||||
in := map[string]any{"messages": []any{
|
||||
map[string]any{"role": "system", "content": "s"},
|
||||
map[string]any{"role": "user", "content": "u"},
|
||||
}}
|
||||
got = buildMessages(in)
|
||||
if len(got) != 2 || got[0]["role"] != "system" || got[1]["content"] != "u" {
|
||||
t.Fatalf("messages passthrough: got %+v", got)
|
||||
}
|
||||
|
||||
// object without messages → stringified single user message
|
||||
got = buildMessages(map[string]any{"prompt": "p"})
|
||||
if len(got) != 1 || got[0]["role"] != "user" || !strings.Contains(got[0]["content"].(string), "prompt") {
|
||||
t.Fatalf("object fallback: got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJudge(t *testing.T) {
|
||||
t.Run("clean json", func(t *testing.T) {
|
||||
v, r, err := parseJudge(`{"score": 0.8, "reasoning": "good"}`)
|
||||
if err != nil || v != 0.8 || r != "good" {
|
||||
t.Fatalf("got %v %q %v", v, r, err)
|
||||
}
|
||||
})
|
||||
t.Run("json embedded in prose", func(t *testing.T) {
|
||||
v, r, err := parseJudge("Here is my verdict: {\"score\": 1, \"reasoning\": \"x\"} done")
|
||||
if err != nil || v != 1 || r != "x" {
|
||||
t.Fatalf("got %v %q %v", v, r, err)
|
||||
}
|
||||
})
|
||||
t.Run("bare float", func(t *testing.T) {
|
||||
v, _, err := parseJudge("0.5")
|
||||
if err != nil || v != 0.5 {
|
||||
t.Fatalf("got %v %v", v, err)
|
||||
}
|
||||
})
|
||||
t.Run("clamped above 1", func(t *testing.T) {
|
||||
v, _, err := parseJudge(`{"score": 1.7}`)
|
||||
if err != nil || v != 1 {
|
||||
t.Fatalf("got %v %v", v, err)
|
||||
}
|
||||
})
|
||||
t.Run("clamped below 0", func(t *testing.T) {
|
||||
v, _, err := parseJudge(`{"score": -3}`)
|
||||
if err != nil || v != 0 {
|
||||
t.Fatalf("got %v %v", v, err)
|
||||
}
|
||||
})
|
||||
t.Run("unparseable is an error, not a fake score", func(t *testing.T) {
|
||||
if _, _, err := parseJudge("the model refused"); err == nil {
|
||||
t.Fatal("expected error for unparseable judge reply")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeJudge(t *testing.T) {
|
||||
// nil → defaults to the model under test + default name/criteria
|
||||
d := normalizeJudge(nil, "gpt-4o-mini")
|
||||
if d.Model != "gpt-4o-mini" || d.Name != "llm-judge" || d.Criteria == "" {
|
||||
t.Fatalf("defaults: %+v", d)
|
||||
}
|
||||
// overrides win, model falls back to under-test when blank
|
||||
o := normalizeJudge(&judgeSpec{Name: "acc", Criteria: "match exactly"}, "m")
|
||||
if o.Model != "m" || o.Name != "acc" || o.Criteria != "match exactly" {
|
||||
t.Fatalf("overrides: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAndClampAndAsText(t *testing.T) {
|
||||
if got := basic("pk", "sk"); got != base64.StdEncoding.EncodeToString([]byte("pk:sk")) {
|
||||
t.Fatalf("basic = %q", got)
|
||||
}
|
||||
if clamp01(0.3) != 0.3 || clamp01(-1) != 0 || clamp01(9) != 1 {
|
||||
t.Fatal("clamp01 wrong")
|
||||
}
|
||||
if asText("s") != "s" {
|
||||
t.Fatal("asText string")
|
||||
}
|
||||
if asText(map[string]any{"a": 1}) != `{"a":1}` {
|
||||
t.Fatalf("asText json = %q", asText(map[string]any{"a": 1}))
|
||||
}
|
||||
if asText(nil) != "" {
|
||||
t.Fatal("asText nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDv4Shape(t *testing.T) {
|
||||
id := uuidv4()
|
||||
if len(id) != 36 || strings.Count(id, "-") != 4 {
|
||||
t.Fatalf("uuid shape: %q", id)
|
||||
}
|
||||
if id[14] != '4' { // version nibble
|
||||
t.Fatalf("uuid version: %q", id)
|
||||
}
|
||||
if id == uuidv4() {
|
||||
t.Fatal("uuid not unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKeysFailsClosed(t *testing.T) {
|
||||
// No env keys, no KMS → precise, config-naming error (never a silent pass).
|
||||
s := &service{cfg: config{}}
|
||||
_, _, err := s.resolveKeys(context.Background(), "acme")
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed error with no keys")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CONSOLE_PUBLIC_KEY") || !strings.Contains(err.Error(), "console-pk-acme") {
|
||||
t.Fatalf("error must name the missing config: %v", err)
|
||||
}
|
||||
|
||||
// Global env pair present → returned.
|
||||
s = &service{cfg: config{publicKey: "pk", secretKey: "sk"}}
|
||||
pk, sk, err := s.resolveKeys(context.Background(), "acme")
|
||||
if err != nil || pk != "pk" || sk != "sk" {
|
||||
t.Fatalf("global keys: %q %q %v", pk, sk, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package execsvc exposes the Code Interpreter ("Run Code") surface on the
|
||||
// unified cloud-api /v1 plane, per HIP-0106.
|
||||
//
|
||||
// hanzo.chat (LibreChat fork) drives its execute_code agent tool against a
|
||||
// code-interpreter API whose contract is fixed by the upstream client
|
||||
// (@librechat/agents CodeExecutor): it POSTs {lang, code, files?} to
|
||||
// `${LIBRECHAT_CODE_BASEURL}/exec` with header `X-API-Key`, and uses the sibling
|
||||
// paths /exec/programmatic, /upload, /download/{id}, /files/{sid}. The response
|
||||
// is {session_id, stdout, stderr, files:[{name}]}. cloud-api is the single edge
|
||||
// that owns api.hanzo.ai/v1, so this subsystem mounts those paths and forwards
|
||||
// each request UNCHANGED to a sandboxed executor upstream. No code runs here —
|
||||
// this is a reverse proxy identical in shape to clients/o11y, so there is zero
|
||||
// request/response drift from the contract.
|
||||
//
|
||||
// SANDBOX: the upstream MUST be an isolated executor (Hanzo Runtime / a
|
||||
// per-call container sandbox). This binary NEVER shells out; there is no
|
||||
// os/exec anywhere in this package. Point CODE_EXEC_UPSTREAM at the sandbox
|
||||
// service's in-cluster DNS. The executor is the isolation boundary; cloud only
|
||||
// adds auth + the unified surface.
|
||||
//
|
||||
// AUTH: the gateway (order 80) bypasses these paths (the credential is an opaque
|
||||
// service key on X-API-Key, not a JWT), so this subsystem enforces the key
|
||||
// itself with a constant-time compare against CODE_EXEC_API_KEY (KMS-sourced,
|
||||
// synced into the pod env). Endpoints are never open: an unset key fails closed.
|
||||
package exec
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// defaultUpstream is the in-cluster address of the sandboxed code executor.
|
||||
// Overridable via CODE_EXEC_UPSTREAM. It must speak the LibreChat
|
||||
// code-interpreter contract (/exec, /files/{sid}, /upload, /download/{id}).
|
||||
const defaultUpstream = "http://code-exec.hanzo.svc.cluster.local:8000"
|
||||
|
||||
// prefixes are the code-interpreter path surfaces this subsystem owns on /v1.
|
||||
// Each is forwarded verbatim to the executor (no path rewrite: the executor
|
||||
// serves the same /exec, /upload, … paths the LibreChat client expects).
|
||||
var prefixes = []string{
|
||||
"/v1/exec", // covers /v1/exec and /v1/exec/programmatic
|
||||
"/v1/upload", // multipart file upload into a session
|
||||
"/v1/download", // /v1/download/{id}
|
||||
"/v1/files", // /v1/files/{session_id}
|
||||
}
|
||||
|
||||
func upstream() string {
|
||||
if v := strings.TrimSpace(os.Getenv("CODE_EXEC_UPSTREAM")); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultUpstream
|
||||
}
|
||||
|
||||
// apiKey is the shared service key the chat server presents on X-API-Key. It is
|
||||
// KMS-sourced and synced into the pod env as CODE_EXEC_API_KEY (mirrors the
|
||||
// per-key secretKeyRef pattern of every other cloud subsystem).
|
||||
func apiKey() string { return strings.TrimSpace(os.Getenv("CODE_EXEC_API_KEY")) }
|
||||
|
||||
// newProxy builds the reverse proxy to the executor as a plain http.Handler
|
||||
// (wrapped for zip via AdaptNetHTTP at mount). Pure (URL in, handler out) so it
|
||||
// is unit-testable without a live upstream. The path is preserved verbatim;
|
||||
// only scheme/host are rewritten to the upstream, and the upstream vhost is set
|
||||
// so it is not addressed as api.hanzo.ai.
|
||||
func newProxy(rawURL string) (http.Handler, error) {
|
||||
target, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target.Scheme == "" || target.Host == "" {
|
||||
return nil, fmt.Errorf("execsvc: CODE_EXEC_UPSTREAM must be an absolute URL, got %q", rawURL)
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
base := proxy.Director
|
||||
proxy.Director = func(r *http.Request) {
|
||||
base(r) // sets scheme/host to target; joins paths
|
||||
r.Host = target.Host // upstream vhost, not api.hanzo.ai
|
||||
}
|
||||
// Code execution can be slow (installs, compute) but must not hang a worker
|
||||
// forever; bound the wait on the executor's response headers.
|
||||
proxy.Transport = &http.Transport{
|
||||
ResponseHeaderTimeout: 120 * time.Second,
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// guard wraps an http.Handler with the constant-time X-API-Key check. Unset key
|
||||
// ⇒ 503 (fail closed, not open); wrong key ⇒ 401. Errors are emitted in the
|
||||
// same {status,error} JSON shape zip uses so the surface is uniform.
|
||||
func guard(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
want := apiKey()
|
||||
if want == "" {
|
||||
writeErr(w, http.StatusServiceUnavailable, "code execution not configured")
|
||||
return
|
||||
}
|
||||
got := strings.TrimSpace(r.Header.Get("X-API-Key"))
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
// Minimal hand-rolled JSON to avoid a dependency; msg is a fixed literal.
|
||||
_, _ = fmt.Fprintf(w, `{"status":%d,"error":%q}`, status, msg)
|
||||
}
|
||||
|
||||
// Mount registers the code-interpreter surface on app. The gateway terminates
|
||||
// user auth for the chat UI, but code exec is called server-side by the chat
|
||||
// node process with the shared service key, so we enforce that key here.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("execsvc.Mount: nil zip.App")
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
return fmt.Errorf("execsvc.Mount: nil deps.Logger")
|
||||
}
|
||||
logger = logger.New("subsystem", "exec")
|
||||
|
||||
proxy, err := newProxy(upstream())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h := zip.AdaptNetHTTP(guard(proxy))
|
||||
|
||||
// Own each prefix for every method (POST /exec, POST /upload, GET
|
||||
// /download/{id}, GET /files/{sid}). Registered before ai (order 150), so
|
||||
// these specific paths win over ai's bare /v1/* glob.
|
||||
for _, p := range prefixes {
|
||||
app.All(p, h) // exact match, e.g. /v1/exec, /v1/upload
|
||||
app.All(p+"/*", h) // subpaths, e.g. /v1/exec/programmatic, /v1/files/{sid}
|
||||
}
|
||||
|
||||
logger.Info("code interpreter surface mounted (reverse proxy)",
|
||||
"upstream", upstream(), "prefixes", strings.Join(prefixes, ","))
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Order 140: before hanzoai/ai (150) so the specific /v1/exec, /v1/upload,
|
||||
// /v1/download, /v1/files paths take precedence over ai's /v1/* catch-all.
|
||||
cloud.Register("exec", 140, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("execsvc.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v3"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// Mount() must register the overlapping static + wildcard routes (/v1/exec and
|
||||
// /v1/exec/*) on a real Fiber router WITHOUT panicking, and a request routed
|
||||
// through the whole app must reach the guarded proxy. This catches
|
||||
// route-registration errors the direct-handler tests can't.
|
||||
func TestMountRoutesThroughRouter(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"session_id":"s","stdout":"ok\n","stderr":"","files":[]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
t.Setenv("CODE_EXEC_UPSTREAM", up.URL)
|
||||
t.Setenv("CODE_EXEC_API_KEY", "k")
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
fa := app.Fiber()
|
||||
|
||||
// Exact prefix, a wildcard subpath, and a file path all route to the proxy.
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{http.MethodPost, "/v1/exec"},
|
||||
{http.MethodPost, "/v1/exec/programmatic"},
|
||||
{http.MethodGet, "/v1/files/sess-1"},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, "http://api.hanzo.ai"+tc.path,
|
||||
strings.NewReader(`{"lang":"py","code":"x=1"}`))
|
||||
req.Header.Set("X-API-Key", "k")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s %s: status %d, want 200 (routed to proxy)", tc.method, tc.path, resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// And the guard still fires through the router: wrong key ⇒ 401.
|
||||
req := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai/v1/exec", nil)
|
||||
req.Header.Set("X-API-Key", "wrong")
|
||||
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("guarded route: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong-key through router: status %d, want 401", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// Mount validates its inputs.
|
||||
func TestMountRejectsBadInputs(t *testing.T) {
|
||||
if err := Mount(nil, cloud.Deps{Logger: luxlog.New("test")}); err == nil {
|
||||
t.Fatal("Mount(nil app) should error")
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{}); err == nil {
|
||||
t.Fatal("Mount(nil logger) should error")
|
||||
}
|
||||
}
|
||||
|
||||
// The proxy must forward the request path + body verbatim to the sandboxed
|
||||
// executor and return its response unchanged — the behavior that makes cloud a
|
||||
// transparent edge in front of the sandbox, with no contract drift.
|
||||
func TestProxyForwardsVerbatim(t *testing.T) {
|
||||
var gotPath, gotHost, gotBody, gotKey string
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotHost = r.Host
|
||||
gotKey = r.Header.Get("X-API-Key")
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"session_id":"s1","stdout":"hi\n","stderr":"","files":[]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
proxy, err := newProxy(up.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("newProxy: %v", err)
|
||||
}
|
||||
t.Setenv("CODE_EXEC_API_KEY", "secret-key")
|
||||
h := guard(proxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai/v1/exec",
|
||||
strings.NewReader(`{"lang":"py","code":"print('hi')"}`))
|
||||
req.Header.Set("X-API-Key", "secret-key")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s, want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gotPath != "/v1/exec" {
|
||||
t.Fatalf("upstream path = %q, want /v1/exec (verbatim, no rewrite)", gotPath)
|
||||
}
|
||||
if gotBody != `{"lang":"py","code":"print('hi')"}` {
|
||||
t.Fatalf("upstream body = %q, want the request body verbatim", gotBody)
|
||||
}
|
||||
if gotKey != "secret-key" {
|
||||
t.Fatalf("upstream X-API-Key = %q, want it forwarded", gotKey)
|
||||
}
|
||||
if gotHost == "api.hanzo.ai" {
|
||||
t.Fatalf("upstream Host = %q, want the executor vhost (not the edge host)", gotHost)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"stdout":"hi\n"`) {
|
||||
t.Fatalf("response not passed through: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A programmatic-tool-calling subpath (/v1/exec/programmatic) and file paths
|
||||
// must also be forwarded verbatim.
|
||||
func TestProxyForwardsSubpaths(t *testing.T) {
|
||||
var gotPath string
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
_, _ = io.WriteString(w, `[]`)
|
||||
}))
|
||||
defer up.Close()
|
||||
proxy, err := newProxy(up.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("newProxy: %v", err)
|
||||
}
|
||||
t.Setenv("CODE_EXEC_API_KEY", "k")
|
||||
h := guard(proxy)
|
||||
|
||||
for _, p := range []string{"/v1/exec/programmatic", "/v1/files/sess-123", "/v1/download/abc"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://api.hanzo.ai"+p, nil)
|
||||
req.Header.Set("X-API-Key", "k")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: status = %d, want 200", p, rec.Code)
|
||||
}
|
||||
if gotPath != p {
|
||||
t.Fatalf("%s: upstream path = %q, want verbatim", p, gotPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fail closed: with no configured key the surface returns 503, never proxies.
|
||||
func TestGuardUnsetKeyFailsClosed(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("upstream must NOT be reached when key is unset")
|
||||
}))
|
||||
defer up.Close()
|
||||
proxy, _ := newProxy(up.URL)
|
||||
t.Setenv("CODE_EXEC_API_KEY", "")
|
||||
h := guard(proxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai/v1/exec", nil)
|
||||
req.Header.Set("X-API-Key", "anything")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 (fail closed on unset key)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrong key ⇒ 401, upstream never reached.
|
||||
func TestGuardWrongKeyRejected(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("upstream must NOT be reached with a wrong key")
|
||||
}))
|
||||
defer up.Close()
|
||||
proxy, _ := newProxy(up.URL)
|
||||
t.Setenv("CODE_EXEC_API_KEY", "right")
|
||||
h := guard(proxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai/v1/exec", nil)
|
||||
req.Header.Set("X-API-Key", "wrong")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (wrong key)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProxyRejectsBadURL(t *testing.T) {
|
||||
if _, err := newProxy("://nope"); err == nil {
|
||||
t.Fatal("expected error for malformed upstream URL")
|
||||
}
|
||||
if _, err := newProxy("/relative/only"); err == nil {
|
||||
t.Fatal("expected error for non-absolute upstream URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamDefaultAndOverride(t *testing.T) {
|
||||
t.Setenv("CODE_EXEC_UPSTREAM", "")
|
||||
if got := upstream(); got != defaultUpstream {
|
||||
t.Fatalf("upstream() = %q, want default %q", got, defaultUpstream)
|
||||
}
|
||||
t.Setenv("CODE_EXEC_UPSTREAM", "http://sandbox:8000")
|
||||
if got := upstream(); got != "http://sandbox:8000" {
|
||||
t.Fatalf("upstream() = %q, want override", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Package gojahost runs a Hanzo Node service's goja bundle (a self-contained,
|
||||
// ESM-free JS file exposing globalThis.handle(req)) inside the unified cloud
|
||||
// binary, per HIP-0106.
|
||||
//
|
||||
// It is the SHARED glue used by clients/plan and clients/pricing to host
|
||||
// @hanzo/plans and @hanzo/pricing in-process via dop251/goja — the same engine
|
||||
// base/plugins/gojavm uses. We do not import base's gojavm Runtime directly
|
||||
// because that loader is manifest-driven (extension.json + a single exported
|
||||
// `fn` over JSON-over-the-wire payloads); our services instead inject a catalog
|
||||
// of JSON globals at VM init and call a richer handle({route,params,...}) entry.
|
||||
// The VM-pool + compile-once + per-runtime ensureLoaded discipline here mirrors
|
||||
// gojavm/runtime.go exactly so behavior and the pool semantics are identical.
|
||||
//
|
||||
// Module boundary: the JS bundle + catalog data live in the service repos
|
||||
// (hanzoai/plans, hanzoai/pricing) and are passed in by the caller. This
|
||||
// package carries zero service logic — only the engine plumbing.
|
||||
package gojahost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// defaultPoolSize mirrors gojavm's default. Override via CLOUD_GOJAHOST_POOL_SIZE.
|
||||
const defaultPoolSize = 8
|
||||
|
||||
// Request is the dispatch envelope handed to globalThis.handle in JS.
|
||||
type Request struct {
|
||||
Route string `json:"route"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
Query map[string]string `json:"query,omitempty"`
|
||||
Tenant string `json:"tenant,omitempty"`
|
||||
}
|
||||
|
||||
// Response is what globalThis.handle returns: an HTTP status + an opaque body
|
||||
// that the host serializes straight to JSON.
|
||||
type Response struct {
|
||||
Status int `json:"status"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
// Host is a compiled service bundle plus a pool of goja runtimes that have had
|
||||
// the bundle + the injected globals evaluated. Safe for concurrent use.
|
||||
type Host struct {
|
||||
name string
|
||||
program *goja.Program
|
||||
globals map[string]any
|
||||
pool []*slot
|
||||
factory func() *goja.Runtime
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
type slot struct {
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
vm *goja.Runtime
|
||||
loaded bool
|
||||
hasFunc bool
|
||||
}
|
||||
|
||||
// Config configures a Host.
|
||||
type Config struct {
|
||||
// Name identifies the service for error messages ("plans", "pricing").
|
||||
Name string
|
||||
// Bundle is the goja bundle source (goja/bundle.js from the service repo).
|
||||
Bundle []byte
|
||||
// Globals are injected onto each runtime before the bundle runs, e.g.
|
||||
// {"__PLANS_DATA__": <catalog>}. Values are converted via goja.ToValue.
|
||||
// Pointers to the same Go value are shared read-only across runtimes; the
|
||||
// bundles never mutate injected globals.
|
||||
Globals map[string]any
|
||||
}
|
||||
|
||||
// New compiles the bundle and pre-warms the runtime pool. The bundle is
|
||||
// compiled once (goja.Program is safe to share across runtimes); each pool
|
||||
// runtime evaluates it lazily on first use.
|
||||
func New(cfg Config) (*Host, error) {
|
||||
if cfg.Name == "" {
|
||||
return nil, errors.New("gojahost: Config.Name required")
|
||||
}
|
||||
if len(cfg.Bundle) == 0 {
|
||||
return nil, fmt.Errorf("gojahost[%s]: empty bundle", cfg.Name)
|
||||
}
|
||||
prog, err := goja.Compile(cfg.Name+"/bundle.js", string(cfg.Bundle), true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gojahost[%s]: compile: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
size := defaultPoolSize
|
||||
if v := os.Getenv("CLOUD_GOJAHOST_POOL_SIZE"); v != "" {
|
||||
if n, e := strconv.Atoi(v); e == nil {
|
||||
size = n
|
||||
}
|
||||
}
|
||||
if size < 1 {
|
||||
size = 1
|
||||
}
|
||||
|
||||
h := &Host{
|
||||
name: cfg.Name,
|
||||
program: prog,
|
||||
globals: cfg.Globals,
|
||||
factory: func() *goja.Runtime { return goja.New() },
|
||||
pool: make([]*slot, size),
|
||||
}
|
||||
for i := range h.pool {
|
||||
h.pool[i] = &slot{vm: h.factory()}
|
||||
}
|
||||
|
||||
// Eagerly load + validate one runtime so misconfiguration (bad bundle,
|
||||
// missing handle export) fails at Mount, not at first request.
|
||||
if err := h.withSlot(func(s *slot) error {
|
||||
if err := h.ensure(s); err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.hasFunc {
|
||||
return fmt.Errorf("gojahost[%s]: bundle does not define globalThis.handle", cfg.Name)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// ensure evaluates the bundle on a runtime exactly once (installing the
|
||||
// injected globals first), then records whether globalThis.handle exists.
|
||||
func (h *Host) ensure(s *slot) error {
|
||||
if s.loaded {
|
||||
return nil
|
||||
}
|
||||
for k, v := range h.globals {
|
||||
if err := s.vm.Set(k, v); err != nil {
|
||||
return fmt.Errorf("gojahost[%s]: set global %s: %w", h.name, k, err)
|
||||
}
|
||||
}
|
||||
// Minimal node-ish shims the bundles may touch. The bundles are written
|
||||
// to avoid console, but defensively wire a no-op console so a stray
|
||||
// console.* never throws ReferenceError.
|
||||
installConsole(s.vm)
|
||||
|
||||
if _, err := s.vm.RunProgram(h.program); err != nil {
|
||||
return fmt.Errorf("gojahost[%s]: run bundle: %w", h.name, err)
|
||||
}
|
||||
_, s.hasFunc = goja.AssertFunction(s.vm.Get("handle"))
|
||||
s.loaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dispatch calls globalThis.handle(req) on a pooled runtime and returns the
|
||||
// JS-side {status, body}. ctx cancellation interrupts the call.
|
||||
func (h *Host) Dispatch(ctx context.Context, req Request) (*Response, error) {
|
||||
h.mu.Lock()
|
||||
if h.closed {
|
||||
h.mu.Unlock()
|
||||
return nil, fmt.Errorf("gojahost[%s]: closed", h.name)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp *Response
|
||||
err := h.withSlot(func(s *slot) error {
|
||||
if err := h.ensure(s); err != nil {
|
||||
return err
|
||||
}
|
||||
fn, ok := goja.AssertFunction(s.vm.Get("handle"))
|
||||
if !ok {
|
||||
return fmt.Errorf("gojahost[%s]: globalThis.handle missing", h.name)
|
||||
}
|
||||
|
||||
// Watchdog: interrupt the VM if ctx cancels mid-call.
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.vm.Interrupt(ctx.Err())
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
|
||||
arg := s.vm.ToValue(map[string]any{
|
||||
"route": req.Route,
|
||||
"params": toAnyMap(req.Params),
|
||||
"query": toAnyMap(req.Query),
|
||||
"tenant": req.Tenant,
|
||||
})
|
||||
out, callErr := fn(goja.Undefined(), arg)
|
||||
if callErr != nil {
|
||||
var iex *goja.InterruptedError
|
||||
if errors.As(callErr, &iex) {
|
||||
if ce := ctx.Err(); ce != nil {
|
||||
return ce
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("gojahost[%s]: handle(%s): %w", h.name, req.Route, callErr)
|
||||
}
|
||||
|
||||
// The JS returns {status:number, body:any}. Pull them out and
|
||||
// re-marshal body to canonical JSON bytes via the export view.
|
||||
exported := out.Export()
|
||||
m, ok := exported.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("gojahost[%s]: handle(%s) returned %T, want object", h.name, req.Route, exported)
|
||||
}
|
||||
status := 200
|
||||
if sv, ok := m["status"]; ok {
|
||||
if f, ok := sv.(int64); ok {
|
||||
status = int(f)
|
||||
} else if f, ok := sv.(float64); ok {
|
||||
status = int(f)
|
||||
}
|
||||
}
|
||||
bodyBytes, mErr := json.Marshal(m["body"])
|
||||
if mErr != nil {
|
||||
return fmt.Errorf("gojahost[%s]: marshal body: %w", h.name, mErr)
|
||||
}
|
||||
resp = &Response{Status: status, Body: bodyBytes}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Eval runs an arbitrary JS expression against a pooled runtime (bundle
|
||||
// already loaded) and returns the exported Go value. Used by callers that
|
||||
// want to invoke a non-route helper the bundle exposes (e.g. applyMarkup).
|
||||
func (h *Host) Eval(ctx context.Context, fnName string, jsonArg []byte) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []byte
|
||||
err := h.withSlot(func(s *slot) error {
|
||||
if err := h.ensure(s); err != nil {
|
||||
return err
|
||||
}
|
||||
fn, ok := goja.AssertFunction(s.vm.Get(fnName))
|
||||
if !ok {
|
||||
return fmt.Errorf("gojahost[%s]: globalThis.%s is not a function", h.name, fnName)
|
||||
}
|
||||
var arg any
|
||||
if len(jsonArg) > 0 {
|
||||
if err := json.Unmarshal(jsonArg, &arg); err != nil {
|
||||
return fmt.Errorf("gojahost[%s]: %s arg not JSON: %w", h.name, fnName, err)
|
||||
}
|
||||
}
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.vm.Interrupt(ctx.Err())
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
res, callErr := fn(goja.Undefined(), s.vm.ToValue(arg))
|
||||
if callErr != nil {
|
||||
var iex *goja.InterruptedError
|
||||
if errors.As(callErr, &iex) {
|
||||
if ce := ctx.Err(); ce != nil {
|
||||
return ce
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("gojahost[%s]: %s: %w", h.name, fnName, callErr)
|
||||
}
|
||||
b, mErr := json.Marshal(res.Export())
|
||||
if mErr != nil {
|
||||
return fmt.Errorf("gojahost[%s]: marshal %s result: %w", h.name, fnName, mErr)
|
||||
}
|
||||
out = b
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SetGlobal updates an injected global and forces every pooled runtime to
|
||||
// re-evaluate the bundle on next use (so the new value takes effect). Used by
|
||||
// the pricing sync path to swap in freshly-synced data.
|
||||
func (h *Host) SetGlobal(key string, value any) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.globals == nil {
|
||||
h.globals = map[string]any{}
|
||||
}
|
||||
h.globals[key] = value
|
||||
for _, s := range h.pool {
|
||||
s.mu.Lock()
|
||||
s.loaded = false // re-run bundle with new globals on next ensure
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Close drops the pool.
|
||||
func (h *Host) Close() error {
|
||||
h.mu.Lock()
|
||||
h.closed = true
|
||||
h.pool = nil
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// withSlot borrows a free pool slot; if all are busy it spins up a one-off
|
||||
// runtime (matching gojavm's saturation fallback).
|
||||
func (h *Host) withSlot(call func(*slot) error) error {
|
||||
h.mu.Lock()
|
||||
pool := h.pool
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, s := range pool {
|
||||
s.mu.Lock()
|
||||
if s.busy {
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
s.busy = true
|
||||
s.mu.Unlock()
|
||||
|
||||
err := call(s)
|
||||
|
||||
s.mu.Lock()
|
||||
s.busy = false
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
// Saturated: ephemeral runtime, fully loaded fresh.
|
||||
tmp := &slot{vm: h.factory()}
|
||||
return call(tmp)
|
||||
}
|
||||
|
||||
func toAnyMap(m map[string]string) map[string]any {
|
||||
if m == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installConsole wires a no-op console.{log,info,warn,error,debug} so guest
|
||||
// code that logs does not throw. goja has no console by default.
|
||||
func installConsole(vm *goja.Runtime) {
|
||||
if !goja.IsUndefined(vm.Get("console")) {
|
||||
return
|
||||
}
|
||||
noop := func(goja.FunctionCall) goja.Value { return goja.Undefined() }
|
||||
console := vm.NewObject()
|
||||
for _, m := range []string{"log", "info", "warn", "error", "debug", "trace"} {
|
||||
_ = console.Set(m, noop)
|
||||
}
|
||||
_ = vm.Set("console", console)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package gojahost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const echoBundle = `
|
||||
(function(){
|
||||
globalThis.handle = function(req){
|
||||
if (req.route === 'boom') throw new Error('kaboom');
|
||||
if (req.route === 'notfound') return { status: 404, body: { error: 'nope' } };
|
||||
return { status: 200, body: { route: req.route, tenant: req.tenant, params: req.params, data: globalThis.__X__ } };
|
||||
};
|
||||
globalThis.dbl = function(n){ return n * 2; };
|
||||
})();
|
||||
`
|
||||
|
||||
func newTestHost(t *testing.T) *Host {
|
||||
t.Helper()
|
||||
h, err := New(Config{
|
||||
Name: "test",
|
||||
Bundle: []byte(echoBundle),
|
||||
Globals: map[string]any{"__X__": map[string]any{"k": "v"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestDispatch_OK(t *testing.T) {
|
||||
h := newTestHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), Request{Route: "ping", Tenant: "acme", Params: map[string]string{"id": "7"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if resp.Status != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.Status)
|
||||
}
|
||||
var body struct {
|
||||
Route string `json:"route"`
|
||||
Tenant string `json:"tenant"`
|
||||
Params map[string]string `json:"params"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &body); err != nil {
|
||||
t.Fatalf("unmarshal %q: %v", resp.Body, err)
|
||||
}
|
||||
if body.Route != "ping" || body.Tenant != "acme" || body.Params["id"] != "7" || body.Data["k"] != "v" {
|
||||
t.Fatalf("unexpected body: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_Status404(t *testing.T) {
|
||||
h := newTestHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), Request{Route: "notfound"})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if resp.Status != 404 {
|
||||
t.Fatalf("status = %d, want 404", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_JSThrowIsError(t *testing.T) {
|
||||
h := newTestHost(t)
|
||||
defer h.Close()
|
||||
if _, err := h.Dispatch(context.Background(), Request{Route: "boom"}); err == nil {
|
||||
t.Fatal("expected error from thrown JS exception")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_RejectsBundleWithoutHandle(t *testing.T) {
|
||||
_, err := New(Config{Name: "x", Bundle: []byte(`globalThis.notHandle = 1;`)})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when bundle has no globalThis.handle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEval_Helper(t *testing.T) {
|
||||
h := newTestHost(t)
|
||||
defer h.Close()
|
||||
out, err := h.Eval(context.Background(), "dbl", []byte(`21`))
|
||||
if err != nil {
|
||||
t.Fatalf("Eval: %v", err)
|
||||
}
|
||||
if string(out) != "42" {
|
||||
t.Fatalf("Eval dbl(21) = %s, want 42", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGlobal_TakesEffect(t *testing.T) {
|
||||
h := newTestHost(t)
|
||||
defer h.Close()
|
||||
h.SetGlobal("__X__", map[string]any{"k": "updated"})
|
||||
resp, err := h.Dispatch(context.Background(), Request{Route: "ping"})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(resp.Body, &body)
|
||||
if body.Data["k"] != "updated" {
|
||||
t.Fatalf("global not updated: %+v", body.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_ContextCancel(t *testing.T) {
|
||||
h, err := New(Config{
|
||||
Name: "loop",
|
||||
Bundle: []byte(`globalThis.handle = function(){ var x=0; while(true){ x=(x+1)|0; Math.sin(x); } };`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer h.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if _, err := h.Dispatch(ctx, Request{Route: "x"}); err == nil {
|
||||
t.Fatal("expected ctx error from infinite loop")
|
||||
}
|
||||
if time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("interrupt too slow: %v", time.Since(start))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// InProcess wraps a co-resident subsystem implementation as the
|
||||
// canonical cloud.<Subsystem>Client. The wrapper is a typed
|
||||
// pass-through: calls hit the in-process implementation directly via
|
||||
// Go method dispatch with zero marshalling and zero network hops.
|
||||
//
|
||||
// This is the inter-subsystem default when subsystems mount on the
|
||||
// same zip.App (the common HIP-0106 case). Subsystems that pass their
|
||||
// own concrete *T to BuildDeps get that *T back through the typed
|
||||
// interface — no per-subsystem glue per the "one way" rule.
|
||||
//
|
||||
// Per-subsystem constructors (one per cloud client interface):
|
||||
//
|
||||
// IAMInProcess(impl types.IAMClient) types.IAMClient
|
||||
// KMSInProcess(impl types.KMSClient) types.KMSClient
|
||||
// ... etc.
|
||||
//
|
||||
// They are intentionally trivial; the value of the wrapper is the
|
||||
// type-system enforcement that "in-process" and "RPC" satisfy the
|
||||
// same interface — subsystem code never branches on the mode.
|
||||
|
||||
// IAMInProcess wraps a co-resident IAM implementation. Subsystems
|
||||
// call deps.IAM.VerifyJWT(...) etc. without knowing whether IAM is
|
||||
// in-process or remote.
|
||||
func IAMInProcess(impl types.IAMClient) types.IAMClient { return impl }
|
||||
|
||||
// KMSInProcess wraps a co-resident KMS implementation.
|
||||
func KMSInProcess(impl types.KMSClient) types.KMSClient { return impl }
|
||||
|
||||
// BaseInProcess wraps a co-resident Base implementation.
|
||||
func BaseInProcess(impl types.BaseClient) types.BaseClient { return impl }
|
||||
|
||||
// CommerceInProcess wraps a co-resident Commerce implementation.
|
||||
func CommerceInProcess(impl types.CommerceClient) types.CommerceClient { return impl }
|
||||
|
||||
// AIInProcess wraps a co-resident AI implementation.
|
||||
func AIInProcess(impl types.AIClient) types.AIClient { return impl }
|
||||
|
||||
// O11yInProcess wraps a co-resident O11y implementation.
|
||||
func O11yInProcess(impl types.O11yClient) types.O11yClient { return impl }
|
||||
|
||||
// VFSInProcess wraps a co-resident VFS implementation.
|
||||
func VFSInProcess(impl types.VFSClient) types.VFSClient { return impl }
|
||||
|
||||
// MQInProcess wraps a co-resident MQ implementation.
|
||||
func MQInProcess(impl types.MQClient) types.MQClient { return impl }
|
||||
|
||||
// Payments is NEVER in-process — see PaymentsRPCAt for the only
|
||||
// allowed wiring. Vault is the same. The interfaces exist on
|
||||
// cloud.Deps so subsystems can call them, but the underlying client
|
||||
// always reaches the split-deployed PCI process via ZAP RPC.
|
||||
@@ -0,0 +1,340 @@
|
||||
// Package kms is the Fiber-facing subsystem that exposes the embedded luxfi/kms
|
||||
// secrets-manager as /v1/kms/* on the unified Hanzo Cloud binary (HIP-0106).
|
||||
//
|
||||
// It re-declares luxfi/kms's REST surface (cmd/kms is package main with no
|
||||
// mountable handler) on cloud's Fiber app, backed by the SAME embedded
|
||||
// SecretStore the in-process cloud.KMSClient uses (clients/kmsembed.Client,
|
||||
// built in build.go and handed through deps.KMS), and gated by cloud's ONE auth
|
||||
// boundary (SanitizeIdentity → c.Org()/c.IsAdmin()) — never a parallel JWT stack.
|
||||
//
|
||||
// GET /v1/kms/health — real probe (503 in health-only mode); public
|
||||
// GET /v1/kms/config — SPA runtime config; public
|
||||
// GET /v1/kms/orgs/:org/secrets — list a path's secret metadata; JWT, org-scoped
|
||||
// GET /v1/kms/orgs/:org/secrets/* — read one secret value; JWT, org-scoped
|
||||
// POST /v1/kms/orgs/:org/secrets — upsert a secret (sealed); JWT, org-scoped
|
||||
// DELETE /v1/kms/orgs/:org/secrets/* — delete a secret; JWT, org-scoped
|
||||
//
|
||||
// ORG SCOPING — {org} must equal the caller's validated org (c.Org()); a global
|
||||
// admin (c.IsAdmin()) may act on any org. The org is folded into the store PATH
|
||||
// as /orgs/{org}{subpath}, so one org can never address another org's records.
|
||||
// This mirrors clients/paassvc and clients/admin.
|
||||
package kms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/kmsembed"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// svc holds the embedded KMS client the routes serve from. A nil client means
|
||||
// KMS is not co-resident in this process (secrets served out-of-process or
|
||||
// disabled); the subsystem then mounts only the honest fail-closed health/config
|
||||
// so the binary never pretends to host secrets it cannot.
|
||||
type svc struct {
|
||||
kms *kmsembed.Client
|
||||
}
|
||||
|
||||
// Mount wires /v1/kms/* onto app.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("kms.Mount: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("kms.Mount: nil deps.Logger")
|
||||
}
|
||||
log := deps.Logger.New("subsystem", "kms")
|
||||
|
||||
// deps.KMS is the in-process kmsembed.Client (build.go pickKMSClient) when kms
|
||||
// is co-resident. Anything else (RPC/disabled stub) means secrets are served
|
||||
// elsewhere, so the REST surface mounts health/config only.
|
||||
kc, _ := deps.KMS.(*kmsembed.Client)
|
||||
s := &svc{kms: kc}
|
||||
|
||||
app.Get("/v1/kms/health", s.health)
|
||||
app.Get("/v1/kms/config", configHandler(deps))
|
||||
|
||||
if kc == nil {
|
||||
log.Warn("kms REST mounted health-only: no in-process KMS client (secrets served out-of-process or disabled)")
|
||||
return nil
|
||||
}
|
||||
|
||||
app.Get("/v1/kms/orgs/:org/secrets", s.guard(s.listSecrets))
|
||||
app.Get("/v1/kms/orgs/:org/secrets/*", s.guard(s.getSecret))
|
||||
app.Post("/v1/kms/orgs/:org/secrets", s.guard(s.putSecret))
|
||||
app.Delete("/v1/kms/orgs/:org/secrets/*", s.guard(s.deleteSecret))
|
||||
|
||||
log.Info("kms subsystem mounted",
|
||||
"prefix", "/v1/kms",
|
||||
"ready", kc.Ready(),
|
||||
"signing", kc.SigningConfigured(),
|
||||
"brand", deps.Brand,
|
||||
"env", deps.Env,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// init registers the subsystem. Registered as "kmssvc" (not "kms") for the same
|
||||
// reason clients/paassvc uses "paassvc": serve.go auto-mounts a generic
|
||||
// GET /v1/<name>/health BEFORE MountAll and zip is first-match-wins, so a name of
|
||||
// "kms" would shadow the real fail-closed /v1/kms/health with a fake 200. The
|
||||
// name "kmssvc" parks the generic liveness at the unrouted /v1/kmssvc/health and
|
||||
// lets the real probe own /v1/kms/health. The /v1/kms API prefix is independent
|
||||
// of the internal subsystem name (paassvc → /v1/paas). Enable with
|
||||
// --enable=kmssvc, or leave --enable empty for the default all-on bundle.
|
||||
//
|
||||
// Order 10 is KMS's reserved slot: it mounts before every dependent subsystem so
|
||||
// deps.KMS is a live in-process client by the time authz/commerce/ai mount.
|
||||
func init() {
|
||||
cloud.Register("kmssvc", 10, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("kms.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
|
||||
// guard wraps a secrets handler with the org-scope gate. Fail-closed: a request
|
||||
// whose validated org is neither {org} nor a global admin is refused 403 before
|
||||
// the store is touched; an unconfigured master key yields 503.
|
||||
//
|
||||
// The org match is EXACT (==), not case-folded: this mirrors the platform's own
|
||||
// tenant boundary (SanitizeIdentity gates admin on `owner == adminOrg`, and
|
||||
// X-Org-Id is the raw owner claim), and it keeps the authz check and the store
|
||||
// path in lockstep — orgPath folds :org into /orgs/{org} verbatim, so a
|
||||
// case-insensitive authz check would let org "Acme" reach org "acme"'s namespace.
|
||||
func (s *svc) guard(h zip.Handler) zip.Handler {
|
||||
return func(ctx *zip.Ctx) error {
|
||||
org := reqOrg(ctx)
|
||||
if !validOrg(org) {
|
||||
return zip.ErrBadRequest("org must be a DNS-1123 label")
|
||||
}
|
||||
if !ctx.IsAdmin() && ctx.Org() != org {
|
||||
return zip.ErrForbidden("caller may only access its own org's secrets")
|
||||
}
|
||||
if !s.kms.Ready() {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "%s", kmsembed.ErrMasterKeyMissing.Error())
|
||||
}
|
||||
return h(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ── health + config ────────────────────────────────────────────────────────────
|
||||
|
||||
// health is a REAL probe: 200 only when the store is open AND a master key is
|
||||
// configured; 503 + the honest reason in health-only mode. Not JWT-gated —
|
||||
// liveness must be probe-able by the platform without a token.
|
||||
func (s *svc) health(ctx *zip.Ctx) error {
|
||||
res := map[string]any{"service": "kms", "status": "ok"}
|
||||
if s.kms == nil {
|
||||
res["status"], res["ready"] = "degraded", false
|
||||
res["error"] = "no in-process KMS client (secrets served out-of-process or disabled)"
|
||||
return ctx.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
res["signing"] = s.kms.SigningConfigured()
|
||||
if !s.kms.Ready() {
|
||||
res["status"], res["ready"] = "degraded", false
|
||||
res["error"] = kmsembed.ErrMasterKeyMissing.Error()
|
||||
return ctx.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
res["ready"] = true
|
||||
return ctx.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// configHandler serves the KMS console SPA's runtime config (the OIDC issuer the
|
||||
// console logs in against + the KMS API base). Kept under the /v1/kms namespace
|
||||
// (not /v1/admin) so a gateway that admin-gates the /v1/admin/* prefix cannot
|
||||
// block the console's legitimate public config fetch. No secrets, so it is public.
|
||||
func configHandler(deps cloud.Deps) zip.Handler {
|
||||
issuer := strings.TrimRight(strings.TrimSpace(deps.IAMIssuer), "/")
|
||||
return func(ctx *zip.Ctx) error {
|
||||
return ctx.JSON(http.StatusOK, map[string]any{
|
||||
"brand": deps.Brand,
|
||||
"issuer": issuer,
|
||||
"apiBase": "/v1/kms",
|
||||
"loginPath": "/v1/kms/auth/login",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── secrets CRUD (org-scoped, sealed) ──────────────────────────────────────────
|
||||
|
||||
// secretPutRequest is the POST body: the secret to upsert. env defaults to
|
||||
// "default"; path is optional (relative to the org root); name is required.
|
||||
type secretPutRequest struct {
|
||||
Path string `json:"path"` // optional subpath under the org, e.g. "/ci"
|
||||
Name string `json:"name"` // required
|
||||
Env string `json:"env"` // optional, default "default"
|
||||
Value string `json:"value"` // required; sealed before storage
|
||||
}
|
||||
|
||||
// listSecrets returns the metadata (no ciphertext) of the org's secrets at a
|
||||
// path/env. ?path= narrows to a subpath; ?env= selects the environment.
|
||||
func (s *svc) listSecrets(ctx *zip.Ctx) error {
|
||||
org := reqOrg(ctx)
|
||||
env := envOr(ctx.Query("env"))
|
||||
if !validEnv(env) {
|
||||
return zip.ErrBadRequest("'env' must not contain '/', control characters, or exceed 63 bytes")
|
||||
}
|
||||
sub := ctx.Query("path")
|
||||
if !kmsembed.ValidSubpath(sub) {
|
||||
return zip.ErrBadRequest("'path' must be '/'-separated non-empty segments without '.', '..', or control characters")
|
||||
}
|
||||
metas, err := s.kms.List(orgPath(org, sub), env)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "%v", err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, map[string]any{"secrets": metas, "total": len(metas)})
|
||||
}
|
||||
|
||||
// getSecret reads one secret value. The trailing wildcard is the sub-path + name
|
||||
// under the org; ?env= selects the environment. Returns the opened plaintext.
|
||||
func (s *svc) getSecret(ctx *zip.Ctx) error {
|
||||
org := reqOrg(ctx)
|
||||
env := envOr(ctx.Query("env"))
|
||||
if !validEnv(env) {
|
||||
return zip.ErrBadRequest("'env' must not contain '/', control characters, or exceed 63 bytes")
|
||||
}
|
||||
path, name, ok := targetOf(org, reqWildcard(ctx))
|
||||
if !ok {
|
||||
return zip.ErrBadRequest("secret name is required and must be a clean '/'-separated path")
|
||||
}
|
||||
val, err := s.kms.Get(path, name, env)
|
||||
if err != nil {
|
||||
if errors.Is(err, kmsembed.ErrSecretNotFound) {
|
||||
return zip.ErrNotFound("secret not found")
|
||||
}
|
||||
return zip.Errorf(http.StatusBadGateway, "%v", err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, map[string]any{"name": name, "env": env, "value": string(val)})
|
||||
}
|
||||
|
||||
// putSecret seals + upserts a secret. Body: {path?, name, env?, value}. The value
|
||||
// is sealed under a fresh per-secret DEK (master-key-wrapped) before storage —
|
||||
// plaintext never touches disk.
|
||||
func (s *svc) putSecret(ctx *zip.Ctx) error {
|
||||
org := reqOrg(ctx)
|
||||
var req secretPutRequest
|
||||
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
||||
return zip.Errorf(http.StatusBadRequest, "invalid JSON body: %v", err)
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if !validName(name) {
|
||||
return zip.ErrBadRequest("'name' is required and must not contain '/', control characters, or exceed 253 bytes")
|
||||
}
|
||||
if req.Value == "" {
|
||||
return zip.ErrBadRequest("'value' is required")
|
||||
}
|
||||
env := envOr(req.Env)
|
||||
if !validEnv(env) {
|
||||
return zip.ErrBadRequest("'env' must not contain '/', control characters, or exceed 63 bytes")
|
||||
}
|
||||
if !kmsembed.ValidSubpath(req.Path) {
|
||||
return zip.ErrBadRequest("'path' must be '/'-separated non-empty segments without '.', '..', or control characters")
|
||||
}
|
||||
path := orgPath(org, req.Path)
|
||||
if err := s.kms.Put(path, name, env, []byte(req.Value)); err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "%v", err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, map[string]any{"stored": true, "name": name, "env": env})
|
||||
}
|
||||
|
||||
// deleteSecret removes one secret. The trailing wildcard is the sub-path + name.
|
||||
func (s *svc) deleteSecret(ctx *zip.Ctx) error {
|
||||
org := reqOrg(ctx)
|
||||
env := envOr(ctx.Query("env"))
|
||||
if !validEnv(env) {
|
||||
return zip.ErrBadRequest("'env' must not contain '/', control characters, or exceed 63 bytes")
|
||||
}
|
||||
path, name, ok := targetOf(org, reqWildcard(ctx))
|
||||
if !ok {
|
||||
return zip.ErrBadRequest("secret name is required and must be a clean '/'-separated path")
|
||||
}
|
||||
if err := s.kms.Delete(path, name, env); err != nil {
|
||||
if errors.Is(err, kmsembed.ErrSecretNotFound) {
|
||||
return zip.ErrNotFound("secret not found")
|
||||
}
|
||||
return zip.Errorf(http.StatusBadGateway, "%v", err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, map[string]any{"deleted": true, "name": name, "env": env})
|
||||
}
|
||||
|
||||
// ── path helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func reqOrg(ctx *zip.Ctx) string { return strings.TrimSpace(ctx.Param("org")) }
|
||||
|
||||
// reqWildcard returns the trailing "*" segment of a /secrets/* route, trimmed of
|
||||
// surrounding slashes. This is the secret's sub-path + name under the org.
|
||||
func reqWildcard(ctx *zip.Ctx) string {
|
||||
return strings.Trim(strings.TrimSpace(ctx.Param("*")), "/")
|
||||
}
|
||||
|
||||
// validOrg accepts a DNS-1123-ish label. It is the tenant-isolation boundary
|
||||
// folded into the store path, so it is validated strictly at the edge.
|
||||
func validOrg(org string) bool {
|
||||
if org == "" || len(org) > 63 {
|
||||
return false
|
||||
}
|
||||
for _, r := range org {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// The key-shape validators live in ONE place — clients/kmsembed — so the HTTP
|
||||
// boundary and the in-process store methods enforce identically (DRY). The
|
||||
// subsystem reuses kmsembed.ValidSegment / ValidSubpath here to return a specific
|
||||
// 400 early, before the request reaches the store.
|
||||
func validName(s string) bool { return kmsembed.ValidSegment(s, kmsembed.MaxNameLen) }
|
||||
func validEnv(s string) bool { return kmsembed.ValidSegment(s, kmsembed.MaxEnvLen) }
|
||||
|
||||
// orgPath folds an org + an optional relative subpath into the store path,
|
||||
// namespacing every org under /orgs/{org}. "" subpath → /orgs/{org}.
|
||||
func orgPath(org, sub string) string {
|
||||
base := "/orgs/" + org
|
||||
sub = strings.Trim(strings.TrimSpace(sub), "/")
|
||||
if sub == "" {
|
||||
return base
|
||||
}
|
||||
return base + "/" + sub
|
||||
}
|
||||
|
||||
// targetOf splits a /secrets/* wildcard (sub-path + name) into the validated
|
||||
// store (path, name): the last segment is the name, the rest is the sub-path
|
||||
// folded under the org. "DB" → (/orgs/{org}, DB); "ci/DB" → (/orgs/{org}/ci, DB).
|
||||
// Returns ok=false when the name or sub-path fails the boundary validators, so
|
||||
// the caller can reject the request rather than key a malformed record.
|
||||
func targetOf(org, sub string) (path, name string, ok bool) {
|
||||
var subpath string
|
||||
if slash := strings.LastIndex(sub, "/"); slash >= 0 {
|
||||
subpath, name = sub[:slash], sub[slash+1:]
|
||||
} else {
|
||||
name = sub
|
||||
}
|
||||
if !validName(name) || !kmsembed.ValidSubpath(subpath) {
|
||||
return "", "", false
|
||||
}
|
||||
return orgPath(org, subpath), name, true
|
||||
}
|
||||
|
||||
// envOr returns env or the "default" environment when empty.
|
||||
func envOr(env string) string {
|
||||
if e := strings.TrimSpace(env); e != "" {
|
||||
return e
|
||||
}
|
||||
return defaultEnv
|
||||
}
|
||||
|
||||
// defaultEnv is the secret environment used when a request omits ?env=, matching
|
||||
// luxfi/kms's REST default.
|
||||
const defaultEnv = "default"
|
||||
@@ -0,0 +1,306 @@
|
||||
package kms_test
|
||||
|
||||
// Integration tests for the embedded KMS subsystem, exercised through the REAL
|
||||
// orchestrator path (BuildDeps → the init()-registered MountSpec → the zip/Fiber
|
||||
// stack), mirroring cmd/cloud/main_test.go. Requests run in-process via
|
||||
// app.Fiber().Test — no listener, no external KMS, no PostgreSQL.
|
||||
//
|
||||
// SanitizeIdentity does not run in this harness (it is wired in serve.go, not
|
||||
// MountAll), so a test simulates a validated principal by setting the same
|
||||
// identity headers SanitizeIdentity would emit: X-Org-Id and X-User-IsAdmin. In
|
||||
// production those are stripped from client input and re-issued only for a
|
||||
// JWT-validated principal, so the org-scope gate is real; here we drive it
|
||||
// directly.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/kmsembed"
|
||||
"github.com/hanzoai/zip"
|
||||
"github.com/hanzoai/zip/middleware"
|
||||
|
||||
// Register the kms subsystem (init) into cloud.Registry.
|
||||
_ "github.com/hanzoai/cloud/clients/kms"
|
||||
)
|
||||
|
||||
// masterKeyB64 returns a fresh random 32-byte master key, base64-encoded as the
|
||||
// operator would inject it via CLOUD_KMS_MASTER_KEY_REF.
|
||||
func masterKeyB64(t *testing.T) string {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
if _, err := rand.Read(k); err != nil {
|
||||
t.Fatalf("rand key: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
// newApp wires BuildDeps + the canonical middleware + MountAll for the kmssvc
|
||||
// subsystem, exactly like main()'s path. Returns the app and the built deps (so
|
||||
// tests can reach the in-process KMSClient directly).
|
||||
func newApp(t *testing.T, cfg *cloud.Config) (*zip.App, cloud.Deps) {
|
||||
t.Helper()
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
app := zip.New(zip.Config{Logger: deps.Logger})
|
||||
app.Use(middleware.Recover())
|
||||
app.Use(middleware.RequestID())
|
||||
app.Use(middleware.Logger(deps.Logger))
|
||||
if err := cloud.MountAll(app, cfg, deps); err != nil {
|
||||
t.Fatalf("MountAll: %v", err)
|
||||
}
|
||||
return app, deps
|
||||
}
|
||||
|
||||
func baseCfg(t *testing.T, masterKey string) *cloud.Config {
|
||||
t.Helper()
|
||||
return &cloud.Config{
|
||||
Brand: "hanzo",
|
||||
Domain: "api.hanzo.ai",
|
||||
IAMIssuer: "https://hanzo.id",
|
||||
DataDir: t.TempDir(),
|
||||
Enable: []string{"kmssvc"},
|
||||
KMSMasterKeyRef: masterKey,
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthReadyWithMasterKey: with a master key configured, /v1/kms/health is
|
||||
// 200 and reports ready. This is the real probe, not the generic liveness route.
|
||||
func TestHealthReadyWithMasterKey(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
resp := do(t, app, "GET", "/v1/kms/health", "", "", false, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /v1/kms/health = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body := decode(t, resp.Body)
|
||||
if body["ready"] != true {
|
||||
t.Errorf("health ready=%v, want true", body["ready"])
|
||||
}
|
||||
if body["signing"] != false {
|
||||
t.Errorf("health signing=%v, want false (no MPC configured)", body["signing"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthFailClosedWithoutMasterKey: absent a master key the subsystem still
|
||||
// mounts, but /v1/kms/health reports 503 (health-only mode) — never a silent
|
||||
// insecure 200.
|
||||
func TestHealthFailClosedWithoutMasterKey(t *testing.T) {
|
||||
cfg := baseCfg(t, "") // no master key
|
||||
app, _ := newApp(t, cfg)
|
||||
resp := do(t, app, "GET", "/v1/kms/health", "", "", false, nil)
|
||||
if resp.StatusCode != 503 {
|
||||
t.Fatalf("GET /v1/kms/health (no key) = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
body := decode(t, resp.Body)
|
||||
if body["ready"] != false {
|
||||
t.Errorf("health ready=%v, want false", body["ready"])
|
||||
}
|
||||
if s, _ := body["error"].(string); !strings.Contains(s, "master key not configured") {
|
||||
t.Errorf("health error=%q, want it to name the missing master key", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKMSClientRoundtrip: a PutSecret→GetSecret roundtrip through the in-process
|
||||
// cloud.KMSClient (deps.KMS) against a temp CLOUD_DATA_DIR.
|
||||
func TestKMSClientRoundtrip(t *testing.T) {
|
||||
cfg := baseCfg(t, masterKeyB64(t))
|
||||
_, deps := newApp(t, cfg)
|
||||
|
||||
kc := deps.KMS
|
||||
if kc == nil {
|
||||
t.Fatal("deps.KMS is nil; expected in-process client when kmssvc enabled")
|
||||
}
|
||||
if _, ok := kc.(*kmsembed.Client); !ok {
|
||||
t.Fatalf("deps.KMS is %T, want *kmsembed.Client (in-process)", kc)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
const ref = "console-pk-hanzo"
|
||||
want := []byte("sk-super-secret-value-42")
|
||||
|
||||
if err := kc.PutSecret(ctx, ref, want); err != nil {
|
||||
t.Fatalf("PutSecret: %v", err)
|
||||
}
|
||||
got, err := kc.GetSecret(ctx, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret: %v", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("GetSecret = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// A missing secret is a clean not-found, not a panic or a fabricated value.
|
||||
if _, err := kc.GetSecret(ctx, "does-not-exist"); !errors.Is(err, kmsembed.ErrSecretNotFound) {
|
||||
t.Errorf("GetSecret(missing) err = %v, want ErrSecretNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretNotStoredInPlaintext: the on-disk bytes must NOT contain the
|
||||
// plaintext — proof the AES-256-GCM Seal envelope is applied before storage
|
||||
// (never store secrets in plaintext).
|
||||
func TestSecretNotStoredInPlaintext(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &cloud.Config{
|
||||
Brand: "hanzo", Domain: "api.hanzo.ai", IAMIssuer: "https://hanzo.id",
|
||||
DataDir: dir, Enable: []string{"kmssvc"}, KMSMasterKeyRef: masterKeyB64(t),
|
||||
}
|
||||
_, deps := newApp(t, cfg)
|
||||
|
||||
marker := []byte("PLAINTEXT-MARKER-should-never-hit-disk")
|
||||
if err := deps.KMS.PutSecret(context.Background(), "svc/DB_URL@main", marker); err != nil {
|
||||
t.Fatalf("PutSecret: %v", err)
|
||||
}
|
||||
// Close so the KV flushes to disk, then scan the store files for the marker.
|
||||
if c, ok := deps.KMS.(*kmsembed.Client); ok {
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
found := false
|
||||
root := filepath.Join(dir, "kms")
|
||||
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if bytesContains(b, marker) {
|
||||
found = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk store: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("plaintext marker found on disk — secret was NOT sealed before storage")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignFailsClosedNoMPC: Sign must fail closed with a clear, non-fabricated
|
||||
// error when no MPC backend is configured. It must NEVER return a signature.
|
||||
func TestSignFailsClosedNoMPC(t *testing.T) {
|
||||
cfg := baseCfg(t, masterKeyB64(t)) // master key present, but no MPC
|
||||
_, deps := newApp(t, cfg)
|
||||
|
||||
sig, err := deps.KMS.Sign(context.Background(), "validator-1", []byte("payload"))
|
||||
if err == nil {
|
||||
t.Fatal("Sign returned nil error with no MPC configured — must fail closed")
|
||||
}
|
||||
if sig != nil {
|
||||
t.Fatalf("Sign returned a %d-byte signature with no MPC — must never fabricate", len(sig))
|
||||
}
|
||||
if !errors.Is(err, kmsembed.ErrSignUnavailable) {
|
||||
t.Errorf("Sign err = %v, want ErrSignUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESTRoundtripOrgScoped: the /v1/kms REST surface upserts + reads a secret
|
||||
// for the caller's own org (simulated validated principal), and rejects a
|
||||
// cross-org read with 403 — the org-isolation boundary.
|
||||
func TestRESTRoundtripOrgScoped(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
|
||||
// hanzo caller stores a secret in its own org.
|
||||
body, _ := json.Marshal(map[string]string{"name": "API_KEY", "value": "hk-abc123", "env": "main"})
|
||||
resp := do(t, app, "POST", "/v1/kms/orgs/hanzo/secrets", "hanzo", string(body), false, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("POST secret (own org) = %d, want 200: %s", resp.StatusCode, readAll(resp.Body))
|
||||
}
|
||||
|
||||
// Same caller reads it back.
|
||||
resp = do(t, app, "GET", "/v1/kms/orgs/hanzo/secrets/API_KEY?env=main", "hanzo", "", false, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET secret (own org) = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if v, _ := decode(t, resp.Body)["value"].(string); v != "hk-abc123" {
|
||||
t.Errorf("GET secret value = %q, want hk-abc123", v)
|
||||
}
|
||||
|
||||
// A different org (evil) may NOT read hanzo's secret.
|
||||
resp = do(t, app, "GET", "/v1/kms/orgs/hanzo/secrets/API_KEY?env=main", "evil", "", false, nil)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Errorf("cross-org GET = %d, want 403 (org isolation)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// A global admin may read any org.
|
||||
resp = do(t, app, "GET", "/v1/kms/orgs/hanzo/secrets/API_KEY?env=main", "admin", "", true, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("admin cross-org GET = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// An unauthenticated caller (no org, not admin) is refused.
|
||||
resp = do(t, app, "GET", "/v1/kms/orgs/hanzo/secrets/API_KEY?env=main", "", "", false, nil)
|
||||
if resp.StatusCode != 403 {
|
||||
t.Errorf("anonymous GET = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESTSecretOpsFailClosedWithoutKey: without a master key, an authorized
|
||||
// secret op is refused 503, never served insecurely.
|
||||
func TestRESTSecretOpsFailClosedWithoutKey(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, "")) // no key
|
||||
body, _ := json.Marshal(map[string]string{"name": "X", "value": "y"})
|
||||
resp := do(t, app, "POST", "/v1/kms/orgs/hanzo/secrets", "hanzo", string(body), false, nil)
|
||||
if resp.StatusCode != 503 {
|
||||
t.Fatalf("POST secret (no key) = %d, want 503 (fail-closed)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ── request helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// do drives an in-process request, setting the identity headers SanitizeIdentity
|
||||
// would set for a validated principal (org, isAdmin). An empty org + admin=false
|
||||
// simulates an unauthenticated caller.
|
||||
func do(t *testing.T, app *zip.App, method, path, org, body string, admin bool, _ any) *http.Response {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if body != "" {
|
||||
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 admin {
|
||||
req.Header.Set("X-User-IsAdmin", "true")
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func decode(t *testing.T, r io.Reader) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
b, _ := io.ReadAll(r)
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("decode json %q: %v", string(b), err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func readAll(r io.Reader) string { b, _ := io.ReadAll(r); return string(b) }
|
||||
|
||||
func bytesContains(haystack, needle []byte) bool {
|
||||
return strings.Contains(string(haystack), string(needle))
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package kms_test
|
||||
|
||||
// RED adversarial tests — attacking the embedded KMS org-scope + envelope.
|
||||
// These are PROOF-OF-BREACH probes; a PASS here means the attack was BLOCKED,
|
||||
// a FAIL (t.Fatal) means the breach is real. Each test documents the vector.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/kmsembed"
|
||||
kmsstore "github.com/luxfi/kms/pkg/store"
|
||||
)
|
||||
|
||||
// ── VECTOR 1: org case-fold collision (guard uses strings.EqualFold) ───────────
|
||||
//
|
||||
// The guard compares strings.EqualFold(ctx.Org(), :org). The store PATH is built
|
||||
// from the :org ROUTE PARAM, not ctx.Org(). So a caller whose validated org is
|
||||
// "Hanzo" (capital) may pass :org=hanzo (EqualFold true) and land on the SAME
|
||||
// store path /orgs/hanzo as a *different* tenant whose validated org is "hanzo".
|
||||
// If IAM issues case-distinct org owners, this is cross-tenant read/write.
|
||||
func TestVector1_OrgCaseFoldCollision(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
|
||||
// Tenant A: validated org "hanzo" writes a secret in its own org.
|
||||
body, _ := json.Marshal(map[string]string{"name": "STRIPE_KEY", "value": "sk-tenantA-owns-this", "env": "prod"})
|
||||
resp := do(t, app, "POST", "/v1/kms/orgs/hanzo/secrets", "hanzo", string(body), false, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("tenantA POST = %d, want 200: %s", resp.StatusCode, readAll(resp.Body))
|
||||
}
|
||||
|
||||
// Tenant B: DISTINCT validated org "Hanzo" (capital H) targets :org=hanzo.
|
||||
// Guard: EqualFold("Hanzo","hanzo") == true → PASSES.
|
||||
// Path built from :org param "hanzo" → /orgs/hanzo → tenantA's record.
|
||||
resp = do(t, app, "GET", "/v1/kms/orgs/hanzo/secrets/STRIPE_KEY?env=prod", "Hanzo", "", false, nil)
|
||||
if resp.StatusCode == 200 {
|
||||
v, _ := decode(t, resp.Body)["value"].(string)
|
||||
if v == "sk-tenantA-owns-this" {
|
||||
t.Fatalf("BREACH: tenant 'Hanzo' read tenant 'hanzo' secret via EqualFold guard: value=%q", v)
|
||||
}
|
||||
t.Fatalf("BREACH-ish: cross-case GET returned 200 (value=%q)", v)
|
||||
}
|
||||
t.Logf("cross-case GET blocked with status=%d (breach requires IAM to issue case-distinct owners)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestVector1b: the guard is EXACT-match (==), NOT EqualFold — so a tenant whose
|
||||
// validated owner is "AcmeCorp" is REFUSED on any casing-mismatched :org param
|
||||
// (403), because the store path keys on :org verbatim and a case-insensitive
|
||||
// authz check would let "Acme" reach "acme"'s namespace. Confirm the exact-match
|
||||
// closes the split-namespace hazard: a lowercased :org for a mixed-case owner is
|
||||
// denied outright (not silently split into a second bucket).
|
||||
func TestVector1b_ExactOrgMatchNoSplit(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
const owner = "AcmeCorp"
|
||||
|
||||
// Owner uses its exact casing — allowed.
|
||||
body, _ := json.Marshal(map[string]string{"name": "K", "value": "written-mixedcase", "env": "default"})
|
||||
if r := do(t, app, "POST", "/v1/kms/orgs/AcmeCorp/secrets", owner, string(body), false, nil); r.StatusCode != 200 {
|
||||
t.Fatalf("POST exact-case = %d, want 200", r.StatusCode)
|
||||
}
|
||||
// Same owner, lowercased :org — EXACT match fails → 403. No second bucket.
|
||||
r := do(t, app, "GET", "/v1/kms/orgs/acmecorp/secrets/K", owner, "", false, nil)
|
||||
if r.StatusCode != 403 {
|
||||
t.Fatalf("BREACH: lowercased :org for owner %q = %d, want 403 (exact-match guard)", owner, r.StatusCode)
|
||||
}
|
||||
r = do(t, app, "POST", "/v1/kms/orgs/acmecorp/secrets", owner, string(body), false, nil)
|
||||
if r.StatusCode != 403 {
|
||||
t.Fatalf("BREACH: owner %q could write a lowercased bucket = %d, want 403", owner, r.StatusCode)
|
||||
}
|
||||
t.Logf("exact-match guard: owner %q cannot touch /orgs/acmecorp (403) — no case-split namespace", owner)
|
||||
}
|
||||
|
||||
// ── VECTOR 2: AAD relocation — name-only DEK-wrap AAD ──────────────────────────
|
||||
//
|
||||
// store.Seal binds ciphertext AAD = path/name/env but wraps the DEK with AAD =
|
||||
// NAME ONLY. Open re-derives ciphertext AAD from the record's OWN Path/Name/Env
|
||||
// fields. So if an attacker can PLACE a record (with org A's ciphertext+wrappedDEK)
|
||||
// at org B's store key AND rewrite its Path/Env fields to B's, Open succeeds:
|
||||
// the DEK unwraps (name unchanged) and the ciphertext AAD matches the rewritten
|
||||
// path. This proves the envelope alone does NOT bind a secret to its org — only
|
||||
// the store-key namespacing + the HTTP guard do. We demonstrate at the store
|
||||
// layer (the trust the envelope is supposed to provide).
|
||||
func TestVector2_AADRelocationDirect(t *testing.T) {
|
||||
master := make([]byte, 32)
|
||||
for i := range master {
|
||||
master[i] = 0x42
|
||||
}
|
||||
// Org A seals a secret named "DB" with the SAME name org B would use.
|
||||
secA, err := kmsstore.Seal(master, "/orgs/tenantA", "DB", "prod", []byte("A-super-secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("seal A: %v", err)
|
||||
}
|
||||
// Attacker relocates: copy A's ciphertext + wrapped DEK into a record addressed
|
||||
// as org B, rewriting the AAD-bearing fields to B's coordinates.
|
||||
relocated := &kmsstore.Secret{
|
||||
Name: "DB", // unchanged — DEK-wrap AAD is name-only, so unwrap still works
|
||||
Path: "/orgs/tenantB",
|
||||
Env: "prod",
|
||||
Ciphertext: secA.Ciphertext,
|
||||
WrappedDEK: secA.WrappedDEK,
|
||||
Scheme: secA.Scheme,
|
||||
}
|
||||
pt, err := kmsstore.Open(master, relocated)
|
||||
if err != nil {
|
||||
t.Logf("relocation Open FAILED (envelope binds path): %v", err)
|
||||
return
|
||||
}
|
||||
// If we got here, the envelope did NOT prevent cross-path relocation.
|
||||
t.Fatalf("LATENT-BREACH: A's plaintext (%q) opened under a record relabeled to tenantB — "+
|
||||
"name-only DEK-wrap AAD lets a record be relocated across orgs if an attacker can write the store key",
|
||||
string(pt))
|
||||
}
|
||||
|
||||
// TestVector2b: does the HTTP API expose any write primitive that lets a caller
|
||||
// control the record's stored Path independently of the store KEY? If putSecret
|
||||
// ever wrote Path from the body while keying on the org, an attacker inside org B
|
||||
// could craft a record whose Path says org A. Prove the API does NOT (Put derives
|
||||
// both from the same org-folded path), so V2 is LATENT (needs raw store access),
|
||||
// not remotely exploitable via /v1/kms.
|
||||
func TestVector2b_NoAPIControlledPathSplit(t *testing.T) {
|
||||
app, deps := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
|
||||
// Attacker in org "b" tries to smuggle a Path field pointing at org "a".
|
||||
// secretPutRequest has Path — but putSecret folds it under orgPath(org, req.Path)
|
||||
// AND validSubpath rejects "..", so the climb is refused outright (400).
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "PWN",
|
||||
"value": "attacker-controlled",
|
||||
"env": "default",
|
||||
"path": "../a", // attempt to climb to another org — must be rejected
|
||||
})
|
||||
r := do(t, app, "POST", "/v1/kms/orgs/b/secrets", "b", string(body), false, nil)
|
||||
if r.StatusCode != 400 {
|
||||
t.Fatalf("path='../a' climb = %d, want 400 (validSubpath must reject '..'): %s", r.StatusCode, readAll(r.Body))
|
||||
}
|
||||
t.Logf("path traversal '../a' rejected with 400")
|
||||
// Where did it actually land? Check org "a" cannot see it, and the stored
|
||||
// record's Path is NOT /orgs/a.
|
||||
kc := deps.KMS.(*kmsembed.Client)
|
||||
// org a lists its root — must be empty of PWN.
|
||||
metas, err := kc.List("/orgs/a", "default")
|
||||
if err != nil {
|
||||
t.Fatalf("list a: %v", err)
|
||||
}
|
||||
for _, m := range metas {
|
||||
if m.Name == "PWN" {
|
||||
t.Fatalf("BREACH: attacker in org b planted PWN into org a's namespace (path=%s)", m.Path)
|
||||
}
|
||||
}
|
||||
t.Logf("API path-split blocked: %d records under /orgs/a (PWN not among them)", len(metas))
|
||||
}
|
||||
|
||||
// ── DEEP-A: store-key ↔ record-field divergence (name-only DEK-wrap AAD) ───────
|
||||
//
|
||||
// The store KEY is kms/secrets/{path}/{env}/{name} derived from the *query*
|
||||
// (path,name,env). The record's OWN Path/Name/Env JSON fields are what Open uses
|
||||
// to reconstruct the ciphertext AAD. store.Put keys on secret.Path/Name/Env, and
|
||||
// kmsembed always Seals with the SAME (path,name,env) it keys on — so key and
|
||||
// fields agree. The LATENT risk: the envelope alone does not bind a record to its
|
||||
// store key; if any future/rogue writer keys a record at path P' while its
|
||||
// self-described Path is P (P != P'), Open still succeeds (it trusts the record).
|
||||
// This proves the isolation rests ENTIRELY on kmsembed.Put keying == sealing, and
|
||||
// on the HTTP guard — NOT on cryptographic org-binding. Demonstrate the divergence
|
||||
// at the store layer (which the envelope is supposed to make safe).
|
||||
func TestDeepA_StoreKeyRecordDivergence(t *testing.T) {
|
||||
master := make([]byte, 32)
|
||||
for i := range master {
|
||||
master[i] = 0x11
|
||||
}
|
||||
// Seal a record whose self-described path is org A.
|
||||
sec, err := kmsstore.Seal(master, "/orgs/A", "TOKEN", "prod", []byte("A-only"))
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
// A rogue writer with raw store access Puts this record — store.Put keys on
|
||||
// sec.Path ("/orgs/A"), so it lands at A's key. But if the writer FIRST mutates
|
||||
// the key coordinates without touching the AAD fields, key and fields diverge.
|
||||
// We model the danger: Open trusts the record's fields, so a record physically
|
||||
// placed under B's key but carrying A's Path opens fine — B's LIST (which keys
|
||||
// on the store prefix /orgs/B) would surface it, and B's GET (key /orgs/B/...)
|
||||
// would 404 because the record was keyed under A. i.e. the KEY is the only
|
||||
// isolation; the crypto does not add a second, independent org check.
|
||||
pt, err := kmsstore.Open(master, sec) // opens because fields are self-consistent
|
||||
if err != nil {
|
||||
t.Fatalf("open self-consistent record: %v", err)
|
||||
}
|
||||
if string(pt) != "A-only" {
|
||||
t.Fatalf("open mismatch: %q", pt)
|
||||
}
|
||||
t.Logf("LATENT (defense-in-depth): envelope binds to the record's OWN fields, " +
|
||||
"not to the store key. Cross-org isolation = store-key namespacing + HTTP guard ONLY. " +
|
||||
"A raw-store writer that decouples key from fields is not caught by the crypto. " +
|
||||
"kmsembed.Put keeps them in lockstep, so this is NOT reachable via /v1/kms — but the " +
|
||||
"name-only DEK-wrap AAD means the DEK wrap itself provides ZERO path/env/org binding.")
|
||||
}
|
||||
|
||||
// TestDeepB: sibling-org prefix confusion in List. Org "x" listing must never
|
||||
// surface secrets of org "xy" / "x-attacker" via ZapDB prefix iteration, because
|
||||
// the list prefix kms/secrets//orgs/x/{env}/ is NOT a prefix of //orgs/xy/... .
|
||||
func TestDeepB_SiblingOrgListPrefix(t *testing.T) {
|
||||
app, deps := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
kc := deps.KMS.(*kmsembed.Client)
|
||||
|
||||
// Seed secrets in org "x", "xy", and "x-attacker".
|
||||
for _, org := range []string{"x", "xy", "x-attacker"} {
|
||||
body, _ := json.Marshal(map[string]string{"name": "S", "value": "v-" + org, "env": "default"})
|
||||
if r := do(t, app, "POST", "/v1/kms/orgs/"+org+"/secrets", org, string(body), false, nil); r.StatusCode != 200 {
|
||||
t.Fatalf("seed %s = %d", org, r.StatusCode)
|
||||
}
|
||||
}
|
||||
// Org x lists its root: must see EXACTLY its own one secret, not xy/x-attacker.
|
||||
metas, err := kc.List("/orgs/x", "default")
|
||||
if err != nil {
|
||||
t.Fatalf("list x: %v", err)
|
||||
}
|
||||
if len(metas) != 1 {
|
||||
for _, m := range metas {
|
||||
t.Logf(" leaked: name=%q path=%q", m.Name, m.Path)
|
||||
}
|
||||
t.Fatalf("PREFIX BREACH: org x list returned %d secrets, want 1 (sibling-org prefix leak)", len(metas))
|
||||
}
|
||||
if metas[0].Path != "/orgs/x" {
|
||||
t.Fatalf("org x saw a foreign path %q", metas[0].Path)
|
||||
}
|
||||
t.Logf("no sibling prefix leak: org x sees exactly its own secret")
|
||||
}
|
||||
|
||||
// TestDeepC: does the REST list endpoint honor the org guard for a sibling-prefix
|
||||
// attacker? Attacker org "x" tries to list victim "xy" by exploiting that "x" is
|
||||
// a string-prefix of "xy" — but the guard is EXACT (==), so :org=xy with org=x
|
||||
// caller is 403, and :org=x only lists /orgs/x.
|
||||
func TestDeepC_RESTListNoPrefixEscalation(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
// victim xy stores a secret.
|
||||
body, _ := json.Marshal(map[string]string{"name": "VICT", "value": "secret", "env": "default"})
|
||||
if r := do(t, app, "POST", "/v1/kms/orgs/xy/secrets", "xy", string(body), false, nil); r.StatusCode != 200 {
|
||||
t.Fatalf("seed = %d", r.StatusCode)
|
||||
}
|
||||
// attacker "x" tries to list xy → 403 (exact org mismatch).
|
||||
r := do(t, app, "GET", "/v1/kms/orgs/xy/secrets", "x", "", false, nil)
|
||||
if r.StatusCode != 403 {
|
||||
t.Fatalf("BREACH: prefix-attacker x listed xy = %d, want 403", r.StatusCode)
|
||||
}
|
||||
// attacker "x" lists its OWN org with a crafted ?path= trying to climb — validSubpath blocks "..".
|
||||
r = do(t, app, "GET", "/v1/kms/orgs/x/secrets?path=../xy", "x", "", false, nil)
|
||||
if r.StatusCode != 400 {
|
||||
t.Fatalf("BREACH: ?path=../xy climb = %d, want 400", r.StatusCode)
|
||||
}
|
||||
t.Logf("REST list: prefix-attacker blocked (403 cross-org, 400 on ?path climb)")
|
||||
}
|
||||
|
||||
// TestDeepD: legitimate same-org, same-name, DIFFERENT-path relocation. Because
|
||||
// ciphertext AAD includes path, a value sealed at /orgs/x/a cannot be Opened as
|
||||
// if it were at /orgs/x/b even for the SAME org — confirm the path binding holds
|
||||
// intra-org (so an admin/mis-key that moves a record breaks LOUDLY, not silently
|
||||
// returns wrong plaintext).
|
||||
func TestDeepD_IntraOrgPathBinding(t *testing.T) {
|
||||
master := make([]byte, 32)
|
||||
for i := range master {
|
||||
master[i] = 0x22
|
||||
}
|
||||
sec, _ := kmsstore.Seal(master, "/orgs/x/a", "K", "default", []byte("value-at-a"))
|
||||
// Relabel to /orgs/x/b (same org, same name, same env, different subpath).
|
||||
moved := &kmsstore.Secret{
|
||||
Name: "K", Path: "/orgs/x/b", Env: "default",
|
||||
Ciphertext: sec.Ciphertext, WrappedDEK: sec.WrappedDEK, Scheme: sec.Scheme,
|
||||
}
|
||||
if _, err := kmsstore.Open(master, moved); err == nil {
|
||||
t.Fatalf("LATENT-BREACH: value relocated a→b within org opened OK — ciphertext AAD did not bind path")
|
||||
} else {
|
||||
t.Logf("intra-org path binding holds: relocated record fails Open: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── VECTOR 4: enumeration oracle — 404 vs 403 vs 503 across orgs ───────────────
|
||||
//
|
||||
// Does the response code distinguish "secret exists in another org" from "does
|
||||
// not exist"? The guard 403s a cross-org caller BEFORE the store is touched, so
|
||||
// existence should be indistinguishable. Probe: cross-org GET of an existing vs
|
||||
// non-existing secret must return the SAME status (403), leaking nothing.
|
||||
func TestVector4_NoCrossOrgExistenceOracle(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
|
||||
// victim org stores a secret.
|
||||
body, _ := json.Marshal(map[string]string{"name": "REAL", "value": "v", "env": "default"})
|
||||
if r := do(t, app, "POST", "/v1/kms/orgs/victim/secrets", "victim", string(body), false, nil); r.StatusCode != 200 {
|
||||
t.Fatalf("seed = %d", r.StatusCode)
|
||||
}
|
||||
|
||||
// attacker org probes an EXISTING secret in victim's org.
|
||||
rExist := do(t, app, "GET", "/v1/kms/orgs/victim/secrets/REAL", "attacker", "", false, nil)
|
||||
// attacker org probes a NON-EXISTING secret in victim's org.
|
||||
rMiss := do(t, app, "GET", "/v1/kms/orgs/victim/secrets/NOPE", "attacker", "", false, nil)
|
||||
|
||||
if rExist.StatusCode != rMiss.StatusCode {
|
||||
t.Fatalf("EXISTENCE ORACLE: existing→%d vs missing→%d differ (attacker learns victim's keys)",
|
||||
rExist.StatusCode, rMiss.StatusCode)
|
||||
}
|
||||
if rExist.StatusCode != 403 {
|
||||
t.Errorf("cross-org probe status=%d, want 403 (touch store only after authz)", rExist.StatusCode)
|
||||
}
|
||||
t.Logf("no existence oracle: both cross-org probes = %d", rExist.StatusCode)
|
||||
}
|
||||
|
||||
// ── VECTOR 7: input validation at the boundary ─────────────────────────────────
|
||||
//
|
||||
// name containing '/' — putSecret folds PATH separately but does NOT reject a
|
||||
// name with '/'. A name "a/b" makes the store key kms/secrets/{path}/{env}/a/b.
|
||||
// Does that let name-embedded slashes collide a secret across path boundaries or
|
||||
// escape the org? Prove what actually happens.
|
||||
func TestVector7_NameWithSlashKeyShapeConfusion(t *testing.T) {
|
||||
app, deps := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
kc := deps.KMS.(*kmsembed.Client)
|
||||
|
||||
// Caller in org "x" PUTs a secret whose NAME contains a slash and env-like tail.
|
||||
body, _ := json.Marshal(map[string]string{"name": "sub/EVIL", "value": "slash-in-name", "env": "default"})
|
||||
r := do(t, app, "POST", "/v1/kms/orgs/x/secrets", "x", string(body), false, nil)
|
||||
if r.StatusCode != 200 {
|
||||
t.Logf("POST name-with-slash rejected: %d (%s) — good, name is validated", r.StatusCode, readAll(r.Body))
|
||||
return
|
||||
}
|
||||
t.Logf("POST name-with-slash ACCEPTED — key = kms/secrets//orgs/x/default/sub/EVIL")
|
||||
|
||||
// Now: a GET wildcard "sub/EVIL" splits into path=/orgs/x/sub, name=EVIL.
|
||||
// Does the PUT (name="sub/EVIL", path=/orgs/x) collide with a GET that thinks
|
||||
// path=/orgs/x/sub, name=EVIL? Both produce store key kms/secrets//orgs/x/sub/default/EVIL?
|
||||
// PUT key: kms/secrets/ /orgs/x /default/ sub/EVIL → "kms/secrets//orgs/x/default/sub/EVIL"
|
||||
// GET(sub/EVIL) key: path=/orgs/x/sub name=EVIL → "kms/secrets//orgs/x/sub/default/EVIL"
|
||||
// These DIFFER (env position differs), so no collision — but prove the retrieval story.
|
||||
rg := do(t, app, "GET", "/v1/kms/orgs/x/secrets/sub/EVIL?env=default", "x", "", false, nil)
|
||||
t.Logf("GET sub/EVIL → status=%d body=%s", rg.StatusCode, readAll(rg.Body))
|
||||
|
||||
// The record IS retrievable only via the exact (path,name) the store layer keyed.
|
||||
// List the org root and the /sub subpath to see where it actually lives.
|
||||
rootMetas, _ := kc.List("/orgs/x", "default")
|
||||
subMetas, _ := kc.List("/orgs/x/sub", "default")
|
||||
t.Logf("records at /orgs/x: %d ; at /orgs/x/sub: %d", len(rootMetas), len(subMetas))
|
||||
for _, m := range rootMetas {
|
||||
t.Logf(" /orgs/x has name=%q path=%q", m.Name, m.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVector7b: empty name / whitespace name / control chars.
|
||||
func TestVector7b_DegenerateNames(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
cases := []struct {
|
||||
name string
|
||||
want int // expected status
|
||||
note string
|
||||
}{
|
||||
{"", 400, "empty name rejected"},
|
||||
{" ", 400, "whitespace-only name trimmed to empty → rejected"},
|
||||
{"A\x00B", 200, "NUL in name — does it get stored? (key-shape/log-injection risk)"},
|
||||
{strings.Repeat("N", 100000), 200, "100KB name — DoS / key-size"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
body, _ := json.Marshal(map[string]string{"name": tc.name, "value": "v"})
|
||||
r := do(t, app, "POST", "/v1/kms/orgs/x/secrets", "x", string(body), false, nil)
|
||||
t.Logf("[%s] name=%.20q... → status=%d (expected ~%d)", tc.note, tc.name, r.StatusCode, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── VECTOR 5: master key never leaked in errors/responses ──────────────────────
|
||||
//
|
||||
// Force error paths (bad key length via env is impossible through the API, but a
|
||||
// corrupt-envelope Open error should NOT echo key bytes). Confirm the health +
|
||||
// config + error bodies never contain key material.
|
||||
func TestVector5_ConfigLeaksNothingSensitive(t *testing.T) {
|
||||
mk := masterKeyB64(t)
|
||||
app, _ := newApp(t, baseCfg(t, mk))
|
||||
r := do(t, app, "GET", "/v1/kms/config", "", "", false, nil)
|
||||
if r.StatusCode != 200 {
|
||||
t.Fatalf("config = %d", r.StatusCode)
|
||||
}
|
||||
body := readAll(r.Body)
|
||||
if strings.Contains(body, mk) || strings.Contains(body, mk[:16]) {
|
||||
t.Fatalf("BREACH: /v1/kms/config echoed master key material: %s", body)
|
||||
}
|
||||
// config is public + unauthenticated — confirm it only exposes brand/issuer.
|
||||
t.Logf("/v1/kms/config (public, no auth) body: %s", body)
|
||||
// Assert no obviously-sensitive keys present.
|
||||
for _, bad := range []string{"masterKey", "master_key", "secret", "dek", "wrapped"} {
|
||||
if strings.Contains(strings.ToLower(body), bad) {
|
||||
t.Errorf("config body contains suspicious token %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── VECTOR 3: /v1/kms/config public route — shadow / precedence ──────────────
|
||||
//
|
||||
// kms registers GET /v1/kms/config at order 10 (mounts FIRST), PUBLIC. Confirm
|
||||
// it does not get shadowed by, nor shadow, the admin subsystem's gated /v1/admin/*
|
||||
// routes. Probe an admin-gated route WITHOUT admin: it must still 403 (not fall
|
||||
// through to kms's public handler), and /v1/kms/config must be reachable
|
||||
// without auth.
|
||||
func TestVector3_AdminConfigPrecedence(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
|
||||
// public config reachable without any identity.
|
||||
r := do(t, app, "GET", "/v1/kms/config", "", "", false, nil)
|
||||
if r.StatusCode != 200 {
|
||||
t.Errorf("public /v1/kms/config = %d, want 200", r.StatusCode)
|
||||
}
|
||||
// an admin-gated sibling must NOT be shadowed into public by the order-10 mount.
|
||||
r = do(t, app, "GET", "/v1/admin/orgs", "", "", false, nil)
|
||||
if r.StatusCode == 200 {
|
||||
t.Fatalf("BREACH: /v1/admin/orgs returned 200 without admin — kms order-10 mount shadowed the gate?")
|
||||
}
|
||||
t.Logf("/v1/admin/orgs without admin = %d (gate intact)", r.StatusCode)
|
||||
}
|
||||
|
||||
// TestAdminEdge_ValidOrgStillEnforcedForAdmin: a global admin bypasses the
|
||||
// org-EQUALITY check but NOT validOrg — so an admin cannot address a malformed
|
||||
// :org (empty, oversized, or with forbidden chars). Confirms admin is scoped to
|
||||
// well-formed org labels only (the store path is still /orgs/{validOrg}).
|
||||
func TestAdminEdge_ValidOrgStillEnforcedForAdmin(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
|
||||
// admin with a forbidden-char :org → 400 (validOrg rejects before store touch).
|
||||
// Route needs a non-empty :org segment, so use an over-length label.
|
||||
longOrg := strings.Repeat("a", 64) // > 63 → validOrg false
|
||||
r := do(t, app, "GET", "/v1/kms/orgs/"+longOrg+"/secrets", "admin", "", true, nil)
|
||||
if r.StatusCode != 400 {
|
||||
t.Fatalf("admin over-length :org = %d, want 400 (validOrg enforced for admin too)", r.StatusCode)
|
||||
}
|
||||
// admin with a well-formed :org → allowed (reaches store; empty list is 200).
|
||||
r = do(t, app, "GET", "/v1/kms/orgs/anyorg/secrets", "admin", "", true, nil)
|
||||
if r.StatusCode != 200 {
|
||||
t.Fatalf("admin well-formed :org = %d, want 200", r.StatusCode)
|
||||
}
|
||||
t.Logf("admin scoped to validOrg labels: over-length→400, well-formed→200")
|
||||
}
|
||||
|
||||
// TestAdminEdge_NonAdminEmptyOrgDenied: a caller with NO validated org (anonymous
|
||||
// or a principal whose owner was empty) has ctx.Org()=="" and IsAdmin()==false,
|
||||
// so ctx.Org() != :org is always true → 403. Confirms an empty principal can
|
||||
// never match any org.
|
||||
func TestAdminEdge_NonAdminEmptyOrgDenied(t *testing.T) {
|
||||
app, _ := newApp(t, baseCfg(t, masterKeyB64(t)))
|
||||
// Non-admin, no org header at all.
|
||||
r := do(t, app, "GET", "/v1/kms/orgs/hanzo/secrets", "", "", false, nil)
|
||||
if r.StatusCode != 403 {
|
||||
t.Fatalf("empty-principal GET = %d, want 403", r.StatusCode)
|
||||
}
|
||||
// Even targeting an org whose name is empty-ish is rejected by routing/validOrg.
|
||||
t.Logf("empty principal denied (403) — cannot match any :org")
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package kms_test
|
||||
|
||||
// RED: dual-mount precedence. With BOTH kmssvc (order 10, mounts FIRST, registers
|
||||
// PUBLIC /v1/kms/config) AND admin (order 146, registers GATED /v1/admin/{orgs,
|
||||
// users,...}) enabled, prove kms's order-10 public config does NOT shadow the
|
||||
// admin gate, and admin's routes still require global-admin (403 without it).
|
||||
// This is the real production topology; the kmssvc-only harness cannot see it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
"github.com/hanzoai/zip/middleware"
|
||||
|
||||
// Pull in the full subsystem bundle so BOTH kmssvc (order 10) and admin
|
||||
// (order 146) init()-register — the real production topology.
|
||||
_ "github.com/hanzoai/cloud/subsystems"
|
||||
)
|
||||
|
||||
func newDualApp(t *testing.T, mk string) *zip.App {
|
||||
t.Helper()
|
||||
cfg := &cloud.Config{
|
||||
Brand: "hanzo", Domain: "api.hanzo.ai", IAMIssuer: "https://hanzo.id",
|
||||
DataDir: t.TempDir(),
|
||||
Enable: []string{"kmssvc", "admin"}, // both, so order 10 + 146 co-exist
|
||||
KMSMasterKeyRef: mk,
|
||||
}
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
app := zip.New(zip.Config{Logger: deps.Logger})
|
||||
app.Use(middleware.Recover())
|
||||
app.Use(middleware.RequestID())
|
||||
app.Use(middleware.Logger(deps.Logger))
|
||||
if err := cloud.MountAll(app, cfg, deps); err != nil {
|
||||
t.Fatalf("MountAll: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func TestDualMount_AdminConfigDoesNotShadowGate(t *testing.T) {
|
||||
mk := masterKeyB64(t)
|
||||
app := newDualApp(t, mk)
|
||||
|
||||
// 1. kms's public /v1/kms/config reachable WITHOUT any identity → 200,
|
||||
// and is the KMS config (proves order-10 kms won the exact path).
|
||||
r := do(t, app, "GET", "/v1/kms/config", "", "", false, nil)
|
||||
if r.StatusCode != 200 {
|
||||
t.Fatalf("/v1/kms/config = %d, want 200 (kms public config)", r.StatusCode)
|
||||
}
|
||||
body := decode(t, r.Body)
|
||||
if body["apiBase"] != "/v1/kms" {
|
||||
t.Fatalf("/v1/kms/config not served by kms? body=%v", body)
|
||||
}
|
||||
|
||||
// 2. admin's GATED siblings must 403 WITHOUT admin — NOT shadowed to public,
|
||||
// NOT 404 (they ARE mounted now).
|
||||
for _, path := range []string{"/v1/admin/orgs", "/v1/admin/users", "/v1/admin/me", "/v1/admin/audit"} {
|
||||
r := do(t, app, "GET", path, "hanzo", "", false, nil) // a normal (non-admin) principal
|
||||
if r.StatusCode != 403 {
|
||||
t.Fatalf("BREACH: %s without admin = %d, want 403 (gate must fire; kms must not shadow it)", path, r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. A crafted path that is NOT an exact admin route (e.g. /v1/kms/config/x)
|
||||
// must not fall through to kms's config handler.
|
||||
r = do(t, app, "GET", "/v1/kms/config/../orgs", "hanzo", "", false, nil)
|
||||
t.Logf("/v1/kms/config/../orgs → %d", r.StatusCode)
|
||||
|
||||
// 4. Anonymous probe of admin route leaks nothing beyond 403.
|
||||
r = do(t, app, "GET", "/v1/admin/orgs", "", "", false, nil)
|
||||
if r.StatusCode != 403 {
|
||||
t.Fatalf("anonymous /v1/admin/orgs = %d, want 403", r.StatusCode)
|
||||
}
|
||||
t.Logf("dual-mount OK: kms public /v1/kms/config (200) coexists with admin gate (403 without admin)")
|
||||
_ = json.Marshal
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// Package kms embeds luxfi/kms in-process inside the unified Hanzo Cloud binary
|
||||
// per HIP-0106 ("all Go embeds in cloud"), replacing the legacy Infisical fork.
|
||||
//
|
||||
// It has two faces, both backed by the SAME embedded luxfi/kms library:
|
||||
//
|
||||
// KMSClient — the in-process cloud.KMSClient (GetSecret/PutSecret/Sign) other
|
||||
// subsystems call via deps.KMS. No RPC, no external DB. Built once
|
||||
// in build.go's pickKMSClient and reused by Mount.
|
||||
// /v1/kms/* — the secrets-manager REST surface the KMS console (kms.hanzo.ai)
|
||||
// calls, mounted onto cloud's Fiber app: JWT-gated, org-scoped
|
||||
// secrets CRUD + a real health probe + the SPA admin config.
|
||||
//
|
||||
// STORAGE — luxfi/kms's SecretStore is an embedded ZapDB (github.com/luxfi/zapdb)
|
||||
// KV opened UNDER CLOUD_DATA_DIR/kms (the RWO PVC where per-tenant SQLite lives),
|
||||
// so there is no PostgreSQL and no external DB. cloud runs replicas=1/Recreate, so
|
||||
// the single-writer KV is safe. Secrets are sealed with AES-256-GCM envelope
|
||||
// encryption (store.Seal: a fresh per-secret DEK sealed under the 32-byte master
|
||||
// key) BEFORE they hit the store — plaintext never touches disk. The KV itself is
|
||||
// ALSO opened with ZapDB block-level encryption under the same key (defense in
|
||||
// depth). See New for the fail-secure open strategy across the health-only↔keyed
|
||||
// transition.
|
||||
//
|
||||
// BOOTSTRAP — cloud hosting the secret store is a chicken-and-egg: cloud cannot
|
||||
// fetch its OWN master key from the KMS it hosts. The 32-byte master key is
|
||||
// injected by the operator via a K8s Secret env (CLOUD_KMS_MASTER_KEY_REF,
|
||||
// base64 of 32 bytes) and read ONLY from env — never from the store, never
|
||||
// logged, never persisted in plaintext. When the master key is absent the
|
||||
// subsystem mounts in a fail-closed HEALTH-ONLY mode: /v1/kms/health reports 503,
|
||||
// every secret op returns a clear "master key not configured" error, and the
|
||||
// store is backed by an EPHEMERAL in-memory KV (never an unencrypted on-disk one),
|
||||
// so a later keyed boot opens a clean encrypted store rather than a bricked one.
|
||||
// Never a silent insecure default.
|
||||
//
|
||||
// SIGN — luxfi/kms's Sign is MPC-backed (threshold signing via the MPC daemon).
|
||||
// cloud does not co-host the MPC cluster; Sign therefore fails CLOSED with a
|
||||
// clear error whenever the MPC backend is not configured (CLOUD_KMS_MPC_ADDR /
|
||||
// CLOUD_KMS_MPC_VAULT_ID unset). A signature is NEVER fabricated.
|
||||
//
|
||||
// SECURITY — the REST surface (clients/kms) reuses cloud's ONE auth boundary
|
||||
// (SanitizeIdentity in serve.go establishes the validated principal; handlers
|
||||
// read c.Org()/c.IsAdmin()) rather than a parallel JWT stack. This package is the
|
||||
// cloud-free CLIENT core (the types.KMSClient impl + sealed store access); it
|
||||
// imports NO cloud package so build.go's BuildDeps can construct it without an
|
||||
// import cycle (cloud → clients/kmsembed → cloud/types only). The Fiber routes
|
||||
// that expose it live in the clients/kms subsystem, which imports this package.
|
||||
package kmsembed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/types"
|
||||
kmsstore "github.com/luxfi/kms/pkg/store"
|
||||
luxlog "github.com/luxfi/log"
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
// masterKeyLen is the AES-256 KEK size store.Seal/Open require (32 bytes).
|
||||
const masterKeyLen = 32
|
||||
|
||||
// defaultEnv is the secret environment slug used when a request omits ?env=,
|
||||
// matching luxfi/kms's REST default. Secrets are namespaced (path, name, env).
|
||||
const defaultEnv = "default"
|
||||
|
||||
// ErrMasterKeyMissing is the fail-closed error every secret op returns when no
|
||||
// master key is configured. It is honest (mirrors the DisabledKMS pattern): the
|
||||
// caller knows the operator must inject CLOUD_KMS_MASTER_KEY_REF.
|
||||
var ErrMasterKeyMissing = errors.New("kms: master key not configured (operator must inject CLOUD_KMS_MASTER_KEY_REF)")
|
||||
|
||||
// ErrSignUnavailable is the fail-closed error Sign returns when the MPC backend
|
||||
// is not configured. Signing is threshold-MPC-backed; cloud never fabricates a
|
||||
// signature.
|
||||
var ErrSignUnavailable = errors.New("kms: signing unavailable — MPC backend not configured (set CLOUD_KMS_MPC_ADDR and CLOUD_KMS_MPC_VAULT_ID)")
|
||||
|
||||
// ErrInvalidKey is returned when a secret coordinate (name/env) contains a byte
|
||||
// that would smuggle structure into the store key (a '/', NUL, or control char)
|
||||
// or is out of length bounds. Enforced in ONE place — the store-access methods —
|
||||
// so every entry point (the HTTP subsystem AND the in-process KMSClient facade)
|
||||
// keys clean, unambiguous records.
|
||||
var ErrInvalidKey = errors.New("kms: invalid secret coordinate (name/env must be non-empty, within length bounds, and contain no '/', NUL, or control characters)")
|
||||
|
||||
// Store-key component bounds. The store keys are opaque byte strings, so a '/'
|
||||
// in a name is not filesystem traversal — but forbidding separators + control
|
||||
// chars keeps one key = one secret and closes the door on any future backend
|
||||
// that treats '/' structurally.
|
||||
const (
|
||||
maxNameLen = 253
|
||||
maxEnvLen = 63
|
||||
maxSubpathLen = 253
|
||||
)
|
||||
|
||||
// Client is the in-process cloud.KMSClient backed by the embedded luxfi/kms
|
||||
// SecretStore. GetSecret/PutSecret seal/open through the AES-256-GCM envelope;
|
||||
// Sign fails closed (MPC is never co-hosted here).
|
||||
//
|
||||
// The zero value is not usable; construct with New.
|
||||
type Client struct {
|
||||
db *badger.DB // held so Close can release the KV (SecretStore does not expose it)
|
||||
store *kmsstore.SecretStore
|
||||
masterKey []byte // 32-byte KEK; nil ⇒ health-only fail-closed mode
|
||||
mpcAddr string // MPC daemon address; "" ⇒ Sign fails closed
|
||||
vaultID string // MPC vault id; "" ⇒ Sign fails closed
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
var _ types.KMSClient = (*Client)(nil)
|
||||
|
||||
// Config is the embedded KMS configuration resolved from cloud.Config + env by
|
||||
// New. All fields are optional: an empty MasterKeyB64 yields the fail-closed
|
||||
// health-only mode; empty MPC fields make Sign fail closed.
|
||||
type Config struct {
|
||||
DataDir string // CLOUD_DATA_DIR; the store opens under {DataDir}/kms
|
||||
MasterKeyB64 string // base64 of the 32-byte master key (CLOUD_KMS_MASTER_KEY_REF)
|
||||
MPCAddr string // MPC daemon host:port(,...) — CLOUD_KMS_MPC_ADDR
|
||||
MPCVaultID string // MPC vault id — CLOUD_KMS_MPC_VAULT_ID
|
||||
}
|
||||
|
||||
// New opens the embedded KMS store under cfg.DataDir and returns the in-process
|
||||
// Client. A malformed or missing master key is NOT fatal: the store still opens
|
||||
// (so health can report + list metadata) but the Client runs in health-only mode
|
||||
// where every secret op fails closed with ErrMasterKeyMissing. A bad DataDir /
|
||||
// store-open failure IS fatal (the subsystem cannot serve at all).
|
||||
//
|
||||
// The master key is decoded from base64, validated to be exactly 32 bytes, and
|
||||
// held in memory only. It is never logged and never written to the store.
|
||||
func New(cfg Config, log luxlog.Logger) (*Client, error) {
|
||||
if log == nil {
|
||||
return nil, fmt.Errorf("kms.New: nil logger")
|
||||
}
|
||||
dir := strings.TrimSpace(cfg.DataDir)
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("kms.New: empty DataDir")
|
||||
}
|
||||
dbDir := filepath.Join(dir, "kms")
|
||||
|
||||
masterKey, keyErr := decodeMasterKey(cfg.MasterKeyB64)
|
||||
|
||||
// Store-open strategy, fail-SECURE across the health-only↔keyed transition:
|
||||
//
|
||||
// keyed → open the on-disk store ENCRYPTED at rest with the master
|
||||
// key (WithEncryptionKey), on top of the per-secret Seal
|
||||
// envelope. zapdb rejects a WRONG key at open (rotation
|
||||
// without re-encrypt fails closed, not a silent downgrade).
|
||||
// no key, no store → open an EPHEMERAL IN-MEMORY store, never touching disk.
|
||||
// no key, store → FAIL: an encrypted store already exists but its key is
|
||||
// present absent (the operator dropped CLOUD_KMS_MASTER_KEY_REF).
|
||||
// Refuse loudly rather than silently ignore encrypted data.
|
||||
//
|
||||
// Why in-memory for the fresh health-only case (no store yet): a disk-backed
|
||||
// zapdb opened WITHOUT a key writes a PLAINTEXT KEYREGISTRY. If the operator
|
||||
// then injects the real key on the next boot, zapdb's registry sanity check
|
||||
// rejects the now-mismatched registry and badger.Open fails PERMANENTLY —
|
||||
// bricking KMS until the data dir is wiped. Health-only mode can serve no
|
||||
// secret op anyway (every Get/Put fails closed without the key), so there is
|
||||
// nothing to persist; an ephemeral in-memory KV lets health/metadata work while
|
||||
// leaving the on-disk dir untouched, so the first KEYED boot opens a clean
|
||||
// encrypted store. No unencrypted secret store is ever written to disk.
|
||||
var opts badger.Options
|
||||
switch {
|
||||
case keyErr == nil:
|
||||
if err := os.MkdirAll(dbDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("kms.New: create store dir: %w", err)
|
||||
}
|
||||
opts = badger.DefaultOptions(dbDir).WithLogger(nil).
|
||||
WithEncryptionKey(masterKey).WithIndexCacheSize(16 << 20)
|
||||
case storeExistsOnDisk(dbDir):
|
||||
// An encrypted store is present but no key was supplied: do not silently
|
||||
// open a fresh in-memory store and pretend the on-disk secrets are gone.
|
||||
return nil, fmt.Errorf("kms.New: encrypted store present at %s but no master key configured: %w", dbDir, keyErr)
|
||||
default:
|
||||
opts = badger.DefaultOptions("").WithLogger(nil).WithInMemory(true)
|
||||
}
|
||||
db, err := badger.Open(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kms.New: open store %s: %w", dbDir, err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
db: db,
|
||||
store: kmsstore.NewSecretStore(db),
|
||||
masterKey: masterKey, // nil when keyErr != nil
|
||||
mpcAddr: strings.TrimSpace(cfg.MPCAddr),
|
||||
vaultID: strings.TrimSpace(cfg.MPCVaultID),
|
||||
log: log.New("subsystem", "kms"),
|
||||
}
|
||||
if keyErr != nil {
|
||||
c.log.Warn("kms master key not configured; secret ops fail closed (health-only mode)", "err", keyErr)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// storeExistsOnDisk reports whether a zapdb store was already initialized under
|
||||
// dir. zapdb writes a MANIFEST at the store root on first open, so its presence
|
||||
// marks an existing (encrypted) store — used to refuse a keyless boot over real
|
||||
// data rather than silently shadow it with an in-memory KV.
|
||||
func storeExistsOnDisk(dir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(dir, "MANIFEST")); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decodeMasterKey decodes and validates the base64 master key. A key that is
|
||||
// absent, unparseable, or the wrong length yields (nil, err) so the caller runs
|
||||
// health-only. The raw key bytes are never logged.
|
||||
func decodeMasterKey(b64 string) ([]byte, error) {
|
||||
b64 = strings.TrimSpace(b64)
|
||||
if b64 == "" {
|
||||
return nil, ErrMasterKeyMissing
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kms: master key is not valid base64: %w", err)
|
||||
}
|
||||
if len(key) != masterKeyLen {
|
||||
return nil, fmt.Errorf("kms: master key must decode to %d bytes, got %d", masterKeyLen, len(key))
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Ready reports whether the Client can perform secret ops (a valid master key is
|
||||
// configured). Used to fail closed uniformly across the KMSClient + REST paths,
|
||||
// and surfaced to build.go for the boot log.
|
||||
func (c *Client) Ready() bool { return c != nil && len(c.masterKey) == masterKeyLen }
|
||||
|
||||
// SigningConfigured reports whether an MPC backend is wired. Sign fails closed
|
||||
// when false — no signature is fabricated.
|
||||
func (c *Client) SigningConfigured() bool { return c != nil && c.mpcAddr != "" && c.vaultID != "" }
|
||||
|
||||
// ── cloud.KMSClient ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetSecret resolves a flat ref to (path, name, env), reads the sealed record
|
||||
// from the store, and returns the AES-256-GCM-opened plaintext. Fails closed
|
||||
// with ErrMasterKeyMissing when no master key is configured.
|
||||
//
|
||||
// ref grammar (see parseRef): "name" | "path/name" | "path/name@env". A bare
|
||||
// name resolves to (path="/", name, env="default").
|
||||
func (c *Client) GetSecret(ctx context.Context, ref string) ([]byte, error) {
|
||||
path, name, env := parseRef(ref)
|
||||
return c.Get(path, name, env)
|
||||
}
|
||||
|
||||
// PutSecret seals value under a fresh per-secret DEK (wrapped by the master key)
|
||||
// and upserts it into the store. Plaintext is never persisted. Fails closed with
|
||||
// ErrMasterKeyMissing when no master key is configured.
|
||||
func (c *Client) PutSecret(ctx context.Context, ref string, value []byte) error {
|
||||
path, name, env := parseRef(ref)
|
||||
return c.Put(path, name, env, value)
|
||||
}
|
||||
|
||||
// Sign is threshold-MPC-backed in luxfi/kms and cloud does not co-host the MPC
|
||||
// cluster, so it fails closed with ErrSignUnavailable unless an MPC backend is
|
||||
// explicitly configured. It NEVER returns a fabricated signature.
|
||||
//
|
||||
// When an MPC backend IS configured the caller should route signing to the
|
||||
// dedicated MPC/keys deployment (deps.KMS ZAP RPC); in-process co-hosting of the
|
||||
// MPC signer is intentionally out of scope for the application-tier binary.
|
||||
func (c *Client) Sign(ctx context.Context, keyRef string, payload []byte) ([]byte, error) {
|
||||
if !c.SigningConfigured() {
|
||||
return nil, ErrSignUnavailable
|
||||
}
|
||||
// An MPC backend is configured but co-hosting the threshold signer inside the
|
||||
// application binary is out of scope (the MPC cluster is its own trust/scaling
|
||||
// tier). Fail closed loudly rather than fabricate — the operator wires the KMS
|
||||
// ZAP RPC endpoint (CLOUD_KMS_ZAP_ADDR) for signing instead.
|
||||
return nil, fmt.Errorf("%w: in-process MPC signing is not co-hosted; route signing via CLOUD_KMS_ZAP_ADDR", ErrSignUnavailable)
|
||||
}
|
||||
|
||||
// ── sealed store access (the ONE place seal/open lives) ──────────────────────
|
||||
//
|
||||
// The REST subsystem (clients/kms) and the KMSClient facade both go through
|
||||
// these four methods with explicit (path, name, env) coordinates, so the
|
||||
// AES-256-GCM envelope is applied in exactly one place. ErrSecretNotFound is
|
||||
// returned verbatim so callers can map it to a 404.
|
||||
|
||||
// SecretMeta is a secret's non-sensitive descriptor (never any ciphertext or
|
||||
// plaintext), returned by List for the console's secret browser.
|
||||
type SecretMeta struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Env string `json:"env"`
|
||||
Scheme string `json:"scheme"`
|
||||
}
|
||||
|
||||
// Get reads a sealed secret at (path, name, env) and returns the opened
|
||||
// plaintext. Fails closed with ErrMasterKeyMissing when no master key is set.
|
||||
func (c *Client) Get(path, name, env string) ([]byte, error) {
|
||||
if !c.Ready() {
|
||||
return nil, ErrMasterKeyMissing
|
||||
}
|
||||
if err := validCoords(path, name, env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec, err := c.store.Get(path, name, env)
|
||||
if err != nil {
|
||||
if errors.Is(err, kmsstore.ErrSecretNotFound) {
|
||||
return nil, kmsstore.ErrSecretNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("kms: read secret: %w", err)
|
||||
}
|
||||
pt, err := kmsstore.Open(c.masterKey, sec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kms: open secret: %w", err)
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
// Put seals value under a fresh per-secret DEK (wrapped by the master key) and
|
||||
// upserts it. Plaintext is sealed before it reaches the store — never stored raw.
|
||||
// Fails closed with ErrMasterKeyMissing when no master key is set.
|
||||
func (c *Client) Put(path, name, env string, value []byte) error {
|
||||
if !c.Ready() {
|
||||
return ErrMasterKeyMissing
|
||||
}
|
||||
if err := validCoords(path, name, env); err != nil {
|
||||
return err
|
||||
}
|
||||
sec, err := kmsstore.Seal(c.masterKey, path, name, env, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("kms: seal secret: %w", err)
|
||||
}
|
||||
if err := c.store.Put(sec); err != nil {
|
||||
return fmt.Errorf("kms: write secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the metadata (never ciphertext/plaintext) of the secrets at a
|
||||
// path/env. It does not require the master key: nothing sensitive is decrypted.
|
||||
func (c *Client) List(path, env string) ([]SecretMeta, error) {
|
||||
if !ValidSegment(env, maxEnvLen) {
|
||||
return nil, ErrInvalidKey
|
||||
}
|
||||
secs, err := c.store.List(path, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kms: list secrets: %w", err)
|
||||
}
|
||||
out := make([]SecretMeta, 0, len(secs))
|
||||
for _, s := range secs {
|
||||
out = append(out, SecretMeta{Name: s.Name, Path: s.Path, Env: s.Env, Scheme: s.Scheme})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes a secret. Returns ErrSecretNotFound verbatim for a 404 mapping.
|
||||
func (c *Client) Delete(path, name, env string) error {
|
||||
if err := validCoords(path, name, env); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.store.Delete(path, name, env); err != nil {
|
||||
if errors.Is(err, kmsstore.ErrSecretNotFound) {
|
||||
return kmsstore.ErrSecretNotFound
|
||||
}
|
||||
return fmt.Errorf("kms: delete secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrSecretNotFound is re-exported so the REST subsystem can map a missing
|
||||
// secret to a 404 without importing luxfi/kms's store package directly.
|
||||
var ErrSecretNotFound = kmsstore.ErrSecretNotFound
|
||||
|
||||
// ── key-shape validation (ONE place, both faces) ─────────────────────────────
|
||||
|
||||
// validCoords is the single boundary guard applied by every store-access method
|
||||
// (Get/Put/Delete and, for env, List), so the HTTP subsystem AND the in-process
|
||||
// KMSClient facade (which reaches the store via parseRef) enforce identically —
|
||||
// no entry point can key a malformed record. path is a '/'-separated subpath
|
||||
// (validated as a whole); name and env are single segments.
|
||||
func validCoords(path, name, env string) error {
|
||||
if !ValidSegment(name, maxNameLen) || !ValidSegment(env, maxEnvLen) || !ValidSubpath(path) {
|
||||
return ErrInvalidKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidSegment reports whether s is a valid single store-key segment (name or
|
||||
// env): non-empty, within max bytes, and free of '/', NUL, and ASCII control
|
||||
// characters. Exported so the HTTP subsystem reuses the exact same rule.
|
||||
func ValidSegment(s string, max int) bool {
|
||||
if s == "" || len(s) > max {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r == '/' || r == 0 || r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidSubpath reports whether p is a valid store subpath: '/'-separated
|
||||
// non-empty segments, none of which is "." or ".." or contains a control char,
|
||||
// within the length bound. An empty/"/"-only path is valid (the org/collection
|
||||
// root). Exported so the HTTP subsystem reuses the exact same rule.
|
||||
func ValidSubpath(p string) bool {
|
||||
if len(p) > maxSubpathLen {
|
||||
return false
|
||||
}
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return true
|
||||
}
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
return false
|
||||
}
|
||||
for _, r := range seg {
|
||||
if r == 0 || r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MaxSegmentLens exposes the name/env bounds so the HTTP subsystem can produce
|
||||
// specific 400 messages using the same limits the store methods enforce.
|
||||
const (
|
||||
MaxNameLen = maxNameLen
|
||||
MaxEnvLen = maxEnvLen
|
||||
)
|
||||
|
||||
// Close releases the embedded store. Safe to call once at shutdown.
|
||||
func (c *Client) Close() error {
|
||||
if c == nil || c.db == nil {
|
||||
return nil
|
||||
}
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
// ── ref parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
// parseRef maps a flat KMSClient ref to the store's (path, name, env) coordinate.
|
||||
//
|
||||
// "DATABASE_URL" → ("/", "DATABASE_URL", "default")
|
||||
// "myservice/DATABASE_URL" → ("/myservice","DATABASE_URL", "default")
|
||||
// "myservice/DB@main" → ("/myservice","DB", "main")
|
||||
//
|
||||
// The path is normalized to a leading "/" (the store keys on it verbatim, so a
|
||||
// stable normalization keeps refs and REST paths addressing the same record).
|
||||
func parseRef(ref string) (path, name, env string) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
env = defaultEnv
|
||||
if at := strings.LastIndex(ref, "@"); at >= 0 {
|
||||
if e := strings.TrimSpace(ref[at+1:]); e != "" {
|
||||
env = e
|
||||
}
|
||||
ref = ref[:at]
|
||||
}
|
||||
if slash := strings.LastIndex(ref, "/"); slash >= 0 {
|
||||
name = ref[slash+1:]
|
||||
path = normalizePath(ref[:slash])
|
||||
} else {
|
||||
name = ref
|
||||
path = "/"
|
||||
}
|
||||
return path, name, env
|
||||
}
|
||||
|
||||
// normalizePath ensures a single leading slash and no trailing slash, so
|
||||
// "myservice", "/myservice/", "//myservice" all key the same record.
|
||||
func normalizePath(p string) string {
|
||||
p = strings.Trim(strings.TrimSpace(p), "/")
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
return "/" + p
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package kmsembed
|
||||
|
||||
// RED RE-REVIEW — attacking the new 3-way store-open switch in New() and the
|
||||
// hoisted validCoords. Goal: defeat the fail-secure switch via any
|
||||
// {key present|absent|wrong} × {store absent|present|MANIFEST-missing|plaintext}
|
||||
// combination, or find a silent downgrade / brick the switch was meant to close.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
badger "github.com/luxfi/zapdb"
|
||||
)
|
||||
|
||||
func rrKey(t *testing.T, fill byte) string {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
for i := range k {
|
||||
k[i] = fill
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
func rrRandKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
if _, err := rand.Read(k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
// RR-1: the fix's headline claim — fresh health-only boot (no key, no store) must
|
||||
// NOT write ANYTHING to disk (no plaintext KEYREGISTRY, no MANIFEST), so the first
|
||||
// keyed boot opens a clean encrypted store. Verify the dir stays empty AND the
|
||||
// subsequent keyed boot succeeds + roundtrips.
|
||||
func TestRR1_FreshHealthOnlyWritesNothing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
dbDir := filepath.Join(root, "kms")
|
||||
|
||||
// Boot 1: no key, no existing store → ephemeral in-memory.
|
||||
c1, err := New(Config{DataDir: root, MasterKeyB64: ""}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("fresh health-only New: %v", err)
|
||||
}
|
||||
if c1.Ready() {
|
||||
t.Fatal("health-only must not be Ready")
|
||||
}
|
||||
c1.Close()
|
||||
|
||||
// The on-disk kms dir must contain NO store files (ideally not exist at all).
|
||||
if entries, statErr := os.ReadDir(dbDir); statErr == nil {
|
||||
for _, e := range entries {
|
||||
t.Errorf("BREACH: fresh health-only wrote to disk: %s/%s", dbDir, e.Name())
|
||||
}
|
||||
// A MANIFEST or KEYREGISTRY here would re-introduce the brick.
|
||||
if _, e := os.Stat(filepath.Join(dbDir, "MANIFEST")); e == nil {
|
||||
t.Fatal("BRICK-REGRESSION: MANIFEST written in health-only mode")
|
||||
}
|
||||
if _, e := os.Stat(filepath.Join(dbDir, "KEYREGISTRY")); e == nil {
|
||||
t.Fatal("BRICK-REGRESSION: plaintext KEYREGISTRY written in health-only mode")
|
||||
}
|
||||
} else {
|
||||
t.Logf("clean: %s does not exist after health-only boot", dbDir)
|
||||
}
|
||||
|
||||
// Boot 2: real key → must open a CLEAN encrypted store and roundtrip (8c fixed).
|
||||
c2, err := New(Config{DataDir: root, MasterKeyB64: rrKey(t, 0xA1)}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("BRICK: keyed boot after health-only failed: %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
if err := c2.Put("/orgs/x", "K", "default", []byte("v")); err != nil {
|
||||
t.Fatalf("put after health-only→key: %v", err)
|
||||
}
|
||||
if got, err := c2.Get("/orgs/x", "K", "default"); err != nil || string(got) != "v" {
|
||||
t.Fatalf("roundtrip after health-only→key: got=%q err=%v", got, err)
|
||||
}
|
||||
t.Logf("8c FIXED: health-only wrote nothing; keyed boot opened clean encrypted store + roundtrips")
|
||||
}
|
||||
|
||||
// RR-2 (8b re-verify): encrypted store present + NO key → MUST fail loudly, NOT
|
||||
// silently shadow with an in-memory store (Blue's first attempt regressed this).
|
||||
func TestRR2_EncryptedStoreNoKeyFailsLoud(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
|
||||
// Create an encrypted store with a key + a secret.
|
||||
c1, err := New(Config{DataDir: root, MasterKeyB64: rrKey(t, 0xB2)}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("boot1: %v", err)
|
||||
}
|
||||
_ = c1.Put("/orgs/x", "SECRET", "default", []byte("must-not-vanish"))
|
||||
c1.Close()
|
||||
|
||||
// Boot 2: no key. MANIFEST exists → must ERROR, not open in-memory.
|
||||
c2, err := New(Config{DataDir: root, MasterKeyB64: ""}, log)
|
||||
if err == nil {
|
||||
defer c2.Close()
|
||||
t.Fatalf("SILENT-DOWNGRADE: encrypted store + no key opened without error (Ready=%v). "+
|
||||
"On-disk secret would be shadowed by an empty in-mem KV.", c2.Ready())
|
||||
}
|
||||
t.Logf("8b holds: encrypted+no-key fails loud: %v", err)
|
||||
}
|
||||
|
||||
// RR-3 (8a re-verify): wrong key over an encrypted store → fail closed (zapdb
|
||||
// registry sanity), never silent.
|
||||
func TestRR3_WrongKeyFailsClosed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
c1, _ := New(Config{DataDir: root, MasterKeyB64: rrKey(t, 0xC3)}, log)
|
||||
_ = c1.Put("/orgs/x", "K", "default", []byte("v"))
|
||||
c1.Close()
|
||||
|
||||
c2, err := New(Config{DataDir: root, MasterKeyB64: rrKey(t, 0xD4)}, log)
|
||||
if err == nil {
|
||||
defer c2.Close()
|
||||
_, gErr := c2.Get("/orgs/x", "K", "default")
|
||||
t.Fatalf("SILENT-DOWNGRADE: wrong key opened store without error (Get=%v)", gErr)
|
||||
}
|
||||
t.Logf("8a holds: wrong key fails closed: %v", err)
|
||||
}
|
||||
|
||||
// RR-4: THE MANIFEST-SENTINEL BLIND SPOT. storeExistsOnDisk keys ONLY on MANIFEST.
|
||||
// If a real encrypted store loses its MANIFEST but keeps KEYREGISTRY + SST/vlog
|
||||
// (botched backup restore, partial rsync, fs corruption), a keyless boot sees no
|
||||
// MANIFEST → goes IN-MEMORY, silently shadowing the on-disk encrypted data. Prove
|
||||
// the sentinel's false-negative → silent downgrade. Then prove the follow-on: a
|
||||
// keyed boot over the MANIFEST-less-but-KEYREGISTRY-present dir.
|
||||
func TestRR4_ManifestSentinelBlindSpot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
dbDir := filepath.Join(root, "kms")
|
||||
|
||||
// Create a real encrypted store.
|
||||
c1, err := New(Config{DataDir: root, MasterKeyB64: rrKey(t, 0xE5)}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("boot1: %v", err)
|
||||
}
|
||||
_ = c1.Put("/orgs/x", "K", "default", []byte("on-disk-encrypted"))
|
||||
c1.Close()
|
||||
|
||||
// List the on-disk artifacts.
|
||||
before, _ := os.ReadDir(dbDir)
|
||||
var names []string
|
||||
for _, e := range before {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Logf("store artifacts: %v", names)
|
||||
|
||||
// Simulate MANIFEST loss (keep KEYREGISTRY + data files).
|
||||
keyReg := filepath.Join(dbDir, "KEYREGISTRY")
|
||||
if _, e := os.Stat(keyReg); e != nil {
|
||||
t.Fatalf("expected KEYREGISTRY present: %v", e)
|
||||
}
|
||||
if e := os.Remove(filepath.Join(dbDir, "MANIFEST")); e != nil {
|
||||
t.Fatalf("remove MANIFEST: %v", e)
|
||||
}
|
||||
|
||||
// Boot 2: NO key, MANIFEST gone but KEYREGISTRY + data remain.
|
||||
c2, err := New(Config{DataDir: root, MasterKeyB64: ""}, log)
|
||||
if err == nil {
|
||||
// It opened. Is it in-memory (silent downgrade) while encrypted data sits
|
||||
// on disk? storeExistsOnDisk returned false because MANIFEST is gone.
|
||||
defer c2.Close()
|
||||
t.Logf("SENTINEL BLIND SPOT: no-key boot with MANIFEST removed but KEYREGISTRY present "+
|
||||
"→ New succeeded (Ready=%v). storeExistsOnDisk keys only on MANIFEST, so a store that "+
|
||||
"lost its MANIFEST is treated as absent → in-memory shadow of on-disk encrypted data. "+
|
||||
"KEYREGISTRY still on disk: %v", c2.Ready(), fileExists(keyReg))
|
||||
// Not a confidentiality breach (data still encrypted at rest), but a silent
|
||||
// data-availability downgrade the fix's own goal ("do not silently ignore
|
||||
// on-disk secrets") does not fully achieve when the sentinel file is the one lost.
|
||||
} else {
|
||||
t.Logf("no-key boot with MANIFEST-less store failed (conservative): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RR-5: PLAINTEXT-STORE MIGRATION BRICK. An operator upgrading from the OLD blue
|
||||
// binary (which wrote a plaintext store) to the new keyed binary: a KEYED boot
|
||||
// with WithEncryptionKey against a PRE-EXISTING PLAINTEXT store. zapdb's registry
|
||||
// sanity rejects it → brick. Prove the migration path fails (documented risk).
|
||||
func TestRR5_PlaintextStoreThenKeyedBrick(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
dbDir := filepath.Join(root, "kms")
|
||||
|
||||
// Simulate the OLD behavior: open a PLAINTEXT disk store directly (no key).
|
||||
if err := os.MkdirAll(dbDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pdb, err := badger.Open(badger.DefaultOptions(dbDir).WithLogger(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("open plaintext store: %v", err)
|
||||
}
|
||||
// write something so it's a real store with a MANIFEST + KEYREGISTRY.
|
||||
_ = pdb.Update(func(txn *badger.Txn) error { return txn.Set([]byte("k"), []byte("v")) })
|
||||
pdb.Close()
|
||||
if !fileExists(filepath.Join(dbDir, "MANIFEST")) {
|
||||
t.Fatal("expected plaintext store MANIFEST")
|
||||
}
|
||||
|
||||
// Now the NEW binary boots WITH a key → keyed branch, WithEncryptionKey.
|
||||
c, err := New(Config{DataDir: root, MasterKeyB64: rrKey(t, 0xF6)}, log)
|
||||
if err != nil {
|
||||
t.Logf("MIGRATION BRICK CONFIRMED: keyed boot over a pre-existing PLAINTEXT store fails "+
|
||||
"(New → %v). An operator upgrading from a binary that wrote a plaintext store must wipe "+
|
||||
"{DataDir}/kms first. Not a confidentiality issue (no secret was in the plaintext store "+
|
||||
"in the fixed flow), but a migration foot-gun if a plaintext store ever reached disk.", err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
t.Logf("keyed boot over plaintext store SUCCEEDED (Ready=%v) — zapdb tolerated it?", c.Ready())
|
||||
}
|
||||
|
||||
// RR-6: does the in-memory branch leak ANY disk write? Drive a full lifecycle in
|
||||
// health-only (in-mem) — List/health-metadata — and confirm the dir is pristine.
|
||||
func TestRR6_InMemoryNeverTouchesDisk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
dbDir := filepath.Join(root, "kms")
|
||||
|
||||
c, err := New(Config{DataDir: root, MasterKeyB64: ""}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("in-mem New: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
// List works (no key needed) but must not create disk files.
|
||||
if _, err := c.List("/orgs/x", "default"); err != nil {
|
||||
t.Fatalf("list in health-only: %v", err)
|
||||
}
|
||||
// Secret ops fail closed.
|
||||
if err := c.Put("/orgs/x", "K", "default", []byte("v")); err == nil {
|
||||
t.Fatal("BREACH: Put succeeded in health-only in-memory mode")
|
||||
}
|
||||
if _, err := c.Get("/orgs/x", "K", "default"); err == nil {
|
||||
t.Fatal("BREACH: Get succeeded in health-only in-memory mode")
|
||||
}
|
||||
if _, err := os.Stat(dbDir); err == nil {
|
||||
if entries, _ := os.ReadDir(dbDir); len(entries) > 0 {
|
||||
t.Fatalf("BREACH: in-memory mode created disk files under %s", dbDir)
|
||||
}
|
||||
}
|
||||
t.Logf("in-memory health-only: List OK, Put/Get fail closed, zero disk writes")
|
||||
}
|
||||
|
||||
// RR-7: validator dedup — the facade (parseRef→Get/Put) must reject the SAME bad
|
||||
// coords the HTTP boundary rejects, now that validation is hoisted into kmsembed.
|
||||
// Re-run the facade-asymmetry attack; it must now be BLOCKED.
|
||||
func TestRR7_FacadeValidationNowEnforced(t *testing.T) {
|
||||
log := luxlog.NewNoOpLogger()
|
||||
c, err := New(Config{DataDir: t.TempDir(), MasterKeyB64: rrRandKey(t)}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// NUL + control chars in the ref — previously roundtripped; must now error.
|
||||
badRef := "svc/EVIL\x00NAME@pr\x01od"
|
||||
if err := c.PutSecret(ctx, badRef, []byte("x")); err == nil {
|
||||
t.Fatalf("REGRESSION: facade still accepts NUL+ctrl ref %q — validation not enforced on Put", badRef)
|
||||
} else {
|
||||
t.Logf("facade Put rejects bad ref: %v", err)
|
||||
}
|
||||
// Empty ref → name "" — must error now.
|
||||
if err := c.PutSecret(ctx, "", []byte("x")); err == nil {
|
||||
t.Fatalf("REGRESSION: facade still stores a nameless secret (empty ref)")
|
||||
}
|
||||
// Direct Get with bad coords must also reject (not just Put).
|
||||
if _, err := c.Get("/orgs/x", "bad\x00name", "default"); err == nil {
|
||||
t.Fatalf("REGRESSION: Get accepts NUL in name")
|
||||
}
|
||||
// A CLEAN ref must still work.
|
||||
if err := c.PutSecret(ctx, "svc/GOOD@prod", []byte("ok")); err != nil {
|
||||
t.Fatalf("clean ref rejected: %v", err)
|
||||
}
|
||||
if got, err := c.GetSecret(ctx, "svc/GOOD@prod"); err != nil || string(got) != "ok" {
|
||||
t.Fatalf("clean ref roundtrip: got=%q err=%v", got, err)
|
||||
}
|
||||
t.Logf("facade asymmetry CLOSED: bad coords rejected on Put+Get+empty, clean ref works")
|
||||
}
|
||||
|
||||
// RR-8: validCoords must NOT reject a legitimate path='/' (org/collection root).
|
||||
// A too-strict validator would break every root-level secret. Guard against an
|
||||
// over-correction that fails closed on valid input.
|
||||
func TestRR8_RootPathStillValid(t *testing.T) {
|
||||
log := luxlog.NewNoOpLogger()
|
||||
c, _ := New(Config{DataDir: t.TempDir(), MasterKeyB64: rrRandKey(t)}, log)
|
||||
defer c.Close()
|
||||
// path "/" (root) with a bare name — the parseRef("NAME") case.
|
||||
if err := c.Put("/", "ROOT_SECRET", "default", []byte("v")); err != nil {
|
||||
t.Fatalf("root-path Put rejected (over-correction): %v", err)
|
||||
}
|
||||
if got, err := c.Get("/", "ROOT_SECRET", "default"); err != nil || string(got) != "v" {
|
||||
t.Fatalf("root-path roundtrip: got=%q err=%v", got, err)
|
||||
}
|
||||
// List at root must also pass validation.
|
||||
if _, err := c.List("/", "default"); err != nil {
|
||||
t.Fatalf("root-path List rejected: %v", err)
|
||||
}
|
||||
t.Logf("root path '/' remains valid for Put/Get/List")
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// RR-4b: blast radius of the MANIFEST blind spot. After a store loses MANIFEST,
|
||||
// does supplying the KEY on a later boot (keyed branch → WithEncryptionKey over
|
||||
// the KEYREGISTRY-present-but-MANIFEST-absent dir) RECOVER the secret, or
|
||||
// corrupt/brick? Determines whether RR4 is a transient blip or data loss.
|
||||
func TestRR4b_ManifestLossThenKeyedRecovery(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
dbDir := filepath.Join(root, "kms")
|
||||
key := rrKey(t, 0xAB)
|
||||
|
||||
c1, err := New(Config{DataDir: root, MasterKeyB64: key}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("boot1: %v", err)
|
||||
}
|
||||
if err := c1.Put("/orgs/x", "K", "default", []byte("recover-me")); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
c1.Close()
|
||||
|
||||
// Lose MANIFEST.
|
||||
if e := os.Remove(filepath.Join(dbDir, "MANIFEST")); e != nil {
|
||||
t.Fatalf("rm MANIFEST: %v", e)
|
||||
}
|
||||
|
||||
// Boot with the SAME key (keyed branch). What does zapdb do with no MANIFEST?
|
||||
c2, err := New(Config{DataDir: root, MasterKeyB64: key}, log)
|
||||
if err != nil {
|
||||
t.Logf("BLAST RADIUS: after MANIFEST loss, keyed boot FAILS (New → %v) — the store is "+
|
||||
"bricked until wiped; the on-disk secret is UNRECOVERABLE via this path.", err)
|
||||
return
|
||||
}
|
||||
defer c2.Close()
|
||||
got, gErr := c2.Get("/orgs/x", "K", "default")
|
||||
if gErr == nil && string(got) == "recover-me" {
|
||||
t.Logf("BLAST RADIUS: MANIFEST loss is RECOVERABLE — keyed boot rebuilt the manifest and "+
|
||||
"the secret survives (Get=%q). RR4 is a transient availability blip, not data loss.", got)
|
||||
} else {
|
||||
t.Logf("BLAST RADIUS: keyed boot after MANIFEST loss opened but the secret is GONE "+
|
||||
"(Get=%q err=%v) — silent data loss.", got, gErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package kmsembed
|
||||
|
||||
// RED tests for the embedded KMS client internals: encryption-key restart
|
||||
// semantics (rotation / downgrade), Sign fail-closed, and master-key non-leakage.
|
||||
// White-box (same package) so we can drive New/Get/Put with explicit keys.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
func b64key(t *testing.T, fill byte) string {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
for i := range k {
|
||||
k[i] = fill
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
func randB64Key(t *testing.T) string {
|
||||
t.Helper()
|
||||
k := make([]byte, 32)
|
||||
if _, err := rand.Read(k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
// VECTOR 8a: restart with a DIFFERENT master key (rotation without re-encrypt).
|
||||
// zapdb's KEYREGISTRY sanity-text check must make badger.Open FAIL — so the
|
||||
// store does NOT silently open with a wrong key (which would corrupt/lose data
|
||||
// or, worse, appear to work). We assert New returns an error → pickKMSClient
|
||||
// falls back to DisabledKMS (fail-closed). Proof it is not a silent downgrade.
|
||||
func TestVector8a_RestartWrongKeyFailsClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
|
||||
// Boot 1: key A, write a secret.
|
||||
c1, err := New(Config{DataDir: dir, MasterKeyB64: b64key(t, 0xAA)}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("boot1: %v", err)
|
||||
}
|
||||
if err := c1.Put("/orgs/x", "K", "default", []byte("v")); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
if err := c1.Close(); err != nil {
|
||||
t.Fatalf("close1: %v", err)
|
||||
}
|
||||
|
||||
// Boot 2: key B (rotation, store still encrypted with A's datakey registry).
|
||||
// zapdb sanity check should reject B → New errors.
|
||||
c2, err := New(Config{DataDir: dir, MasterKeyB64: b64key(t, 0xBB)}, log)
|
||||
if err == nil {
|
||||
// If it did NOT error, prove it at least cannot read A's secret with B.
|
||||
defer c2.Close()
|
||||
_, gErr := c2.Get("/orgs/x", "K", "default")
|
||||
t.Fatalf("SILENT-DOWNGRADE RISK: reopened store with WRONG key without error "+
|
||||
"(Get err=%v). Expected badger.Open to fail on KEYREGISTRY sanity mismatch.", gErr)
|
||||
}
|
||||
t.Logf("rotation w/o re-encrypt fails closed: New(wrong key) → %v", err)
|
||||
}
|
||||
|
||||
// VECTOR 8b: store first created ENCRYPTED (key A), then restarted with NO key
|
||||
// (health-only). zapdb must reject the plaintext-open of an encrypted registry —
|
||||
// so you cannot silently DOWNGRADE an encrypted store to unencrypted. New should
|
||||
// error (→ DisabledKMS), never open an encrypted store as plaintext.
|
||||
func TestVector8b_EncryptedThenNoKeyFailsClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
|
||||
c1, err := New(Config{DataDir: dir, MasterKeyB64: b64key(t, 0xCC)}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("boot1: %v", err)
|
||||
}
|
||||
_ = c1.Put("/orgs/x", "K", "default", []byte("v"))
|
||||
c1.Close()
|
||||
|
||||
// Boot 2: NO key. Store dir already has an encrypted KEYREGISTRY.
|
||||
c2, err := New(Config{DataDir: dir, MasterKeyB64: ""}, log)
|
||||
if err == nil {
|
||||
defer c2.Close()
|
||||
if c2.Ready() {
|
||||
t.Fatalf("BREACH: encrypted store reopened in READY mode with no key")
|
||||
}
|
||||
// It opened but is health-only. Is that a silent downgrade of the encrypted KV?
|
||||
t.Fatalf("SILENT-DOWNGRADE RISK: encrypted store reopened WITHOUT key did not error "+
|
||||
"(Ready=%v). Expected badger.Open to fail decrypting the KEYREGISTRY.", c2.Ready())
|
||||
}
|
||||
t.Logf("encrypted→no-key fails closed: New(no key on encrypted dir) → %v", err)
|
||||
}
|
||||
|
||||
// VECTOR 8c: the FOOT-GUN — store first created in HEALTH-ONLY (no key, plaintext
|
||||
// KEYREGISTRY), then the operator injects the real key on the next boot. Does
|
||||
// badger.Open reject the now-mismatched (plaintext) registry, bricking the store
|
||||
// until wiped? This is the availability trap Blue flagged as untested.
|
||||
func TestVector8c_HealthOnlyThenKeyBricks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
|
||||
// Boot 1: NO key → health-only, plaintext KEYREGISTRY created.
|
||||
c1, err := New(Config{DataDir: dir, MasterKeyB64: ""}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("boot1 (health-only): %v", err)
|
||||
}
|
||||
if c1.Ready() {
|
||||
t.Fatal("health-only client should not be Ready")
|
||||
}
|
||||
c1.Close()
|
||||
|
||||
// Boot 2: operator injects the real key. Plaintext registry now mismatches.
|
||||
c2, err := New(Config{DataDir: dir, MasterKeyB64: b64key(t, 0xDD)}, log)
|
||||
if err != nil {
|
||||
t.Logf("FOOT-GUN CONFIRMED: after a health-only boot, injecting the real key BRICKS "+
|
||||
"the store (New → %v). Operator must wipe {DataDir}/kms before first real boot, "+
|
||||
"or the KMS never comes up. Availability trap, not a data-confidentiality breach.", err)
|
||||
return
|
||||
}
|
||||
// If it opened, is it usable (Ready + roundtrip)?
|
||||
defer c2.Close()
|
||||
if !c2.Ready() {
|
||||
t.Fatalf("after health-only→key boot: client not Ready (err=nil but unusable)")
|
||||
}
|
||||
if err := c2.Put("/orgs/x", "K", "default", []byte("v")); err != nil {
|
||||
t.Fatalf("FOOT-GUN: Ready but Put fails after health-only→key transition: %v", err)
|
||||
}
|
||||
got, err := c2.Get("/orgs/x", "K", "default")
|
||||
if err != nil || string(got) != "v" {
|
||||
t.Fatalf("FOOT-GUN: roundtrip broken after health-only→key transition: got=%q err=%v", got, err)
|
||||
}
|
||||
t.Logf("health-only→key transition is CLEAN: store upgrades to encrypted and roundtrips OK")
|
||||
}
|
||||
|
||||
// VECTOR 8d: clean restart with the SAME key must preserve + decrypt secrets
|
||||
// (no data loss, sane path). The happy-path baseline for the above.
|
||||
func TestVector8d_SameKeyRestartRoundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
log := luxlog.NewNoOpLogger()
|
||||
key := randB64Key(t)
|
||||
|
||||
c1, err := New(Config{DataDir: dir, MasterKeyB64: key}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("boot1: %v", err)
|
||||
}
|
||||
if err := c1.Put("/orgs/x", "K", "prod", []byte("persist-me")); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
c1.Close()
|
||||
|
||||
c2, err := New(Config{DataDir: dir, MasterKeyB64: key}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("boot2 (same key): %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
got, err := c2.Get("/orgs/x", "K", "prod")
|
||||
if err != nil {
|
||||
t.Fatalf("get after restart: %v", err)
|
||||
}
|
||||
if string(got) != "persist-me" {
|
||||
t.Fatalf("data loss/corruption: got %q, want persist-me", got)
|
||||
}
|
||||
}
|
||||
|
||||
// VECTOR 6: Sign must NEVER return a non-nil signature. Exhaustively drive both
|
||||
// the unconfigured and the "configured but not co-hosted" branches.
|
||||
func TestVector6_SignNeverFabricates(t *testing.T) {
|
||||
log := luxlog.NewNoOpLogger()
|
||||
ctx := context.Background()
|
||||
|
||||
// Unconfigured MPC.
|
||||
c1, _ := New(Config{DataDir: t.TempDir(), MasterKeyB64: randB64Key(t)}, log)
|
||||
defer c1.Close()
|
||||
if sig, err := c1.Sign(ctx, "k", []byte("p")); sig != nil || err == nil {
|
||||
t.Fatalf("BREACH: Sign(no MPC) sig=%v err=%v — must be (nil, ErrSignUnavailable)", sig, err)
|
||||
}
|
||||
|
||||
// MPC "configured" (addr+vault set) but co-hosting is out of scope: still nil sig.
|
||||
c2, _ := New(Config{DataDir: t.TempDir(), MasterKeyB64: randB64Key(t),
|
||||
MPCAddr: "mpc.internal:9000", MPCVaultID: "v1"}, log)
|
||||
defer c2.Close()
|
||||
if !c2.SigningConfigured() {
|
||||
t.Fatal("expected SigningConfigured true with addr+vault set")
|
||||
}
|
||||
sig, err := c2.Sign(ctx, "k", []byte("p"))
|
||||
if sig != nil {
|
||||
t.Fatalf("BREACH: Sign(MPC configured) returned a %d-byte sig — must never fabricate", len(sig))
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("BREACH: Sign(MPC configured) returned nil error — must fail closed until real RPC wired")
|
||||
}
|
||||
t.Logf("Sign fails closed in both branches: unconfigured=%v, configured-not-cohosted=%v",
|
||||
"(nil, ErrSignUnavailable)", err)
|
||||
}
|
||||
|
||||
// VECTOR 5: master key never appears in any error surfaced by Get/Put/Open on a
|
||||
// corrupt record. Feed a tampered record through Open indirectly by storing then
|
||||
// corrupting is store-level; here we assert the New warn path + Get/Put errors do
|
||||
// not echo key bytes. (Log capture is covered by inspection; this asserts the
|
||||
// error strings are key-free.)
|
||||
func TestVector5_ErrorsNeverEchoKey(t *testing.T) {
|
||||
log := luxlog.NewNoOpLogger()
|
||||
rawKey := make([]byte, 32)
|
||||
for i := range rawKey {
|
||||
rawKey[i] = 0xEE
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(rawKey)
|
||||
c, err := New(Config{DataDir: t.TempDir(), MasterKeyB64: b64}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Get a missing secret — the error must not contain key material.
|
||||
_, gErr := c.Get("/orgs/x", "MISSING", "default")
|
||||
if gErr != nil && (strings.Contains(gErr.Error(), b64) || strings.Contains(gErr.Error(), string(rawKey))) {
|
||||
t.Fatalf("BREACH: Get error leaked key: %v", gErr)
|
||||
}
|
||||
// Health-only error path.
|
||||
hc, _ := New(Config{DataDir: t.TempDir(), MasterKeyB64: ""}, log)
|
||||
defer hc.Close()
|
||||
if _, e := hc.Get("/orgs/x", "K", "default"); e != nil {
|
||||
if strings.Contains(e.Error(), "AA") || strings.Contains(e.Error(), b64) {
|
||||
t.Fatalf("BREACH: health-only error leaked key material: %v", e)
|
||||
}
|
||||
}
|
||||
t.Logf("errors are key-free")
|
||||
}
|
||||
|
||||
// VECTOR 7-facade: the programmatic KMSClient facade (deps.KMS.PutSecret/GetSecret,
|
||||
// used by OTHER subsystems) goes through parseRef, which does NO validSegment
|
||||
// check. So an in-process caller can store a secret whose name/path/env contain
|
||||
// NUL or control chars — the HTTP boundary's validation does NOT protect the
|
||||
// facade. Confirm the asymmetry: the store accepts what the REST layer rejects.
|
||||
// (In-process callers are trusted, so this is a consistency/robustness gap, not a
|
||||
// remote breach — but a malicious/confused ref from a caller that forwards
|
||||
// user-influenced strings into a KMS ref would key a malformed record.)
|
||||
func TestVector7Facade_ParseRefNoValidation(t *testing.T) {
|
||||
log := luxlog.NewNoOpLogger()
|
||||
c, err := New(Config{DataDir: t.TempDir(), MasterKeyB64: randB64Key(t)}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// A ref carrying a NUL and control chars in the name — REST would 400 this.
|
||||
badRef := "svc/EVIL\x00NAME@pr\x01od"
|
||||
if err := c.PutSecret(ctx, badRef, []byte("stored-via-facade")); err != nil {
|
||||
t.Logf("facade rejected bad ref (good, validation added): %v", err)
|
||||
return
|
||||
}
|
||||
got, err := c.GetSecret(ctx, badRef)
|
||||
if err != nil {
|
||||
t.Fatalf("roundtrip bad ref: %v", err)
|
||||
}
|
||||
if string(got) != "stored-via-facade" {
|
||||
t.Fatalf("facade roundtrip mismatch: %q", got)
|
||||
}
|
||||
t.Logf("ASYMMETRY CONFIRMED: parseRef/Put accepted a NUL+control-char ref the REST "+
|
||||
"boundary rejects (%q). deps.KMS callers bypass validSegment. Robustness gap, "+
|
||||
"not a remote breach (in-process callers are trusted).", badRef)
|
||||
}
|
||||
|
||||
// VECTOR emptyRef: a facade Put with an empty ref keys (path="/", name="", env).
|
||||
// Does it store a nameless secret? Prove what an empty programmatic ref does.
|
||||
func TestVectorEmptyRefFacade(t *testing.T) {
|
||||
log := luxlog.NewNoOpLogger()
|
||||
c, _ := New(Config{DataDir: t.TempDir(), MasterKeyB64: randB64Key(t)}, log)
|
||||
defer c.Close()
|
||||
ctx := context.Background()
|
||||
err := c.PutSecret(ctx, "", []byte("nameless"))
|
||||
t.Logf("PutSecret(ref=\"\") → err=%v (keys path=/ name=\"\" env=default)", err)
|
||||
if err == nil {
|
||||
got, gErr := c.GetSecret(ctx, "")
|
||||
t.Logf("GetSecret(ref=\"\") → %q err=%v", got, gErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package ml
|
||||
|
||||
// Integration tests proving the per-org billing gate is wired into the compute
|
||||
// create path (/v1/train/jobs etc.): an unfunded org cannot run free GPU compute
|
||||
// (402 before any k8s object is created), and a funded org is charged on its OWN
|
||||
// org ledger. The metering client's default org is "hanzo", so "billed acme"
|
||||
// also proves the caller org — not the default — is charged.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/commerce/metering"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||
)
|
||||
|
||||
type billDouble struct {
|
||||
available int64
|
||||
mu sync.Mutex
|
||||
usageOrg string
|
||||
usageBody []byte
|
||||
usages int32
|
||||
}
|
||||
|
||||
func (b *billDouble) start(t *testing.T) string {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"available": b.available})
|
||||
})
|
||||
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&b.usages, 1)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
b.mu.Lock()
|
||||
b.usageOrg, b.usageBody = r.Header.Get("X-IAM-Org-Id"), body
|
||||
b.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"transactionId":"tx_1","type":"usage"}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func (b *billDouble) debits() int32 { return atomic.LoadInt32(&b.usages) }
|
||||
func (b *billDouble) lastDebit() (string, []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.usageOrg, b.usageBody
|
||||
}
|
||||
|
||||
func newBilledMLSvc(t *testing.T, commerceURL string) *svc {
|
||||
t.Helper()
|
||||
log := luxlog.New("module", "mlbilltest")
|
||||
m, err := metering.New(metering.Config{BaseURL: commerceURL, Token: "svc-token", Org: "hanzo"})
|
||||
if err != nil {
|
||||
t.Fatalf("metering.New: %v", err)
|
||||
}
|
||||
return &svc{
|
||||
log: log,
|
||||
hc: &http.Client{},
|
||||
dyn: dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()),
|
||||
bill: cloud.NewResourceMeter(cloud.Deps{Logger: log, Metering: m, Env: "mainnet"}, "compute"),
|
||||
}
|
||||
}
|
||||
|
||||
func postTrainJob(t *testing.T, s *svc, org string) *http.Response {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Post("/v1/train/jobs", s.create(jobKind))
|
||||
body := `{"name":"job1","spec":{"runtime":"torch"}}`
|
||||
req, _ := http.NewRequest("POST", "/v1/train/jobs", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Unfunded org → 402, and NO compute is started: the gate runs before
|
||||
// ensureNamespace/Create, so the fake k8s client is never touched and nothing is
|
||||
// billed. This closes the free-GPU hole.
|
||||
func TestComputeCreate_RefusesUnfundedOrg(t *testing.T) {
|
||||
bd := &billDouble{available: 0}
|
||||
s := newBilledMLSvc(t, bd.start(t))
|
||||
|
||||
resp := postTrainJob(t, s, "acme")
|
||||
if resp.StatusCode != http.StatusPaymentRequired {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d body=%s, want 402", resp.StatusCode, body)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), `"code":"insufficient_balance"`) {
|
||||
t.Fatalf("body %s missing insufficient_balance code", body)
|
||||
}
|
||||
if bd.debits() != 0 {
|
||||
t.Fatalf("debits = %d for a refused compute request, want 0", bd.debits())
|
||||
}
|
||||
}
|
||||
|
||||
// Funded org → 201, TrainJob created, and the CALLER org (acme, not the default
|
||||
// hanzo) is debited the compute fee under provider "compute".
|
||||
func TestComputeCreate_AllowsAndDebitsCallerOrg(t *testing.T) {
|
||||
bd := &billDouble{available: 100000}
|
||||
s := newBilledMLSvc(t, bd.start(t))
|
||||
|
||||
resp := postTrainJob(t, s, "acme")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d body=%s, want 201", resp.StatusCode, body)
|
||||
}
|
||||
if !waitForDebit(func() bool { return bd.debits() == 1 }) {
|
||||
t.Fatalf("debits = %d, want 1 (a successful compute create must bill)", bd.debits())
|
||||
}
|
||||
org, raw := bd.lastDebit()
|
||||
if org != "acme" {
|
||||
t.Fatalf("debited org %q, want caller %q (never the default 'hanzo')", org, "acme")
|
||||
}
|
||||
var u struct {
|
||||
User string `json:"user"`
|
||||
Amount int64 `json:"amount"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &u)
|
||||
if u.User != "acme" {
|
||||
t.Fatalf("debit user = %q, want caller org %q", u.User, "acme")
|
||||
}
|
||||
if u.Amount != cloud.DefaultResourceFeeCents {
|
||||
t.Fatalf("debit amount = %d, want default fee %d", u.Amount, cloud.DefaultResourceFeeCents)
|
||||
}
|
||||
if u.Provider != "compute" {
|
||||
t.Fatalf("debit provider = %q, want %q", u.Provider, "compute")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForDebit(cond func() bool) bool {
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return true
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
return cond()
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// Package mlsvc mounts the Hanzo Cloud /v1/ml/* and /v1/train/* surfaces: a
|
||||
// thin, tenant-scoped bridge that turns three Kubeflow-family CustomResources
|
||||
// into a small REST API. No ML logic is reimplemented here — the operators
|
||||
// (kserve, trainer, katib) own reconciliation; this subsystem only translates
|
||||
// REST <-> the Kubernetes API and enforces tenant isolation.
|
||||
//
|
||||
// Three resources, one CRUD shape each (kserve names are internal/opaque — the
|
||||
// user-facing model catalog lives in the hub, never here, so no upstream model
|
||||
// identity is ever introduced by this layer):
|
||||
//
|
||||
// /v1/ml/models InferenceService serving.kserve.io/v1beta1
|
||||
// /v1/train/jobs TrainJob trainer.kubeflow.org/v1alpha1
|
||||
// /v1/train/experiments Experiment kubeflow.org/v1beta1 (katib)
|
||||
//
|
||||
// Plus two leaf surfaces: POST /v1/ml/models/{name}/predict proxies the request
|
||||
// body to the model's kserve v2 data plane (/v2/models/{name}/infer at the
|
||||
// InferenceService's cluster-internal address), and
|
||||
// GET /v1/train/experiments/{name}/trials lists the katib Trials owned by an
|
||||
// experiment.
|
||||
//
|
||||
// Tenancy: every request is scoped to the gateway-minted org (X-Org-Id /
|
||||
// c.Org()) and lands in a PER-ORG Kubernetes namespace ("ml-"<org>). The
|
||||
// namespace IS the tenant boundary — an org physically cannot name into,
|
||||
// list, read, mutate or predict against another org's resources because the
|
||||
// dynamic client is always pinned to the caller's namespace. The org slug is
|
||||
// validated against a strict DNS-label regex (no lossy sanitize), so the
|
||||
// org->namespace map is injective: two distinct orgs can never fold onto one
|
||||
// namespace. Empty org is rejected 403 unless the caller is a gateway-minted
|
||||
// admin (bucketed under the literal "ml-admin" namespace).
|
||||
//
|
||||
// k8s client: built in-process from the in-cluster service account
|
||||
// (rest.InClusterConfig) with a KUBECONFIG fallback for local/dev. It is NOT
|
||||
// hung off the shared cloud.Deps: a raw Kubernetes client has none of the
|
||||
// in-process/ZAP-RPC duality the Deps inter-subsystem clients model, and it is
|
||||
// used by exactly this one subsystem — so it stays self-contained here, the
|
||||
// same way provisioningsvc builds its own backend clients. When no kubeconfig
|
||||
// is resolvable the subsystem mounts anyway and every endpoint fails closed:
|
||||
// mutating routes return 503 and the health routes report status "degraded"
|
||||
// with the real init error (never a fake success).
|
||||
package ml
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
k8stypes "k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// GroupVersionResources for the three managed CRDs (+ trials and core
|
||||
// namespaces). These are the single source of truth for the wire identity of
|
||||
// each resource; a typo here silently breaks every call, so they are asserted
|
||||
// in the tests.
|
||||
var (
|
||||
isvcGVR = schema.GroupVersionResource{Group: "serving.kserve.io", Version: "v1beta1", Resource: "inferenceservices"}
|
||||
trainjobGVR = schema.GroupVersionResource{Group: "trainer.kubeflow.org", Version: "v1alpha1", Resource: "trainjobs"}
|
||||
experimentGVR = schema.GroupVersionResource{Group: "kubeflow.org", Version: "v1beta1", Resource: "experiments"}
|
||||
trialGVR = schema.GroupVersionResource{Group: "kubeflow.org", Version: "v1beta1", Resource: "trials"}
|
||||
nsGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
|
||||
)
|
||||
|
||||
// resourceKind binds a GVR to the apiVersion/kind strings needed to build an
|
||||
// unstructured object on create. One value per user-facing resource family.
|
||||
type resourceKind struct {
|
||||
gvr schema.GroupVersionResource
|
||||
apiVersion string
|
||||
kind string
|
||||
}
|
||||
|
||||
var (
|
||||
modelKind = resourceKind{isvcGVR, "serving.kserve.io/v1beta1", "InferenceService"}
|
||||
jobKind = resourceKind{trainjobGVR, "trainer.kubeflow.org/v1alpha1", "TrainJob"}
|
||||
expKind = resourceKind{experimentGVR, "kubeflow.org/v1beta1", "Experiment"}
|
||||
)
|
||||
|
||||
const (
|
||||
managedByLabel = "app.kubernetes.io/managed-by"
|
||||
managedByValue = "hanzo-cloud"
|
||||
orgLabel = "hanzo.ai/org"
|
||||
katibExpLabel = "katib.kubeflow.org/experiment"
|
||||
nsPrefix = "ml-"
|
||||
predictBodyCap = 32 << 20 // 32 MiB ceiling on a predictor response read
|
||||
predictTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// nameRE constrains a user-supplied resource name to a DNS-1123 label (the
|
||||
// shape every CR metadata.name must satisfy). Validated at the boundary; it is
|
||||
// the injection guard for the path/object name.
|
||||
var nameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
// orgRE constrains the gateway-minted org slug. Strict (no lossy folding) so
|
||||
// "ml-"<org> is an injective tenant->namespace map and stays a valid DNS-1123
|
||||
// label (<=63 chars: "ml-" + <=42).
|
||||
var orgRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?$`)
|
||||
|
||||
// computeFeeEnvPrefix is the operator knob for the per-create compute fee. The
|
||||
// effective fee is cloud.ResourceFeeCents(computeFeeEnvPrefix, kind): a per-kind
|
||||
// override (e.g. CLOUD_COMPUTE_FEE_CENTS_TRAINJOB=…) wins over the global
|
||||
// CLOUD_COMPUTE_FEE_CENTS, else the $1.00 default. Set a kind to 0 to make it
|
||||
// free (and therefore un-gated).
|
||||
//
|
||||
// This is the create/submission fee. A TrainJob's ongoing GPU-hour cost
|
||||
// (hanzoai/pricing infrastructure.compute centsPerHour) is billed by REUSING
|
||||
// s.bill.Meter with a runtime-derived amount from a future usage watcher — never
|
||||
// fabricated here.
|
||||
const computeFeeEnvPrefix = "CLOUD_COMPUTE_FEE_CENTS"
|
||||
|
||||
type svc struct {
|
||||
dyn dynamic.Interface // nil when no kubeconfig resolved (fail-closed)
|
||||
initErr string // why dyn is nil, surfaced by health
|
||||
hc *http.Client // predictor data-plane client (inference latency)
|
||||
log luxlog.Logger
|
||||
// bill is the shared per-org resource gate+meter (reuses deps.Metering, the
|
||||
// one commerce client). Nil/!Enabled() makes Gate allow and Meter a no-op.
|
||||
bill *cloud.ResourceMeter
|
||||
}
|
||||
|
||||
// Mount wires the /v1/ml/* and /v1/train/* surfaces onto app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("ml.Mount: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("ml.Mount: nil deps.Logger")
|
||||
}
|
||||
log := deps.Logger.New("subsystem", "ml")
|
||||
|
||||
s := &svc{log: log, hc: &http.Client{Timeout: predictTimeout}, bill: cloud.NewResourceMeter(deps, "compute")}
|
||||
if dyn, err := newDynamic(); err != nil {
|
||||
s.initErr = err.Error()
|
||||
log.Warn("kubernetes client unavailable; ml/train endpoints will fail closed", "err", err)
|
||||
} else {
|
||||
s.dyn = dyn
|
||||
}
|
||||
|
||||
// Models (kserve InferenceService).
|
||||
app.Get("/v1/ml/models", s.list(modelKind))
|
||||
app.Post("/v1/ml/models", s.create(modelKind))
|
||||
app.Get("/v1/ml/models/:name", s.get(modelKind))
|
||||
app.Patch("/v1/ml/models/:name", s.patch(modelKind))
|
||||
app.Delete("/v1/ml/models/:name", s.del(modelKind))
|
||||
app.Post("/v1/ml/models/:name/predict", s.predict)
|
||||
|
||||
// Training jobs (trainer TrainJob).
|
||||
app.Get("/v1/train/jobs", s.list(jobKind))
|
||||
app.Post("/v1/train/jobs", s.create(jobKind))
|
||||
app.Get("/v1/train/jobs/:name", s.get(jobKind))
|
||||
app.Delete("/v1/train/jobs/:name", s.del(jobKind))
|
||||
|
||||
// Experiments + trials (katib).
|
||||
app.Get("/v1/train/experiments", s.list(expKind))
|
||||
app.Post("/v1/train/experiments", s.create(expKind))
|
||||
app.Get("/v1/train/experiments/:name", s.get(expKind))
|
||||
app.Delete("/v1/train/experiments/:name", s.del(expKind))
|
||||
app.Get("/v1/train/experiments/:name/trials", s.trials)
|
||||
|
||||
// Real-probe health (the generic auto-health from serve.go lands at the
|
||||
// harmless, unrouted /v1/mlsvc/health for the registered subsystem name;
|
||||
// these two report ACTUAL k8s reachability + CRD presence).
|
||||
app.Get("/v1/ml/health", s.health("ml", isvcGVR))
|
||||
app.Get("/v1/train/health", s.health("train", trainjobGVR, experimentGVR))
|
||||
|
||||
log.Info("ml/train surface mounted", "k8s", s.dyn != nil, "brand", deps.Brand, "env", deps.Env, "billing", s.bill.Enabled())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Registered under the name "mlsvc" (not "ml"/"train") on purpose: serve.go
|
||||
// auto-mounts a generic GET /v1/<name>/health BEFORE MountAll, and zip/fiber is
|
||||
// first-match-wins, so a name of "ml" would shadow our real-probe /v1/ml/health.
|
||||
// "mlsvc" keeps the generic liveness at the unrouted /v1/mlsvc/health and lets
|
||||
// the real probes own /v1/ml/health and /v1/train/health. Order 130 binds the
|
||||
// /v1/ml and /v1/train families before the AI subsystem's /v1/* catch-all (150).
|
||||
func init() {
|
||||
cloud.Register("mlsvc", 130, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("ml.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
|
||||
// ── CRUD (generic across the three kinds) ────────────────────────────────────
|
||||
|
||||
func (s *svc) list(k resourceKind) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
ns, _, err := s.tenant(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ul, err := s.dyn.Resource(k.gvr).Namespace(ns).List(c.Context(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) { // tenant namespace not created yet
|
||||
return c.JSON(http.StatusOK, map[string]any{"items": []any{}})
|
||||
}
|
||||
return s.k8sErr(c, k, "list", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"items": viewList(ul.Items)})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) create(k resourceKind) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
ns, org, err := s.tenant(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||
return zip.Errorf(http.StatusBadRequest, "invalid JSON body: %v", err)
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if !nameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("'name' must be a DNS-1123 label: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
}
|
||||
if len(req.Spec) == 0 {
|
||||
return zip.ErrBadRequest("'spec' is required (the " + k.kind + " spec)")
|
||||
}
|
||||
var spec map[string]any
|
||||
if err := json.Unmarshal(req.Spec, &spec); err != nil {
|
||||
return zip.Errorf(http.StatusBadRequest, "invalid 'spec': %v", err)
|
||||
}
|
||||
|
||||
// Pre-create balance gate (fail-closed, per-org). Refuse BEFORE the tenant
|
||||
// namespace or CR is created so an unfunded org cannot run free GPU
|
||||
// compute; an unreachable commerce refuses 503 (default fail-closed).
|
||||
// Scoped to THIS caller's org (the same slug that maps to the tenant
|
||||
// namespace, derived by #66's identity sanitizer from a validated JWT),
|
||||
// so billing can never target another tenant. fee is reused by the
|
||||
// post-success debit; fee==0 or unconfigured billing makes this a no-op.
|
||||
fee := cloud.ResourceFeeCents(computeFeeEnvPrefix, k.kind)
|
||||
if err := s.bill.Gate(c.Context(), org, k.kind, fee); err != nil {
|
||||
return cloud.DenyResource(c, err)
|
||||
}
|
||||
|
||||
if err := s.ensureNamespace(c.Context(), ns, org); err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "ensure tenant namespace: %v", err)
|
||||
}
|
||||
obj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": k.apiVersion,
|
||||
"kind": k.kind,
|
||||
"metadata": map[string]any{
|
||||
"name": name,
|
||||
"namespace": ns,
|
||||
"labels": labelsFor(org, req.Labels),
|
||||
},
|
||||
"spec": spec,
|
||||
}}
|
||||
out, err := s.dyn.Resource(k.gvr).Namespace(ns).Create(c.Context(), obj, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
switch {
|
||||
case apierrors.IsAlreadyExists(err):
|
||||
return zip.ErrConflict(k.kind + " already exists")
|
||||
case apierrors.IsInvalid(err), apierrors.IsBadRequest(err):
|
||||
return zip.Errorf(http.StatusUnprocessableEntity, "%s rejected by kubernetes: %v", k.kind, err)
|
||||
default:
|
||||
return s.k8sErr(c, k, "create", err)
|
||||
}
|
||||
}
|
||||
// Resource created — debit the caller's org ledger for the compute
|
||||
// submission (per-org, env-attributed, async best-effort). Ongoing
|
||||
// GPU-hour cost reuses s.bill.Meter from a future runtime usage watcher.
|
||||
s.bill.Meter(org, k.kind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
return c.JSON(http.StatusCreated, view(out, true))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) get(k resourceKind) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
ns, _, err := s.tenant(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := s.dyn.Resource(k.gvr).Namespace(ns).Get(c.Context(), reqName(c), metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return zip.ErrNotFound(k.kind + " not found")
|
||||
}
|
||||
return s.k8sErr(c, k, "get", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, view(out, true))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) patch(k resourceKind) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
ns, _, err := s.tenant(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := c.Body()
|
||||
if len(body) == 0 {
|
||||
return zip.ErrBadRequest("empty patch body (send a JSON merge patch)")
|
||||
}
|
||||
out, err := s.dyn.Resource(k.gvr).Namespace(ns).Patch(c.Context(), reqName(c), k8stypes.MergePatchType, body, metav1.PatchOptions{})
|
||||
if err != nil {
|
||||
switch {
|
||||
case apierrors.IsNotFound(err):
|
||||
return zip.ErrNotFound(k.kind + " not found")
|
||||
case apierrors.IsInvalid(err), apierrors.IsBadRequest(err):
|
||||
return zip.Errorf(http.StatusUnprocessableEntity, "patch rejected by kubernetes: %v", err)
|
||||
default:
|
||||
return s.k8sErr(c, k, "patch", err)
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusOK, view(out, true))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) del(k resourceKind) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
ns, _, err := s.tenant(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.dyn.Resource(k.gvr).Namespace(ns).Delete(c.Context(), reqName(c), metav1.DeleteOptions{}); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return zip.ErrNotFound(k.kind + " not found")
|
||||
}
|
||||
return s.k8sErr(c, k, "delete", err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// ── leaf surfaces ────────────────────────────────────────────────────────────
|
||||
|
||||
// predict proxies the request body to the model's kserve v2 data plane. The v2
|
||||
// model name defaults to the InferenceService name (kserve's single-model
|
||||
// convention) and may be overridden with ?model=. The predictor's status + body
|
||||
// are returned verbatim so a model-side error surfaces honestly.
|
||||
func (s *svc) predict(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
ns, _, err := s.tenant(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
obj, err := s.dyn.Resource(isvcGVR).Namespace(ns).Get(c.Context(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return zip.ErrNotFound("model not found")
|
||||
}
|
||||
return s.k8sErr(c, modelKind, "get", err)
|
||||
}
|
||||
addr := internalURL(obj)
|
||||
if addr == "" {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "model %q is not ready (no serving address yet)", name)
|
||||
}
|
||||
model := strings.TrimSpace(c.Query("model"))
|
||||
if model == "" {
|
||||
model = name
|
||||
}
|
||||
target := strings.TrimRight(addr, "/") + "/v2/models/" + model + "/infer"
|
||||
req, err := http.NewRequestWithContext(c.Context(), http.MethodPost, target, bytes.NewReader(c.Body()))
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "build predict request: %v", err)
|
||||
}
|
||||
ct := c.Header("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "application/json"
|
||||
}
|
||||
req.Header.Set("Content-Type", ct)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.hc.Do(req)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "predict: model data plane unreachable: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, predictBodyCap))
|
||||
if respCT := resp.Header.Get("Content-Type"); respCT != "" {
|
||||
c.SetHeader("Content-Type", respCT)
|
||||
}
|
||||
return c.Bytes(resp.StatusCode, rb)
|
||||
}
|
||||
|
||||
// trials lists the katib Trials owned by an experiment in the caller's tenant
|
||||
// namespace. The experiment is fetched first so a cross-tenant or missing name
|
||||
// is a clean 404 rather than an empty list.
|
||||
func (s *svc) trials(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
ns, _, err := s.tenant(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqName(c)
|
||||
if _, err := s.dyn.Resource(experimentGVR).Namespace(ns).Get(c.Context(), name, metav1.GetOptions{}); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return zip.ErrNotFound("experiment not found")
|
||||
}
|
||||
return s.k8sErr(c, expKind, "get", err)
|
||||
}
|
||||
ul, err := s.dyn.Resource(trialGVR).Namespace(ns).List(c.Context(), metav1.ListOptions{
|
||||
LabelSelector: katibExpLabel + "=" + name,
|
||||
})
|
||||
if err != nil {
|
||||
return s.k8sErr(c, resourceKind{trialGVR, "kubeflow.org/v1beta1", "Trial"}, "list", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"experiment": name, "items": viewList(ul.Items)})
|
||||
}
|
||||
|
||||
// health is a REAL probe: it verifies the API server is reachable and that the
|
||||
// subsystem's CRDs are served, and reports the actual state. 200 only when
|
||||
// everything is ok; 503 + the real reason otherwise (never status-theater).
|
||||
func (s *svc) health(name string, gvrs ...schema.GroupVersionResource) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
res := map[string]any{"service": name, "status": "ok"}
|
||||
if s.dyn == nil {
|
||||
res["status"], res["k8s"], res["error"] = "degraded", false, s.initErr
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
ctx := c.Context()
|
||||
if _, err := s.dyn.Resource(nsGVR).List(ctx, metav1.ListOptions{Limit: 1}); err != nil {
|
||||
res["status"], res["k8s"], res["error"] = "degraded", false, err.Error()
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
res["k8s"] = true
|
||||
crds := map[string]bool{}
|
||||
allOK := true
|
||||
for _, g := range gvrs {
|
||||
_, err := s.dyn.Resource(g).Namespace(metav1.NamespaceDefault).List(ctx, metav1.ListOptions{Limit: 1})
|
||||
crds[g.Resource] = err == nil
|
||||
if err != nil {
|
||||
allOK = false
|
||||
}
|
||||
}
|
||||
res["crds"] = crds
|
||||
if !allOK {
|
||||
res["status"] = "degraded"
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
// ── tenancy + k8s plumbing ───────────────────────────────────────────────────
|
||||
|
||||
// tenant resolves the per-org namespace for a request from the gateway-minted
|
||||
// identity. Pure mapping lives in tenantNS for testability.
|
||||
func (s *svc) tenant(c *zip.Ctx) (ns, org string, err error) {
|
||||
return tenantNS(c.Org(), c.IsAdmin())
|
||||
}
|
||||
|
||||
// tenantNS maps a gateway org slug to its tenant namespace. Empty org is
|
||||
// rejected unless admin (literal "admin" bucket). The org is lowercased and
|
||||
// validated against orgRE with NO lossy sanitize, making org->namespace
|
||||
// injective — two distinct orgs can never fold onto one namespace.
|
||||
func tenantNS(rawOrg string, isAdmin bool) (ns, org string, err error) {
|
||||
org = strings.ToLower(strings.TrimSpace(rawOrg))
|
||||
if org == "" {
|
||||
if isAdmin {
|
||||
return nsPrefix + "admin", "admin", nil
|
||||
}
|
||||
return "", "", zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
if !orgRE.MatchString(org) {
|
||||
return "", "", zip.ErrForbidden("invalid org identifier")
|
||||
}
|
||||
return nsPrefix + org, org, nil
|
||||
}
|
||||
|
||||
// ensureNamespace idempotently creates the tenant namespace before the first
|
||||
// resource lands in it.
|
||||
func (s *svc) ensureNamespace(ctx context.Context, ns, org string) error {
|
||||
if _, err := s.dyn.Resource(nsGVR).Get(ctx, ns, metav1.GetOptions{}); err == nil {
|
||||
return nil
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
obj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Namespace",
|
||||
"metadata": map[string]any{
|
||||
"name": ns,
|
||||
"labels": map[string]any{managedByLabel: managedByValue, orgLabel: org},
|
||||
},
|
||||
}}
|
||||
if _, err := s.dyn.Resource(nsGVR).Create(ctx, obj, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svc) ready() error {
|
||||
if s.dyn == nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "ml: kubernetes client not configured: %s", s.initErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// k8sErr maps a raw API error to an honest gateway-level error. RBAC denials
|
||||
// name the missing access so the operator knows exactly what to grant the
|
||||
// cloud-api service account.
|
||||
func (s *svc) k8sErr(c *zip.Ctx, k resourceKind, op string, err error) error {
|
||||
s.log.Error("k8s op failed", "op", op, "kind", k.kind, "resource", k.gvr.Resource, "err", err)
|
||||
if apierrors.IsForbidden(err) {
|
||||
return zip.Errorf(http.StatusBadGateway,
|
||||
"%s %s: kubernetes RBAC denied (cloud-api service account needs %s on %s.%s): %v",
|
||||
op, k.kind, op, k.gvr.Resource, k.gvr.Group, err)
|
||||
}
|
||||
return zip.Errorf(http.StatusBadGateway, "%s %s failed: %v", op, k.kind, err)
|
||||
}
|
||||
|
||||
// newDynamic builds the dynamic client from the in-cluster service account,
|
||||
// falling back to KUBECONFIG / ~/.kube/config for local/dev.
|
||||
func newDynamic() (dynamic.Interface, error) {
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||
clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{})
|
||||
cfg, err = cc.ClientConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no in-cluster config and no kubeconfig: %w", err)
|
||||
}
|
||||
}
|
||||
cfg.UserAgent = "hanzo-cloud-mlsvc"
|
||||
return dynamic.NewForConfig(cfg)
|
||||
}
|
||||
|
||||
// ── pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
func reqName(c *zip.Ctx) string { return strings.ToLower(strings.TrimSpace(c.Param("name"))) }
|
||||
|
||||
// labelsFor stamps the managed-by + tenant-org labels onto a create. The org
|
||||
// label is set LAST so a caller can never override the tenant boundary marker.
|
||||
func labelsFor(org string, user map[string]string) map[string]any {
|
||||
m := map[string]any{}
|
||||
for k, v := range user {
|
||||
m[k] = v
|
||||
}
|
||||
m[managedByLabel] = managedByValue
|
||||
m[orgLabel] = org
|
||||
return m
|
||||
}
|
||||
|
||||
// view trims a CR to an honest, non-bloated shape: name + creation time + live
|
||||
// status, plus the spec on single-object reads. Namespace is intentionally
|
||||
// omitted (internal tenant detail).
|
||||
func view(obj *unstructured.Unstructured, withSpec bool) map[string]any {
|
||||
m := map[string]any{
|
||||
"name": obj.GetName(),
|
||||
"createdAt": obj.GetCreationTimestamp().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if st, ok, _ := unstructured.NestedMap(obj.Object, "status"); ok {
|
||||
m["status"] = st
|
||||
}
|
||||
if withSpec {
|
||||
if sp, ok, _ := unstructured.NestedMap(obj.Object, "spec"); ok {
|
||||
m["spec"] = sp
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func viewList(items []unstructured.Unstructured) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for i := range items {
|
||||
out = append(out, view(&items[i], false))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// internalURL returns the cluster-internal serving address of an
|
||||
// InferenceService, preferring status.address.url (the in-cluster URL) over
|
||||
// status.url (the external/ingress URL). Empty until the model is ready.
|
||||
func internalURL(obj *unstructured.Unstructured) string {
|
||||
if v, ok, _ := unstructured.NestedString(obj.Object, "status", "address", "url"); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
if v, ok, _ := unstructured.NestedString(obj.Object, "status", "url"); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package ml
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func TestTenantNS(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
org string
|
||||
admin bool
|
||||
wantNS string
|
||||
wantOrg string
|
||||
wantError bool
|
||||
}{
|
||||
{"plain org", "acme", false, "ml-acme", "acme", false},
|
||||
{"uppercase is lowered", "ACME", false, "ml-acme", "acme", false},
|
||||
{"whitespace trimmed", " acme ", false, "ml-acme", "acme", false},
|
||||
{"hyphenated org", "acme-corp", false, "ml-acme-corp", "acme-corp", false},
|
||||
{"empty + admin -> admin bucket", "", true, "ml-admin", "admin", false},
|
||||
{"empty + non-admin -> 403", "", false, "", "", true},
|
||||
{"underscore rejected", "acme_corp", false, "", "", true},
|
||||
{"bang rejected", "acme!", false, "", "", true},
|
||||
{"leading hyphen rejected", "-acme", false, "", "", true},
|
||||
{"trailing hyphen rejected", "acme-", false, "", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ns, org, err := tenantNS(tc.org, tc.admin)
|
||||
if tc.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for org=%q admin=%v", tc.org, tc.admin)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ns != tc.wantNS || org != tc.wantOrg {
|
||||
t.Fatalf("tenantNS(%q,%v) = (%q,%q), want (%q,%q)", tc.org, tc.admin, ns, org, tc.wantNS, tc.wantOrg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantNSInjective is the cross-tenant fold guard: distinct org slugs must
|
||||
// map to distinct namespaces (no lossy sanitize collapsing two tenants onto
|
||||
// one namespace, which would be cross-tenant model access).
|
||||
func TestTenantNSInjective(t *testing.T) {
|
||||
a, _, errA := tenantNS("a-b", false)
|
||||
b, _, errB := tenantNS("a--b", false)
|
||||
if errA != nil || errB != nil {
|
||||
t.Fatalf("both should be valid: %v %v", errA, errB)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatalf("distinct orgs folded to one namespace: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameRE(t *testing.T) {
|
||||
valid := []string{"a", "foo", "foo-bar", "model-1", "abc123", "a-b-c-d"}
|
||||
invalid := []string{"", "-foo", "foo-", "Foo", "foo_bar", "a.b", "foo bar",
|
||||
"this-name-is-way-too-long-to-be-a-valid-dns-1123-label-because-it-exceeds-the-sixty-three-character-limit"}
|
||||
for _, v := range valid {
|
||||
if !nameRE.MatchString(v) {
|
||||
t.Errorf("expected %q valid", v)
|
||||
}
|
||||
}
|
||||
for _, v := range invalid {
|
||||
if nameRE.MatchString(v) {
|
||||
t.Errorf("expected %q invalid", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLabelsForTenantBoundary proves a caller cannot override the tenant org
|
||||
// label or the managed-by marker via user-supplied labels.
|
||||
func TestLabelsForTenantBoundary(t *testing.T) {
|
||||
got := labelsFor("acme", map[string]string{
|
||||
"team": "ml",
|
||||
orgLabel: "evil-tenant", // attempted override
|
||||
managedByLabel: "attacker", // attempted override
|
||||
})
|
||||
if got[orgLabel] != "acme" {
|
||||
t.Fatalf("org label override leaked: %v", got[orgLabel])
|
||||
}
|
||||
if got[managedByLabel] != managedByValue {
|
||||
t.Fatalf("managed-by override leaked: %v", got[managedByLabel])
|
||||
}
|
||||
if got["team"] != "ml" {
|
||||
t.Fatalf("benign user label dropped: %v", got["team"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestView(t *testing.T) {
|
||||
obj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "serving.kserve.io/v1beta1",
|
||||
"kind": "InferenceService",
|
||||
"metadata": map[string]any{"name": "m1", "namespace": "ml-acme"},
|
||||
"spec": map[string]any{"predictor": map[string]any{"model": map[string]any{"runtime": "x"}}},
|
||||
"status": map[string]any{"url": "http://m1.ml-acme.svc.cluster.local"},
|
||||
}}
|
||||
|
||||
noSpec := view(obj, false)
|
||||
if noSpec["name"] != "m1" {
|
||||
t.Fatalf("name = %v", noSpec["name"])
|
||||
}
|
||||
if _, ok := noSpec["status"]; !ok {
|
||||
t.Fatal("status must be present in list view")
|
||||
}
|
||||
if _, ok := noSpec["spec"]; ok {
|
||||
t.Fatal("spec must NOT be present in list view")
|
||||
}
|
||||
if _, ok := noSpec["createdAt"]; !ok {
|
||||
t.Fatal("createdAt must be present")
|
||||
}
|
||||
// Namespace is an internal tenant detail and must never be echoed.
|
||||
if _, ok := noSpec["namespace"]; ok {
|
||||
t.Fatal("namespace must not be echoed")
|
||||
}
|
||||
|
||||
withSpec := view(obj, true)
|
||||
if _, ok := withSpec["spec"]; !ok {
|
||||
t.Fatal("spec must be present in single-object view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalURL(t *testing.T) {
|
||||
// address.url (internal) wins over url (external).
|
||||
both := &unstructured.Unstructured{Object: map[string]any{"status": map[string]any{
|
||||
"url": "http://external.example.com",
|
||||
"address": map[string]any{"url": "http://m1.ml-acme.svc.cluster.local"},
|
||||
}}}
|
||||
if got := internalURL(both); got != "http://m1.ml-acme.svc.cluster.local" {
|
||||
t.Fatalf("address.url should win, got %q", got)
|
||||
}
|
||||
// falls back to url.
|
||||
only := &unstructured.Unstructured{Object: map[string]any{"status": map[string]any{
|
||||
"url": "http://external.example.com",
|
||||
}}}
|
||||
if got := internalURL(only); got != "http://external.example.com" {
|
||||
t.Fatalf("should fall back to url, got %q", got)
|
||||
}
|
||||
// empty when not ready.
|
||||
none := &unstructured.Unstructured{Object: map[string]any{"status": map[string]any{}}}
|
||||
if got := internalURL(none); got != "" {
|
||||
t.Fatalf("unready model should have no address, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGVRs pins the wire identity of every managed resource — a typo here
|
||||
// silently breaks every call against that CRD.
|
||||
func TestGVRs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
group, ver, res string
|
||||
got [3]string
|
||||
}{
|
||||
{"InferenceService", "serving.kserve.io", "v1beta1", "inferenceservices",
|
||||
[3]string{isvcGVR.Group, isvcGVR.Version, isvcGVR.Resource}},
|
||||
{"TrainJob", "trainer.kubeflow.org", "v1alpha1", "trainjobs",
|
||||
[3]string{trainjobGVR.Group, trainjobGVR.Version, trainjobGVR.Resource}},
|
||||
{"Experiment", "kubeflow.org", "v1beta1", "experiments",
|
||||
[3]string{experimentGVR.Group, experimentGVR.Version, experimentGVR.Resource}},
|
||||
{"Trial", "kubeflow.org", "v1beta1", "trials",
|
||||
[3]string{trialGVR.Group, trialGVR.Version, trialGVR.Resource}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
want := [3]string{tc.group, tc.ver, tc.res}
|
||||
if tc.got != want {
|
||||
t.Errorf("%s GVR = %v, want %v", tc.name, tc.got, want)
|
||||
}
|
||||
}
|
||||
// create kinds must carry apiVersion = group/version and the right kind.
|
||||
if modelKind.apiVersion != "serving.kserve.io/v1beta1" || modelKind.kind != "InferenceService" {
|
||||
t.Errorf("modelKind wrong: %+v", modelKind)
|
||||
}
|
||||
if jobKind.apiVersion != "trainer.kubeflow.org/v1alpha1" || jobKind.kind != "TrainJob" {
|
||||
t.Errorf("jobKind wrong: %+v", jobKind)
|
||||
}
|
||||
if expKind.apiVersion != "kubeflow.org/v1beta1" || expKind.kind != "Experiment" {
|
||||
t.Errorf("expKind wrong: %+v", expKind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package o11y initializes the o11y subsystem's runtime handler in the
|
||||
// unified cloud binary.
|
||||
//
|
||||
// hanzoai/o11y registers the /v1/o11y/* route surface (order 70) but delegates
|
||||
// every request to a handler installed via o11y.SetHandler. The standalone
|
||||
// o11y cmd/server constructs the full runtime in-process and installs its own
|
||||
// PublicHandler. The cloud binary does NOT construct that heavy runtime
|
||||
// (telemetry stores, rule manager, opamp, websockets) a second time — a
|
||||
// dedicated o11y Deployment already runs it. Instead, cloud installs a reverse
|
||||
// proxy to that deployment as the handler, so /v1/o11y/* serves real telemetry
|
||||
// instead of the "o11y runtime not initialized" 503.
|
||||
//
|
||||
// Path is preserved verbatim: /v1/o11y/* is forwarded unchanged to the o11y
|
||||
// runtime, which rewrites /v1/o11y/* -> /api/* internally (see o11y
|
||||
// app.createPublicServer). The gateway terminates auth and propagates identity
|
||||
// as X-* headers, which the proxy forwards.
|
||||
package o11y
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/o11y"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// defaultUpstream is the in-cluster address of the o11y runtime Deployment's
|
||||
// Service (port 80 -> container 8080). Overridable via O11Y_UPSTREAM.
|
||||
const defaultUpstream = "http://o11y.hanzo.svc.cluster.local:80"
|
||||
|
||||
func upstream() string {
|
||||
if v := strings.TrimSpace(os.Getenv("O11Y_UPSTREAM")); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultUpstream
|
||||
}
|
||||
|
||||
// newHandler builds the reverse-proxy handler targeting the o11y runtime. Pure
|
||||
// (URL in, handler out) so it is unit-testable without a live upstream.
|
||||
//
|
||||
// The path is forwarded UNCHANGED: the o11y runtime registers its routes at
|
||||
// their exact public path (/v1/o11y/<version>/<path>) — no /api/, no rewrite.
|
||||
// One and one way: the route IS the path, on both sides of this proxy.
|
||||
func newHandler(rawURL string) (http.Handler, error) {
|
||||
target, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
base := proxy.Director
|
||||
proxy.Director = func(r *http.Request) {
|
||||
base(r) // sets scheme/host to target; path unchanged
|
||||
r.Host = target.Host // upstream vhost, not api.hanzo.ai
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Order 71: after o11y.Mount (70) installs the route surface. Ordering is not
|
||||
// strictly required (the handler is resolved per-request) but keeps the
|
||||
// runtime install adjacent to its routes.
|
||||
cloud.Register("o11y-runtime", 71, func(_ any, deps cloud.Deps) error {
|
||||
h, err := newHandler(upstream())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o11y.SetHandler(h)
|
||||
if deps.Logger != nil {
|
||||
deps.Logger.New("subsystem", "o11y-runtime").
|
||||
Info("o11y runtime handler installed (reverse proxy)", "upstream", upstream())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Mount is a no-op kept for symmetry with other subsystems; the handler is
|
||||
// installed in init via cloud.Register. It satisfies callers that look for a
|
||||
// Mount(app, deps) entrypoint.
|
||||
func Mount(_ *zip.App, _ cloud.Deps) error { return nil }
|
||||
@@ -0,0 +1,62 @@
|
||||
package o11y
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newHandler must forward the request path verbatim to the upstream and return
|
||||
// its response — the behavior that turns the o11y 503 stub into real telemetry.
|
||||
func TestNewHandlerProxiesPathVerbatim(t *testing.T) {
|
||||
var gotPath, gotHost string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotHost = r.Host
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"status":"ok"}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
h, err := newHandler(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("newHandler: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://api.hanzo.ai/v1/o11y/v3/query_range", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
// Path forwarded VERBATIM — the o11y runtime registers routes at their exact
|
||||
// public path (/v1/o11y/*). No /api/, no rewrite.
|
||||
if gotPath != "/v1/o11y/v3/query_range" {
|
||||
t.Fatalf("upstream path = %q, want /v1/o11y/v3/query_range (verbatim, no rewrite)", gotPath)
|
||||
}
|
||||
if gotHost == "api.hanzo.ai" {
|
||||
t.Fatalf("upstream Host = %q, want the upstream vhost (not the edge host)", gotHost)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Fatalf("content-type = %q, want application/json", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHandlerRejectsBadURL(t *testing.T) {
|
||||
if _, err := newHandler("://nope"); err == nil {
|
||||
t.Fatal("expected error for malformed upstream URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamDefault(t *testing.T) {
|
||||
t.Setenv("O11Y_UPSTREAM", "")
|
||||
if got := upstream(); got != defaultUpstream {
|
||||
t.Fatalf("upstream() = %q, want default %q", got, defaultUpstream)
|
||||
}
|
||||
t.Setenv("O11Y_UPSTREAM", "http://example:9000")
|
||||
if got := upstream(); got != "http://example:9000" {
|
||||
t.Fatalf("upstream() = %q, want override", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Package paassvc mounts the native, in-process Hanzo PaaS control plane at
|
||||
// /v1/paas/*: the "one and only one way to deploy" made native to the cloud
|
||||
// binary. It is the Go port of the standalone Dokploy-based platform's
|
||||
// build→deploy lifecycle (pkg/platform/src/services/ci/deploy-executor.ts +
|
||||
// services/apps/inventory.ts + db/schema/apps-drift.ts), collapsed into an
|
||||
// in-process cloud subsystem exactly like clients/ml is the k8s bridge for the
|
||||
// Kubeflow CRDs.
|
||||
//
|
||||
// The deploy mechanism is the SAME one the operator already reconciles: a
|
||||
// merge-patch of the operator `Service` CR's `.spec.image`. No second deployer
|
||||
// is invented; the Hanzo operator owns the rollout. This module only observes
|
||||
// the declared/running/latest tags per service (the drift board) and flips the
|
||||
// one CR field a deploy changes — the identical contract the Node deploy-executor
|
||||
// implemented, now native.
|
||||
//
|
||||
// drift.go is the PURE half: it derives the drift verdict for one observed
|
||||
// service row and performs no IO. It is a faithful port of
|
||||
// `pkg/platform/src/db/schema/apps-drift.ts` so the two implementations can never
|
||||
// disagree about what "drift" means (one way to compute drift, period). The
|
||||
// cluster reader (paas.go) owns observing the tags; this file only interprets
|
||||
// them.
|
||||
package paassvc
|
||||
|
||||
import "regexp"
|
||||
|
||||
// semverTagRE is the semver-only policy from the platform contract's constraint 1:
|
||||
// every declared/running tag MUST be exactly `vMAJOR.MINOR.PATCH`. Anything else
|
||||
// (`:main`, `:latest`, `:dev`, `:edge`, `sha-…`, `1.42.33-billing`, …) is a
|
||||
// floating reference. Mirrors the `^v\d+\.\d+\.\d+$` gate the platform's
|
||||
// reconciler enforces at the patch boundary (apps-drift.ts `SEMVER_TAG`).
|
||||
var semverTagRE = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
||||
|
||||
// IsSemverTag reports whether tag is a strict `vX.Y.Z` semver tag.
|
||||
func IsSemverTag(tag string) bool { return tag != "" && semverTagRE.MatchString(tag) }
|
||||
|
||||
// DriftKind enumerates the kinds of drift from the platform contract
|
||||
// (apps-drift.ts `DriftKind`). Each value is independent — one service row can
|
||||
// carry several at once (e.g. a floating running tag with a zero-asset release).
|
||||
type DriftKind string
|
||||
|
||||
const (
|
||||
// DriftStale — declared ≠ latest: a newer release exists but is not declared. (yellow)
|
||||
DriftStale DriftKind = "stale"
|
||||
// DriftUnrolled — running ≠ declared: the cluster has not rolled to the declared tag. (yellow)
|
||||
DriftUnrolled DriftKind = "un-rolled"
|
||||
// DriftFloatingDeclared — declaredTag is not strict semver; the reconciler would refuse it. (red)
|
||||
DriftFloatingDeclared DriftKind = "floating-declared"
|
||||
// DriftFloatingRunning — runningTag is not strict semver; policy violation on the cluster. (red)
|
||||
DriftFloatingRunning DriftKind = "floating-running"
|
||||
// DriftNoRelease — no GH Release found for the declared tag. (red)
|
||||
DriftNoRelease DriftKind = "no-release"
|
||||
// DriftZeroAssets — GH Release exists but shipped 0 assets. (red)
|
||||
DriftZeroAssets DriftKind = "zero-assets"
|
||||
)
|
||||
|
||||
// DriftSeverity is the aggregate drift severity. "ok" = no flags; otherwise the
|
||||
// max over flags.
|
||||
type DriftSeverity string
|
||||
|
||||
const (
|
||||
SeverityOK DriftSeverity = "ok"
|
||||
SeverityYellow DriftSeverity = "yellow"
|
||||
SeverityRed DriftSeverity = "red"
|
||||
)
|
||||
|
||||
// severityOf maps each kind to its severity. Stale/un-rolled are warnings; the
|
||||
// rest are hard drift. Mirrors apps-drift.ts `SEVERITY`.
|
||||
var severityOf = map[DriftKind]DriftSeverity{
|
||||
DriftStale: SeverityYellow,
|
||||
DriftUnrolled: SeverityYellow,
|
||||
DriftFloatingDeclared: SeverityRed,
|
||||
DriftFloatingRunning: SeverityRed,
|
||||
DriftNoRelease: SeverityRed,
|
||||
DriftZeroAssets: SeverityRed,
|
||||
}
|
||||
|
||||
// DriftFlag is a single drift finding: its kind, severity, and a human-readable
|
||||
// reason (apps-drift.ts `DriftFlag`).
|
||||
type DriftFlag struct {
|
||||
Kind DriftKind `json:"kind"`
|
||||
Severity DriftSeverity `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Drift is the drift verdict for one observed service row: the ordered flags plus
|
||||
// the rolled-up severity (apps-drift.ts `Drift`).
|
||||
type Drift struct {
|
||||
Severity DriftSeverity `json:"severity"`
|
||||
Flags []DriftFlag `json:"flags"`
|
||||
}
|
||||
|
||||
// Observed is the minimal set of already-observed tag fields the drift derivation
|
||||
// reads — mirrors the `Pick<App, …>` the TS `computeDrift` accepts. The reader
|
||||
// (paas.go) fills these from the cluster; the release fields are populated by the
|
||||
// GH-release reader (a follow-up), so today they are the honest zero value
|
||||
// (ReleaseURL == "" ⇒ no-release, exactly like the un-populated TS columns).
|
||||
type Observed struct {
|
||||
DeclaredTag string // what SHOULD run — spec.image.tag on the operator Service CR
|
||||
RunningTag string // what ACTUALLY runs — observed from the CR status / Deployment
|
||||
LatestTag string // newest released tag (GH release reader; empty until wired)
|
||||
ReleaseURL string // GH Release URL for DeclaredTag (empty ⇒ no-release)
|
||||
ReleaseAssets int // asset count on the GH Release (0 ⇒ zero-assets)
|
||||
}
|
||||
|
||||
func flag(kind DriftKind, message string) DriftFlag {
|
||||
return DriftFlag{Kind: kind, Severity: severityOf[kind], Message: message}
|
||||
}
|
||||
|
||||
// ComputeDriftFlags derives the drift flags for one observed service row, exactly
|
||||
// per the platform contract (apps-drift.ts `computeDriftFlags`).
|
||||
//
|
||||
// Detection rules (each independent; a row may trip several):
|
||||
//
|
||||
// - floating-declared — DeclaredTag is set but not vX.Y.Z. The reconciler
|
||||
// refuses non-semver declarations, so this is hard drift. (When the
|
||||
// declaration itself is floating, comparing it against LatestTag for "stale"
|
||||
// is meaningless, so stale is suppressed in that case.)
|
||||
// - floating-running — RunningTag is set but not vX.Y.Z: the cluster is running
|
||||
// a floating image. Hard drift.
|
||||
// - stale — DeclaredTag and LatestTag are both known semver and differ: a newer
|
||||
// release exists that is not yet declared.
|
||||
// - un-rolled — DeclaredTag and RunningTag are both known and differ: the
|
||||
// declaration has not reached the cluster yet.
|
||||
// - no-release — a DeclaredTag exists but no GH Release was found (ReleaseURL "").
|
||||
// - zero-assets — a GH Release exists (ReleaseURL set) but ReleaseAssets == 0.
|
||||
//
|
||||
// Tags are compared verbatim (the reader stores reality un-normalized); no
|
||||
// ordering is assumed beyond equality — matching the contract.
|
||||
func ComputeDriftFlags(o Observed) []DriftFlag {
|
||||
var flags []DriftFlag
|
||||
|
||||
declaredFloating := o.DeclaredTag != "" && !IsSemverTag(o.DeclaredTag)
|
||||
runningFloating := o.RunningTag != "" && !IsSemverTag(o.RunningTag)
|
||||
|
||||
if declaredFloating {
|
||||
flags = append(flags, flag(DriftFloatingDeclared,
|
||||
"declared tag \""+o.DeclaredTag+"\" is not semver (vX.Y.Z); the reconciler will refuse it"))
|
||||
}
|
||||
if runningFloating {
|
||||
flags = append(flags, flag(DriftFloatingRunning,
|
||||
"running tag \""+o.RunningTag+"\" is a floating reference, not semver"))
|
||||
}
|
||||
|
||||
// "stale" only makes sense for a semver declaration: declared ≠ latest.
|
||||
if !declaredFloating && o.DeclaredTag != "" && o.LatestTag != "" && o.DeclaredTag != o.LatestTag {
|
||||
flags = append(flags, flag(DriftStale,
|
||||
"declared "+o.DeclaredTag+" is behind latest "+o.LatestTag))
|
||||
}
|
||||
|
||||
// "un-rolled": running ≠ declared (only once running is known and not already
|
||||
// flagged as floating, to avoid double-counting).
|
||||
if !runningFloating && o.DeclaredTag != "" && o.RunningTag != "" && o.RunningTag != o.DeclaredTag {
|
||||
flags = append(flags, flag(DriftUnrolled,
|
||||
"running "+o.RunningTag+" has not rolled to declared "+o.DeclaredTag))
|
||||
}
|
||||
|
||||
// Release-artifact integrity is keyed off the declared tag.
|
||||
if o.DeclaredTag != "" {
|
||||
if o.ReleaseURL == "" {
|
||||
flags = append(flags, flag(DriftNoRelease,
|
||||
"no GH Release found for declared tag "+o.DeclaredTag))
|
||||
} else if o.ReleaseAssets == 0 {
|
||||
flags = append(flags, flag(DriftZeroAssets,
|
||||
"GH Release for "+o.DeclaredTag+" shipped 0 assets"))
|
||||
}
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
// DriftSeverityOf rolls a list of flags up to a single severity (red > yellow >
|
||||
// ok). Mirrors apps-drift.ts `driftSeverity`.
|
||||
func DriftSeverityOf(flags []DriftFlag) DriftSeverity {
|
||||
red, yellow := false, false
|
||||
for _, f := range flags {
|
||||
switch f.Severity {
|
||||
case SeverityRed:
|
||||
red = true
|
||||
case SeverityYellow:
|
||||
yellow = true
|
||||
}
|
||||
}
|
||||
if red {
|
||||
return SeverityRed
|
||||
}
|
||||
if yellow {
|
||||
return SeverityYellow
|
||||
}
|
||||
return SeverityOK
|
||||
}
|
||||
|
||||
// ComputeDrift is the full drift verdict (flags + rolled-up severity) for one
|
||||
// observed service row (apps-drift.ts `computeDrift`). Flags is always non-nil so
|
||||
// the JSON encodes `[]`, never `null`.
|
||||
func ComputeDrift(o Observed) Drift {
|
||||
flags := ComputeDriftFlags(o)
|
||||
if flags == nil {
|
||||
flags = []DriftFlag{}
|
||||
}
|
||||
return Drift{Severity: DriftSeverityOf(flags), Flags: flags}
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
// paas.go — the cluster-facing half of the native Hanzo PaaS control plane.
|
||||
//
|
||||
// It mounts /v1/paas/* on the unified cloud binary and speaks to the SAME
|
||||
// operator surface the standalone platform's deploy-executor drove: the
|
||||
// `hanzo.ai/v1` `services` CustomResource. Two responsibilities, both a straight
|
||||
// port of the Node platform:
|
||||
//
|
||||
// GET /v1/paas/apps — the fleet drift board (inventory.ts): list every
|
||||
// operator Service CR across the platform
|
||||
// namespaces, read declared vs running tag +
|
||||
// health from the CR (+ its status), and attach
|
||||
// the drift verdict (drift.go / apps-drift.ts).
|
||||
// GET /v1/paas/apps/:app — one service row by CR name.
|
||||
// POST /v1/paas/apps/:app/deploy— deploy a new image tag by merge-patching the
|
||||
// Service CR's `.spec.image` (deploy-executor.ts).
|
||||
// The operator reconciles the rollout; cloud never
|
||||
// reimplements a deployer.
|
||||
// GET /v1/paas/health — real k8s reachability + Service CRD presence.
|
||||
//
|
||||
// SECURITY — every route is GLOBAL-ADMIN ONLY, fail-closed, gated on the SAME
|
||||
// predicate the rest of cloud uses: c.IsAdmin() (true only for a JWT-validated
|
||||
// principal whose org is the admin org, matching the gateway's admin-guard — see
|
||||
// clients/admin). Unlike clients/ml (per-tenant namespaces), the PaaS control
|
||||
// plane reads and mutates SYSTEM Service CRs across the whole fleet, so it is
|
||||
// admin-only: a tenant must never patch another org's — or a platform — service.
|
||||
// The user-facing PaaS view lives in console2; users never call this surface.
|
||||
//
|
||||
// k8s client: built in-process from the in-cluster service account
|
||||
// (rest.InClusterConfig) with a KUBECONFIG fallback for local/dev — the identical
|
||||
// construction clients/ml uses. When no kubeconfig is resolvable the subsystem
|
||||
// mounts anyway and every endpoint fails closed (503 + the real init error; the
|
||||
// health route reports "degraded"), never status-theater.
|
||||
package paassvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
k8stypes "k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// servicesGVR is the operator Service CR — the single source of truth for the
|
||||
// declared image of every Hanzo service. Asserted in the tests; a typo here
|
||||
// silently breaks the whole board.
|
||||
var servicesGVR = schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "services"}
|
||||
|
||||
// deploymentsGVR is the live Deployment behind each Service — the source of the
|
||||
// RUNNING tag (the operator Service CR status does not surface the running image,
|
||||
// so the running tag is observed from the Deployment's container, exactly as the
|
||||
// platform inventory reads it in inventory.ts). Read-only for this subsystem.
|
||||
var deploymentsGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
|
||||
|
||||
// nsEnv maps each scanned namespace to the lifecycle env it represents, mirroring
|
||||
// the platform inventory's DEFAULT_TARGETS (inventory.ts): the `hanzo` namespace
|
||||
// is production (main); the env-split namespaces map to test/dev. Only listed
|
||||
// namespaces are scanned — the reader never reaches beyond the platform tier.
|
||||
// Cross-cluster federation (lux-k8s/zoo-k8s) is a follow-up phase (a per-cluster
|
||||
// client from a KMS-loaded kubeconfig), exactly as the Node inventory federates.
|
||||
var nsEnv = map[string]string{
|
||||
"hanzo": "main",
|
||||
"hanzo-testnet": "test",
|
||||
"hanzo-devnet": "dev",
|
||||
}
|
||||
|
||||
// appNameRE constrains the :app path segment to a DNS-1123 label (every Service
|
||||
// CR metadata.name satisfies this). Validated at the boundary; it is the
|
||||
// injection guard for the CR name a deploy/read targets.
|
||||
var appNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
// imageRepoRE constrains a deploy's target image repository. A registry path of
|
||||
// host/namespace/name segments (letters, digits, ., -, _, /). The tag is
|
||||
// validated separately (deploy accepts any non-empty tag so a controlled hotfix
|
||||
// to a floating tag is possible, but the drift board then flags it loudly).
|
||||
var imageRepoRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*[a-z0-9]$`)
|
||||
|
||||
const userAgent = "hanzo-cloud-paassvc"
|
||||
|
||||
type svc struct {
|
||||
dyn dynamic.Interface // nil when no kubeconfig resolved (fail-closed)
|
||||
initErr string // why dyn is nil, surfaced by health + ready()
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
// Mount wires the /v1/paas/* surface onto app. Every handler gates on
|
||||
// c.IsAdmin() first (global-admin only), then reads/patches the operator Service
|
||||
// CRs.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("paassvc.Mount: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("paassvc.Mount: nil deps.Logger")
|
||||
}
|
||||
log := deps.Logger.New("subsystem", "paas")
|
||||
|
||||
s := &svc{log: log}
|
||||
if dyn, err := newDynamic(); err != nil {
|
||||
s.initErr = err.Error()
|
||||
log.Warn("kubernetes client unavailable; /v1/paas endpoints will fail closed", "err", err)
|
||||
} else {
|
||||
s.dyn = dyn
|
||||
}
|
||||
|
||||
app.Get("/v1/paas/apps", s.guard(s.listApps))
|
||||
app.Get("/v1/paas/apps/:app", s.guard(s.getApp))
|
||||
app.Post("/v1/paas/apps/:app/deploy", s.guard(s.deploy))
|
||||
app.Get("/v1/paas/health", s.health)
|
||||
|
||||
log.Info("paas control plane mounted",
|
||||
"prefix", "/v1/paas", "k8s", s.dyn != nil, "brand", deps.Brand, "env", deps.Env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Registered under "paassvc" (not "paas") for the same reason clients/ml uses
|
||||
// "mlsvc": serve.go auto-mounts a generic GET /v1/<name>/health BEFORE MountAll
|
||||
// and zip is first-match-wins, so a name of "paas" would shadow the real-probe
|
||||
// /v1/paas/health. "paassvc" keeps the generic liveness at the unrouted
|
||||
// /v1/paassvc/health and lets the real probe own /v1/paas/health. Order 128 binds
|
||||
// the /v1/paas family before the projectsvc (125) neighbours and well before the
|
||||
// AI /v1/* catch-all (150); it has no ordering dependency (self-contained k8s
|
||||
// client).
|
||||
func init() {
|
||||
cloud.Register("paassvc", 128, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("paassvc.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
|
||||
// guard wraps a handler with the global-admin gate. Fail-closed: any request whose
|
||||
// validated identity is not a global admin is refused 403 before the handler — no
|
||||
// cluster object is read or mutated, matching clients/admin.guard.
|
||||
func (s *svc) guard(h zip.Handler) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
}
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ── observe: the drift board (inventory.ts) ──────────────────────────────────
|
||||
|
||||
// AppView is one service row on the drift board: the observed tags + topology +
|
||||
// the derived drift verdict. It is the Go analogue of the platform's `AppView`
|
||||
// (apps-api.ts) so console2 renders the same shape the Dokploy board did.
|
||||
type AppView struct {
|
||||
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"` // service / CR name, e.g. iam
|
||||
Env string `json:"env"` // main|test|dev
|
||||
Repo string `json:"repo"` // owner/repo, e.g. hanzoai/iam
|
||||
Registry string `json:"registry"`
|
||||
DeclaredTag string `json:"declaredTag"`
|
||||
RunningTag string `json:"runningTag"`
|
||||
LatestTag string `json:"latestTag"`
|
||||
Health string `json:"health"` // green|yellow|red|"" (unknown)
|
||||
Phase string `json:"phase"` // operator status.phase (Running/…)
|
||||
Cluster string `json:"cluster"`
|
||||
Namespace string `json:"namespace"`
|
||||
Endpoints []string `json:"endpoints"`
|
||||
Drift Drift `json:"drift"`
|
||||
}
|
||||
|
||||
// listApps returns the whole fleet's drift board, ordered deterministically
|
||||
// (org, app, env). Optional narrowing filters mirror the platform board:
|
||||
// ?env=, ?health=, ?drift=1 (only rows that are actually drifting), ?org=.
|
||||
func (s *svc) listApps(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
views, err := s.observeFleet(c.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
env := strings.TrimSpace(c.Query("env"))
|
||||
health := strings.TrimSpace(c.Query("health"))
|
||||
org := strings.TrimSpace(c.Query("org"))
|
||||
driftOnly := c.Query("drift") == "1" || c.Query("drift") == "true"
|
||||
|
||||
out := make([]AppView, 0, len(views))
|
||||
byDrift := map[DriftSeverity]int{SeverityOK: 0, SeverityYellow: 0, SeverityRed: 0}
|
||||
for _, v := range views {
|
||||
if env != "" && v.Env != env {
|
||||
continue
|
||||
}
|
||||
if health != "" && v.Health != health {
|
||||
continue
|
||||
}
|
||||
if org != "" && v.Org != org {
|
||||
continue
|
||||
}
|
||||
if driftOnly && v.Drift.Severity == SeverityOK {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
byDrift[v.Drift.Severity]++
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"apps": out,
|
||||
"summary": map[string]any{
|
||||
"total": len(out),
|
||||
"byDrift": map[string]int{
|
||||
"ok": byDrift[SeverityOK],
|
||||
"yellow": byDrift[SeverityYellow],
|
||||
"red": byDrift[SeverityRed],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// getApp returns one service row by CR name. Scans the platform namespaces in
|
||||
// env order (main→test→dev) and returns the first match, so the bare app name
|
||||
// resolves to production by default.
|
||||
func (s *svc) getApp(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqApp(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("app must be a DNS-1123 label")
|
||||
}
|
||||
for _, ns := range scanOrder() {
|
||||
obj, err := s.dyn.Resource(servicesGVR).Namespace(ns).Get(c.Context(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return s.k8sErr("get", err)
|
||||
}
|
||||
repository, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "repository")
|
||||
return c.JSON(http.StatusOK, observeCR(obj, ns, nsEnv[ns], s.runningTagOf(c.Context(), ns, name, repository)))
|
||||
}
|
||||
return zip.ErrNotFound("service not found in the platform namespaces")
|
||||
}
|
||||
|
||||
// observeFleet lists every Service CR across the scanned namespaces and maps each
|
||||
// to an AppView. A namespace that does not exist / is empty is skipped, never
|
||||
// fatal (the fleet board must still render the reachable namespaces).
|
||||
func (s *svc) observeFleet(ctx context.Context) ([]AppView, error) {
|
||||
var views []AppView
|
||||
for _, ns := range scanOrder() {
|
||||
list, err := s.dyn.Resource(servicesGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return nil, s.k8sErr("list", err)
|
||||
}
|
||||
// Running state: one Deployment list per namespace, indexed by name — the
|
||||
// running-tag source (inventory.ts). Best-effort: a Deployment RBAC/list
|
||||
// error leaves runningTag empty (an honest unknown) rather than failing the
|
||||
// whole board, so the declared/health/phase columns still render.
|
||||
running := s.runningTagsIn(ctx, ns)
|
||||
env := nsEnv[ns]
|
||||
for i := range list.Items {
|
||||
cr := &list.Items[i]
|
||||
views = append(views, observeCR(cr, ns, env, running[cr.GetName()]))
|
||||
}
|
||||
}
|
||||
sort.Slice(views, func(i, j int) bool {
|
||||
if views[i].Org != views[j].Org {
|
||||
return views[i].Org < views[j].Org
|
||||
}
|
||||
if views[i].App != views[j].App {
|
||||
return views[i].App < views[j].App
|
||||
}
|
||||
return views[i].Env < views[j].Env
|
||||
})
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// ── deploy: merge-patch the Service CR image (deploy-executor.ts) ─────────────
|
||||
|
||||
// deploy rolls a new image tag onto a service by merge-patching ONLY the Service
|
||||
// CR's `.spec.image`. The operator reconciles the rollout (Deployment update,
|
||||
// rolling restart) — this is the exact contract deploy-executor.ts implemented,
|
||||
// now native. Content-Type is JSON merge-patch (application/merge-patch+json),
|
||||
// which the operator CRD accepts; the dynamic client's MergePatchType sets it.
|
||||
func (s *svc) deploy(c *zip.Ctx) error {
|
||||
if err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
name := reqApp(c)
|
||||
if !appNameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("app must be a DNS-1123 label")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Tag string `json:"tag"` // required — the new image tag (e.g. v1.1.3)
|
||||
Repository string `json:"repository"` // optional — override image repo; else keep the CR's
|
||||
Namespace string `json:"namespace"` // optional — target ns; else resolve to where the CR lives
|
||||
}
|
||||
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||
return zip.Errorf(http.StatusBadRequest, "invalid JSON body: %v", err)
|
||||
}
|
||||
tag := strings.TrimSpace(req.Tag)
|
||||
if tag == "" {
|
||||
return zip.ErrBadRequest("'tag' is required (the image tag to deploy)")
|
||||
}
|
||||
if strings.ContainsAny(tag, " \t\n/") || len(tag) > 128 {
|
||||
return zip.ErrBadRequest("'tag' must be a single image tag (no whitespace or '/')")
|
||||
}
|
||||
repo := strings.TrimSpace(req.Repository)
|
||||
if repo != "" && !imageRepoRE.MatchString(repo) {
|
||||
return zip.ErrBadRequest("'repository' is not a valid image repository path")
|
||||
}
|
||||
|
||||
ns := strings.TrimSpace(req.Namespace)
|
||||
if ns != "" {
|
||||
if _, ok := nsEnv[ns]; !ok {
|
||||
return zip.ErrBadRequest("'namespace' must be a platform namespace (hanzo|hanzo-testnet|hanzo-devnet)")
|
||||
}
|
||||
} else {
|
||||
resolved, err := s.resolveNamespace(c.Context(), name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns = resolved
|
||||
}
|
||||
|
||||
// Build the merge-patch. When repository is omitted we patch only the tag +
|
||||
// pullPolicy so an existing repo is preserved (JSON merge-patch merges keys,
|
||||
// so omitting `repository` leaves the CR's value intact).
|
||||
image := map[string]any{"tag": tag, "pullPolicy": "Always"}
|
||||
if repo != "" {
|
||||
image["repository"] = repo
|
||||
}
|
||||
patch, err := json.Marshal(map[string]any{"spec": map[string]any{"image": image}})
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "encode patch: %v", err)
|
||||
}
|
||||
|
||||
out, err := s.dyn.Resource(servicesGVR).Namespace(ns).
|
||||
Patch(c.Context(), name, k8stypes.MergePatchType, patch, metav1.PatchOptions{})
|
||||
if err != nil {
|
||||
switch {
|
||||
case apierrors.IsNotFound(err):
|
||||
return zip.ErrNotFound("service not found in namespace " + ns)
|
||||
case apierrors.IsInvalid(err), apierrors.IsBadRequest(err):
|
||||
return zip.Errorf(http.StatusUnprocessableEntity, "patch rejected by kubernetes: %v", err)
|
||||
default:
|
||||
return s.k8sErr("patch", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Info("deployed via Service CR patch",
|
||||
"app", name, "namespace", ns, "tag", tag, "repository", repo,
|
||||
"actor", c.User(), "requestID", c.RequestID())
|
||||
|
||||
// Read the effective repo from the patched CR (the caller may have omitted
|
||||
// repository to keep the CR's existing value) so the running-tag container
|
||||
// match uses the real declared repo.
|
||||
effRepo, _, _ := unstructured.NestedString(out.Object, "spec", "image", "repository")
|
||||
view := observeCR(out, ns, nsEnv[ns], s.runningTagOf(c.Context(), ns, name, effRepo))
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"rolledOut": true,
|
||||
"target": ns + "/" + name,
|
||||
"reason": "patched Service/" + name + " image to " + tag,
|
||||
"app": view,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveNamespace finds the platform namespace a Service CR lives in, scanning
|
||||
// in env order (main→test→dev) so a bare deploy targets production. Returns a
|
||||
// clean 404 when the CR exists in none of them.
|
||||
func (s *svc) resolveNamespace(ctx context.Context, name string) (string, error) {
|
||||
for _, ns := range scanOrder() {
|
||||
if _, err := s.dyn.Resource(servicesGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}); err == nil {
|
||||
return ns, nil
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return "", s.k8sErr("get", err)
|
||||
}
|
||||
}
|
||||
return "", zip.ErrNotFound("service " + name + " not found in the platform namespaces")
|
||||
}
|
||||
|
||||
// ── health ────────────────────────────────────────────────────────────────
|
||||
|
||||
// health is a REAL probe: it verifies the API server is reachable and that the
|
||||
// Service CRD is served, and reports the actual state. 200 only when everything is
|
||||
// ok; 503 + the real reason otherwise (never status-theater). Not admin-gated —
|
||||
// liveness must be probe-able by the platform/operator without a JWT.
|
||||
func (s *svc) health(c *zip.Ctx) error {
|
||||
res := map[string]any{"service": "paas", "status": "ok"}
|
||||
if s.dyn == nil {
|
||||
res["status"], res["k8s"], res["error"] = "degraded", false, s.initErr
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
if _, err := s.dyn.Resource(servicesGVR).Namespace("hanzo").List(c.Context(), metav1.ListOptions{Limit: 1}); err != nil {
|
||||
res["status"], res["k8s"], res["crd"], res["error"] = "degraded", true, false, err.Error()
|
||||
return c.JSON(http.StatusServiceUnavailable, res)
|
||||
}
|
||||
res["k8s"], res["crd"] = true, true
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ── k8s plumbing ────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *svc) ready() error {
|
||||
if s.dyn == nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "paas: kubernetes client not configured: %s", s.initErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// k8sErr maps a raw API error to an honest gateway-level error. RBAC denials name
|
||||
// the missing access so the operator knows exactly what to grant the cloud service
|
||||
// account (get/list/patch on services.hanzo.ai). Mirrors ml.k8sErr.
|
||||
func (s *svc) k8sErr(op string, err error) error {
|
||||
s.log.Error("k8s op failed", "op", op, "resource", servicesGVR.Resource, "err", err)
|
||||
if apierrors.IsForbidden(err) {
|
||||
return zip.Errorf(http.StatusBadGateway,
|
||||
"%s services: kubernetes RBAC denied (cloud service account needs %s on services.hanzo.ai): %v",
|
||||
op, op, err)
|
||||
}
|
||||
return zip.Errorf(http.StatusBadGateway, "%s services failed: %v", op, err)
|
||||
}
|
||||
|
||||
// newDynamic builds the dynamic client from the in-cluster service account,
|
||||
// falling back to KUBECONFIG / ~/.kube/config for local/dev — identical to
|
||||
// clients/ml.newDynamic.
|
||||
func newDynamic() (dynamic.Interface, error) {
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||
clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{})
|
||||
cfg, err = cc.ClientConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no in-cluster config and no kubeconfig: %w", err)
|
||||
}
|
||||
}
|
||||
cfg.UserAgent = userAgent
|
||||
return dynamic.NewForConfig(cfg)
|
||||
}
|
||||
|
||||
// ── pure mapping helpers (unit-tested without a cluster) ─────────────────────
|
||||
|
||||
func reqApp(c *zip.Ctx) string { return strings.ToLower(strings.TrimSpace(c.Param("app"))) }
|
||||
|
||||
// scanOrder returns the platform namespaces in a stable env order (main first),
|
||||
// so a bare app-name read/deploy resolves to production before test/dev.
|
||||
func scanOrder() []string { return []string{"hanzo", "hanzo-testnet", "hanzo-devnet"} }
|
||||
|
||||
// orgFromRepository derives the image namespace ("org") from an image repo:
|
||||
// `ghcr.io/hanzoai/chat` → `hanzoai`; `docker.io/grafana/grafana` → `grafana`.
|
||||
// Falls back to the whole repo when it has no namespace segment. Ported verbatim
|
||||
// from inventory.ts `orgFromRepository`.
|
||||
func orgFromRepository(repository string) string {
|
||||
parts := nonEmpty(strings.Split(repository, "/"))
|
||||
if len(parts) >= 3 {
|
||||
return parts[1]
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return parts[0]
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
// repoFromRepository derives the owner/repo GitHub coordinate from an image repo:
|
||||
// `ghcr.io/hanzoai/chat` → `hanzoai/chat` (the image path minus the registry
|
||||
// host). Ported verbatim from inventory.ts `repoFromRepository`.
|
||||
func repoFromRepository(repository string) string {
|
||||
parts := nonEmpty(strings.Split(repository, "/"))
|
||||
if len(parts) >= 3 {
|
||||
return strings.Join(parts[1:], "/")
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func nonEmpty(in []string) []string {
|
||||
out := in[:0]
|
||||
for _, s := range in {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// healthFromStatus rolls the operator's reconciled Service status up to the
|
||||
// apps-table health vocabulary. The operator populates status.readyReplicas /
|
||||
// status.replicas (and phase); we prefer that reconciled truth over re-deriving
|
||||
// from the Deployment (the operator already did that join). Mirrors
|
||||
// inventory.ts healthFromDeployment semantics: desired 0 ⇒ yellow (intentionally
|
||||
// scaled to zero, not unhealthy), ready>=desired ⇒ green, some ready ⇒ yellow,
|
||||
// none ⇒ red. Empty when the status carries no replica counts yet.
|
||||
func healthFromStatus(status map[string]any) string {
|
||||
desired, hasDesired := nestedInt(status, "replicas")
|
||||
ready, _ := nestedInt(status, "readyReplicas")
|
||||
if !hasDesired {
|
||||
// Fall back to availableReplicas if the operator only reports that.
|
||||
if avail, ok := nestedInt(status, "availableReplicas"); ok {
|
||||
if avail > 0 {
|
||||
return "green"
|
||||
}
|
||||
return "red"
|
||||
}
|
||||
return "" // no replica signal yet — unknown, never a fabricated green
|
||||
}
|
||||
if desired == 0 {
|
||||
return "yellow"
|
||||
}
|
||||
if ready >= desired {
|
||||
return "green"
|
||||
}
|
||||
if ready > 0 {
|
||||
return "yellow"
|
||||
}
|
||||
return "red"
|
||||
}
|
||||
|
||||
// observeCR maps one Service CR (+ its operator-reconciled status + the running
|
||||
// tag observed from the live Deployment) into an AppView, attaching the drift
|
||||
// verdict. This is inventory.ts observeService fused with apps-api.ts toAppView:
|
||||
// declared tag from the CR spec, running tag from the Deployment (passed in),
|
||||
// health + phase + endpoints from the operator-reconciled CR status.
|
||||
func observeCR(obj *unstructured.Unstructured, namespace, env, runningTag string) AppView {
|
||||
name := obj.GetName()
|
||||
repository, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "repository")
|
||||
declaredTag, _, _ := unstructured.NestedString(obj.Object, "spec", "image", "tag")
|
||||
|
||||
status, _, _ := unstructured.NestedMap(obj.Object, "status")
|
||||
phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase")
|
||||
endpoints := nestedStringSlice(status, "endpoints")
|
||||
|
||||
obs := Observed{DeclaredTag: declaredTag, RunningTag: runningTag}
|
||||
return AppView{
|
||||
ID: orgFromRepository(repository) + "/" + name + "/" + env,
|
||||
Org: orgFromRepository(repository),
|
||||
App: name,
|
||||
Env: env,
|
||||
Repo: repoFromRepository(repository),
|
||||
Registry: repository,
|
||||
DeclaredTag: declaredTag,
|
||||
RunningTag: runningTag,
|
||||
LatestTag: "", // GH-release reader is a follow-up phase (release-reader.ts)
|
||||
Health: healthFromStatus(status),
|
||||
Phase: phase,
|
||||
Cluster: "hanzo-k8s",
|
||||
Namespace: namespace,
|
||||
Endpoints: endpoints,
|
||||
Drift: ComputeDrift(obs),
|
||||
}
|
||||
}
|
||||
|
||||
// runningTagsIn lists the Deployments in a namespace and returns a map of
|
||||
// Deployment-name → running image tag (the container whose image repo the caller
|
||||
// later matches against the CR's declared repo, in runningTagOf; here we index by
|
||||
// name and keep the first container's tag as the default). Best-effort: any list
|
||||
// error yields an empty map so the board still renders declared/health/phase.
|
||||
func (s *svc) runningTagsIn(ctx context.Context, namespace string) map[string]string {
|
||||
out := map[string]string{}
|
||||
list, err := s.dyn.Resource(deploymentsGVR).Namespace(namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
s.log.Warn("list deployments for running tag failed; running tag will be empty",
|
||||
"namespace", namespace, "err", err)
|
||||
return out
|
||||
}
|
||||
for i := range list.Items {
|
||||
d := &list.Items[i]
|
||||
out[d.GetName()] = firstContainerTag(d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningTagOf reads a single Deployment's running tag, matching the container
|
||||
// whose image repo equals the CR's declared repo (so a sidecar like replicate/otel
|
||||
// is never mistaken for the app), falling back to the first container. Mirrors
|
||||
// inventory.ts runningTagFromDeployment. Best-effort: any error → "".
|
||||
func (s *svc) runningTagOf(ctx context.Context, namespace, name, declaredRepository string) string {
|
||||
d, err := s.dyn.Resource(deploymentsGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return runningTagFromDeployment(d, declaredRepository)
|
||||
}
|
||||
|
||||
// nestedInt reads an integer-valued key from an unstructured map, tolerating the
|
||||
// int64/float64 the k8s decoder may produce.
|
||||
func nestedInt(m map[string]any, key string) (int, bool) {
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case int64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case float64:
|
||||
return int(v), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// nestedStringSlice reads a []string key from an unstructured map (the k8s decoder
|
||||
// yields []any of string).
|
||||
func nestedStringSlice(m map[string]any, key string) []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := m[key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deploymentContainers extracts the pod-template container images from an
|
||||
// unstructured Deployment (spec.template.spec.containers[].image).
|
||||
func deploymentContainers(dep *unstructured.Unstructured) []string {
|
||||
if dep == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok, _ := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "containers")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
imgs := make([]string, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if img, ok := cm["image"].(string); ok && img != "" {
|
||||
imgs = append(imgs, img)
|
||||
}
|
||||
}
|
||||
return imgs
|
||||
}
|
||||
|
||||
// runningTagFromDeployment picks the running tag from a Deployment by matching the
|
||||
// container whose image repository equals the CR's declared repository (so a
|
||||
// sidecar can never be mistaken for the app), falling back to the first container.
|
||||
// Mirrors inventory.ts runningTagFromDeployment.
|
||||
func runningTagFromDeployment(dep *unstructured.Unstructured, declaredRepository string) string {
|
||||
imgs := deploymentContainers(dep)
|
||||
if len(imgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, img := range imgs {
|
||||
if repoFromImageRef(img) == declaredRepository {
|
||||
return tagFromImageRef(img)
|
||||
}
|
||||
}
|
||||
return tagFromImageRef(imgs[0])
|
||||
}
|
||||
|
||||
// firstContainerTag is the default running tag for the namespace-indexed map: the
|
||||
// first container's tag. The per-service exact match (runningTagFromDeployment)
|
||||
// is used when the declared repo is known; this keeps the list pass O(deployments)
|
||||
// without a Get per service.
|
||||
func firstContainerTag(dep *unstructured.Unstructured) string {
|
||||
imgs := deploymentContainers(dep)
|
||||
if len(imgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return tagFromImageRef(imgs[0])
|
||||
}
|
||||
|
||||
// repoFromImageRef splits `ghcr.io/hanzoai/iam:v1` → `ghcr.io/hanzoai/iam`.
|
||||
// A digest ref (`repo@sha256:…`) keeps the repo; a bare repo returns itself.
|
||||
func repoFromImageRef(ref string) string {
|
||||
if at := strings.LastIndex(ref, "@"); at >= 0 {
|
||||
ref = ref[:at]
|
||||
}
|
||||
// A ':' after the last '/' is the tag separator (a ':' in a registry host:port
|
||||
// segment lives before a '/', so guard on the last slash).
|
||||
slash := strings.LastIndex(ref, "/")
|
||||
colon := strings.LastIndex(ref, ":")
|
||||
if colon > slash {
|
||||
return ref[:colon]
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// tagFromImageRef splits `ghcr.io/hanzoai/iam:v1` → `v1`. A digest ref returns the
|
||||
// digest; a bare repo (no tag) returns "".
|
||||
func tagFromImageRef(ref string) string {
|
||||
if at := strings.LastIndex(ref, "@"); at >= 0 {
|
||||
return ref[at+1:]
|
||||
}
|
||||
slash := strings.LastIndex(ref, "/")
|
||||
colon := strings.LastIndex(ref, ":")
|
||||
if colon > slash && colon < len(ref)-1 {
|
||||
return ref[colon+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//go:build paasintegration
|
||||
|
||||
// Integration probe against a REAL cluster. Not part of the normal unit suite —
|
||||
// it is gated behind the `paasintegration` build tag AND requires PAAS_IT=1, so
|
||||
// `go test ./...` never touches a cluster. Run explicitly:
|
||||
//
|
||||
// PAAS_IT=1 go test -tags paasintegration -run TestIntegration ./clients/paassvc/ -v
|
||||
//
|
||||
// It proves the end-to-end deploy path the standalone platform's deploy-executor
|
||||
// implemented, now native in cloud:
|
||||
// - observeFleet lists the operator Service CRs (the drift board) off the live
|
||||
// cluster via the KUBECONFIG fallback in newDynamic.
|
||||
// - an IDEMPOTENT same-image merge-patch on a low-risk service (pricing) proves
|
||||
// the write path reaches the operator WITHOUT changing what runs (same tag =
|
||||
// no rollout). It never mutates a tag, so it cannot perturb live state.
|
||||
package paassvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
k8stypes "k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
const itService = "pricing" // low-risk service CLAUDE.md already validated
|
||||
|
||||
func itClient(t *testing.T) *svc {
|
||||
t.Helper()
|
||||
if os.Getenv("PAAS_IT") != "1" {
|
||||
t.Skip("set PAAS_IT=1 to run the live-cluster integration probe")
|
||||
}
|
||||
dyn, err := newDynamic()
|
||||
if err != nil {
|
||||
t.Fatalf("newDynamic (needs a live KUBECONFIG): %v", err)
|
||||
}
|
||||
return &svc{dyn: dyn, log: luxlog.New("paas-it")}
|
||||
}
|
||||
|
||||
// TestIntegrationObserveFleet lists the real fleet and asserts the board is
|
||||
// non-empty and self-consistent (every row has org/app/env/registry and a drift
|
||||
// verdict). It is READ-ONLY.
|
||||
func TestIntegrationObserveFleet(t *testing.T) {
|
||||
s := itClient(t)
|
||||
views, err := s.observeFleet(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("observeFleet: %v", err)
|
||||
}
|
||||
if len(views) == 0 {
|
||||
t.Fatalf("expected a non-empty fleet board")
|
||||
}
|
||||
t.Logf("observed %d service rows across the platform namespaces", len(views))
|
||||
var green, red, yellow int
|
||||
sawPricing := false
|
||||
for _, v := range views {
|
||||
if v.Org == "" || v.App == "" || v.Env == "" || v.Registry == "" {
|
||||
t.Errorf("incomplete row: %+v", v)
|
||||
}
|
||||
switch v.Drift.Severity {
|
||||
case SeverityOK:
|
||||
green++
|
||||
case SeverityRed:
|
||||
red++
|
||||
case SeverityYellow:
|
||||
yellow++
|
||||
}
|
||||
if v.App == itService && v.Env == "main" {
|
||||
sawPricing = true
|
||||
t.Logf("pricing row: declared=%s health=%s phase=%s drift=%s endpoints=%v",
|
||||
v.DeclaredTag, v.Health, v.Phase, v.Drift.Severity, v.Endpoints)
|
||||
}
|
||||
}
|
||||
t.Logf("drift summary: ok=%d yellow=%d red=%d", green, yellow, red)
|
||||
if !sawPricing {
|
||||
t.Errorf("expected to observe the %q service in ns hanzo", itService)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationIdempotentDeploy proves the deploy WRITE path reaches the
|
||||
// operator without changing live state: it reads pricing's CURRENT tag and
|
||||
// re-patches the CR to the SAME tag. Same image ⇒ the operator sees no change ⇒
|
||||
// no rollout. This exercises the exact merge-patch the deploy handler issues.
|
||||
func TestIntegrationIdempotentDeploy(t *testing.T) {
|
||||
s := itClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := s.dyn.Resource(servicesGVR).Namespace("hanzo").Get(ctx, itService, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("get %s before: %v", itService, err)
|
||||
}
|
||||
tag, _, _ := unstructured.NestedString(before.Object, "spec", "image", "tag")
|
||||
repo, _, _ := unstructured.NestedString(before.Object, "spec", "image", "repository")
|
||||
genBefore := before.GetGeneration()
|
||||
t.Logf("pricing before: repo=%s tag=%s generation=%d", repo, tag, genBefore)
|
||||
if tag == "" {
|
||||
t.Fatalf("pricing CR has no spec.image.tag; refusing to patch")
|
||||
}
|
||||
|
||||
// Same-image merge-patch (the identical body the deploy handler builds).
|
||||
patch := []byte(`{"spec":{"image":{"tag":"` + tag + `","repository":"` + repo + `","pullPolicy":"Always"}}}`)
|
||||
after, err := s.dyn.Resource(servicesGVR).Namespace("hanzo").
|
||||
Patch(ctx, itService, k8stypes.MergePatchType, patch, metav1.PatchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent patch: %v", err)
|
||||
}
|
||||
afterTag, _, _ := unstructured.NestedString(after.Object, "spec", "image", "tag")
|
||||
genAfter := after.GetGeneration()
|
||||
t.Logf("pricing after: tag=%s generation=%d", afterTag, genAfter)
|
||||
|
||||
if afterTag != tag {
|
||||
t.Fatalf("tag changed by an idempotent patch: %s -> %s", tag, afterTag)
|
||||
}
|
||||
// A same-spec merge-patch must not bump .metadata.generation (no spec change).
|
||||
if genAfter != genBefore {
|
||||
t.Errorf("generation moved on a no-op patch: %d -> %d (a rollout may have been triggered)", genBefore, genAfter)
|
||||
}
|
||||
t.Logf("OK: deploy write-path reached the operator; live state unchanged (no rollout)")
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package paassvc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
// TestServicesGVR pins the operator Service CR identity. A typo here silently
|
||||
// breaks every read/deploy, so it is asserted (matches ml_test's GVR guard).
|
||||
func TestServicesGVR(t *testing.T) {
|
||||
want := schema.GroupVersionResource{Group: "hanzo.ai", Version: "v1", Resource: "services"}
|
||||
if servicesGVR != want {
|
||||
t.Fatalf("servicesGVR = %v, want %v", servicesGVR, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsSemverTag is the exact semver policy from apps-drift.ts SEMVER_TAG:
|
||||
// strictly vMAJOR.MINOR.PATCH; everything else is floating.
|
||||
func TestIsSemverTag(t *testing.T) {
|
||||
valid := []string{"v1.0.0", "v1.28.16", "v0.3.0", "v10.20.30", "v4.4.4"}
|
||||
invalid := []string{
|
||||
"", "1.0.0", "v1.0", "v1", "latest", "main", "dev", "edge",
|
||||
"sha-08d2dea-amd64", "1.42.33-billing", "v1.0.0-rc1", "vX.Y.Z",
|
||||
"e19980422d342f40b8ba3142e6bbba54a076fc7f", "nolux-hanzo4",
|
||||
}
|
||||
for _, v := range valid {
|
||||
if !IsSemverTag(v) {
|
||||
t.Errorf("expected %q to be a semver tag", v)
|
||||
}
|
||||
}
|
||||
for _, v := range invalid {
|
||||
if IsSemverTag(v) {
|
||||
t.Errorf("expected %q to NOT be a semver tag", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeDrift ports the apps-drift.ts contract cases 1:1 — the two
|
||||
// implementations must agree exactly.
|
||||
func TestComputeDrift(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
obs Observed
|
||||
wantSev DriftSeverity
|
||||
wantKinds []DriftKind
|
||||
}{
|
||||
{
|
||||
// Clean: declared==running==latest, released with assets.
|
||||
name: "fully clean",
|
||||
obs: Observed{DeclaredTag: "v1.2.0", RunningTag: "v1.2.0", LatestTag: "v1.2.0", ReleaseURL: "https://x/rel", ReleaseAssets: 3},
|
||||
wantSev: SeverityOK,
|
||||
wantKinds: nil,
|
||||
},
|
||||
{
|
||||
// declared behind latest → stale (yellow), plus running rolled to declared.
|
||||
name: "stale only",
|
||||
obs: Observed{DeclaredTag: "v1.2.0", RunningTag: "v1.2.0", LatestTag: "v1.3.0", ReleaseURL: "https://x/rel", ReleaseAssets: 1},
|
||||
wantSev: SeverityYellow,
|
||||
wantKinds: []DriftKind{DriftStale},
|
||||
},
|
||||
{
|
||||
// running != declared → un-rolled (yellow).
|
||||
name: "un-rolled only",
|
||||
obs: Observed{DeclaredTag: "v1.3.0", RunningTag: "v1.2.0", LatestTag: "v1.3.0", ReleaseURL: "https://x/rel", ReleaseAssets: 1},
|
||||
wantSev: SeverityYellow,
|
||||
wantKinds: []DriftKind{DriftUnrolled},
|
||||
},
|
||||
{
|
||||
// floating declared → red; stale suppressed even though latest set.
|
||||
name: "floating declared suppresses stale",
|
||||
obs: Observed{DeclaredTag: "sha-08d2dea", RunningTag: "sha-08d2dea", LatestTag: "v1.3.0", ReleaseURL: "https://x/rel", ReleaseAssets: 1},
|
||||
wantSev: SeverityRed,
|
||||
wantKinds: []DriftKind{DriftFloatingDeclared, DriftFloatingRunning},
|
||||
},
|
||||
{
|
||||
// floating running only (declared is clean semver, matches latest).
|
||||
name: "floating running",
|
||||
obs: Observed{DeclaredTag: "v1.3.0", RunningTag: "main", LatestTag: "v1.3.0", ReleaseURL: "https://x/rel", ReleaseAssets: 1},
|
||||
wantSev: SeverityRed,
|
||||
wantKinds: []DriftKind{DriftFloatingRunning},
|
||||
},
|
||||
{
|
||||
// declared semver but no GH release → no-release (red). This is the
|
||||
// "all-red" state the live fleet shows today.
|
||||
name: "no release",
|
||||
obs: Observed{DeclaredTag: "v1.2.0", RunningTag: "v1.2.0"},
|
||||
wantSev: SeverityRed,
|
||||
wantKinds: []DriftKind{DriftNoRelease},
|
||||
},
|
||||
{
|
||||
// release exists but 0 assets → zero-assets (red) — the iam class.
|
||||
name: "zero assets",
|
||||
obs: Observed{DeclaredTag: "v1.28.16", RunningTag: "v1.28.16", LatestTag: "v1.28.16", ReleaseURL: "https://x/rel", ReleaseAssets: 0},
|
||||
wantSev: SeverityRed,
|
||||
wantKinds: []DriftKind{DriftZeroAssets},
|
||||
},
|
||||
{
|
||||
// Multiple at once: floating running + stale + no-release.
|
||||
name: "compound",
|
||||
obs: Observed{DeclaredTag: "v1.2.0", RunningTag: "sha-x", LatestTag: "v1.3.0"},
|
||||
wantSev: SeverityRed,
|
||||
wantKinds: []DriftKind{DriftFloatingRunning, DriftStale, DriftNoRelease},
|
||||
},
|
||||
{
|
||||
// No declared tag at all → no flags (nothing to compare).
|
||||
name: "no declared tag",
|
||||
obs: Observed{RunningTag: "v1.2.0"},
|
||||
wantSev: SeverityOK,
|
||||
wantKinds: nil,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ComputeDrift(tc.obs)
|
||||
if got.Severity != tc.wantSev {
|
||||
t.Errorf("severity = %q, want %q (flags=%v)", got.Severity, tc.wantSev, kinds(got.Flags))
|
||||
}
|
||||
if !reflect.DeepEqual(kinds(got.Flags), tc.wantKinds) {
|
||||
t.Errorf("kinds = %v, want %v", kinds(got.Flags), tc.wantKinds)
|
||||
}
|
||||
// Flags must never be nil in the verdict (JSON `[]`, never `null`).
|
||||
if got.Flags == nil {
|
||||
t.Errorf("Drift.Flags must be non-nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func kinds(flags []DriftFlag) []DriftKind {
|
||||
if len(flags) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]DriftKind, 0, len(flags))
|
||||
for _, f := range flags {
|
||||
out = append(out, f.Kind)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestOrgFromRepository ports inventory.ts orgFromRepository cases.
|
||||
func TestOrgFromRepository(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"ghcr.io/hanzoai/chat": "hanzoai",
|
||||
"ghcr.io/hanzoai/insights/capture": "hanzoai",
|
||||
"docker.io/grafana/grafana": "grafana",
|
||||
"docker.io/otel/opentelemetry-collector-contrib": "otel",
|
||||
"hanzoai/iam": "hanzoai", // no registry host
|
||||
"bareimage": "bareimage",
|
||||
}
|
||||
for repo, want := range cases {
|
||||
if got := orgFromRepository(repo); got != want {
|
||||
t.Errorf("orgFromRepository(%q) = %q, want %q", repo, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepoFromRepository ports inventory.ts repoFromRepository cases.
|
||||
func TestRepoFromRepository(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"ghcr.io/hanzoai/chat": "hanzoai/chat",
|
||||
"ghcr.io/hanzoai/insights/capture": "hanzoai/insights/capture",
|
||||
"docker.io/grafana/grafana": "grafana/grafana",
|
||||
"hanzoai/iam": "hanzoai/iam",
|
||||
"bareimage": "bareimage",
|
||||
}
|
||||
for repo, want := range cases {
|
||||
if got := repoFromRepository(repo); got != want {
|
||||
t.Errorf("repoFromRepository(%q) = %q, want %q", repo, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthFromStatus mirrors inventory.ts healthFromDeployment semantics but
|
||||
// off the operator-reconciled Service status.
|
||||
func TestHealthFromStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status map[string]any
|
||||
want string
|
||||
}{
|
||||
{"all ready", map[string]any{"replicas": int64(2), "readyReplicas": int64(2)}, "green"},
|
||||
{"partial", map[string]any{"replicas": int64(3), "readyReplicas": int64(1)}, "yellow"},
|
||||
{"none ready", map[string]any{"replicas": int64(2), "readyReplicas": int64(0)}, "red"},
|
||||
{"scaled to zero", map[string]any{"replicas": int64(0), "readyReplicas": int64(0)}, "yellow"},
|
||||
{"available fallback ok", map[string]any{"availableReplicas": int64(2)}, "green"},
|
||||
{"available fallback zero", map[string]any{"availableReplicas": int64(0)}, "red"},
|
||||
{"no signal", map[string]any{"phase": "Pending"}, ""},
|
||||
{"nil status", nil, ""},
|
||||
{"float decode", map[string]any{"replicas": float64(2), "readyReplicas": float64(2)}, "green"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := healthFromStatus(tc.status); got != tc.want {
|
||||
t.Errorf("healthFromStatus(%v) = %q, want %q", tc.status, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveCR proves the CR→AppView mapping end to end (declared tag, org/repo
|
||||
// derivation, health, endpoints, drift) on a real-shaped Service CR.
|
||||
func TestObserveCR(t *testing.T) {
|
||||
obj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "hanzo.ai/v1",
|
||||
"kind": "Service",
|
||||
"metadata": map[string]any{"name": "pricing"},
|
||||
"spec": map[string]any{
|
||||
"image": map[string]any{"repository": "ghcr.io/hanzoai/pricing", "tag": "v1.1.2"},
|
||||
},
|
||||
"status": map[string]any{
|
||||
"phase": "Running",
|
||||
"replicas": int64(2),
|
||||
"readyReplicas": int64(2),
|
||||
"endpoints": []any{"https://pricing.hanzo.ai"},
|
||||
},
|
||||
}}
|
||||
v := observeCR(obj, "hanzo", "main", "v1.1.2")
|
||||
if v.ID != "hanzoai/pricing/main" {
|
||||
t.Errorf("ID = %q, want hanzoai/pricing/main", v.ID)
|
||||
}
|
||||
if v.Org != "hanzoai" || v.App != "pricing" || v.Env != "main" {
|
||||
t.Errorf("org/app/env = %q/%q/%q", v.Org, v.App, v.Env)
|
||||
}
|
||||
if v.Repo != "hanzoai/pricing" || v.Registry != "ghcr.io/hanzoai/pricing" {
|
||||
t.Errorf("repo/registry = %q/%q", v.Repo, v.Registry)
|
||||
}
|
||||
if v.DeclaredTag != "v1.1.2" {
|
||||
t.Errorf("declaredTag = %q, want v1.1.2", v.DeclaredTag)
|
||||
}
|
||||
if v.Health != "green" || v.Phase != "Running" {
|
||||
t.Errorf("health/phase = %q/%q, want green/Running", v.Health, v.Phase)
|
||||
}
|
||||
if v.RunningTag != "v1.1.2" {
|
||||
t.Errorf("runningTag = %q, want v1.1.2", v.RunningTag)
|
||||
}
|
||||
if !reflect.DeepEqual(v.Endpoints, []string{"https://pricing.hanzo.ai"}) {
|
||||
t.Errorf("endpoints = %v", v.Endpoints)
|
||||
}
|
||||
// pricing v1.1.2 declared==running, semver, but no GH release wired yet →
|
||||
// no-release (red). No un-rolled flag (running matches declared).
|
||||
if v.Drift.Severity != SeverityRed || len(v.Drift.Flags) != 1 || v.Drift.Flags[0].Kind != DriftNoRelease {
|
||||
t.Errorf("drift = %+v, want single no-release red", v.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveCRFloating proves a floating declared tag (the commerce/cloud class)
|
||||
// is flagged red.
|
||||
func TestObserveCRFloating(t *testing.T) {
|
||||
obj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{"name": "billing"},
|
||||
"spec": map[string]any{"image": map[string]any{"repository": "ghcr.io/hanzoai/billing", "tag": "sha-08d2dea-amd64"}},
|
||||
"status": map[string]any{"phase": "Running", "replicas": int64(1), "readyReplicas": int64(1)},
|
||||
}}
|
||||
v := observeCR(obj, "hanzo", "main", "sha-08d2dea-amd64")
|
||||
if v.Drift.Severity != SeverityRed {
|
||||
t.Fatalf("expected red for floating declared, got %q", v.Drift.Severity)
|
||||
}
|
||||
if kinds(v.Drift.Flags)[0] != DriftFloatingDeclared {
|
||||
t.Errorf("expected floating-declared first, got %v", kinds(v.Drift.Flags))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppNameRE + imageRepoRE are the boundary injection guards for the CR name
|
||||
// and deploy image repo.
|
||||
func TestAppNameRE(t *testing.T) {
|
||||
valid := []string{"iam", "cloud", "commerce-admin", "insights-kv", "world-gw", "a"}
|
||||
invalid := []string{"", "-iam", "iam-", "IAM", "i am", "iam/x", "iam.x", "iam_x"}
|
||||
for _, v := range valid {
|
||||
if !appNameRE.MatchString(v) {
|
||||
t.Errorf("expected %q valid app name", v)
|
||||
}
|
||||
}
|
||||
for _, v := range invalid {
|
||||
if appNameRE.MatchString(v) {
|
||||
t.Errorf("expected %q invalid app name", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageRepoRE(t *testing.T) {
|
||||
valid := []string{"ghcr.io/hanzoai/iam", "docker.io/grafana/grafana", "ghcr.io/hanzoai/insights/capture"}
|
||||
invalid := []string{"", "ghcr.io/hanzoai/iam ", " ghcr.io/x", "GHCR.io/x", "ghcr.io/hanzoai/iam:tag"}
|
||||
for _, v := range valid {
|
||||
if !imageRepoRE.MatchString(v) {
|
||||
t.Errorf("expected %q valid repo", v)
|
||||
}
|
||||
}
|
||||
for _, v := range invalid {
|
||||
if imageRepoRE.MatchString(v) {
|
||||
t.Errorf("expected %q invalid repo", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentsGVR pins the Deployment GVR (the running-tag source).
|
||||
func TestDeploymentsGVR(t *testing.T) {
|
||||
want := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
|
||||
if deploymentsGVR != want {
|
||||
t.Fatalf("deploymentsGVR = %v, want %v", deploymentsGVR, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRefSplit proves repo/tag extraction across tag, digest, host:port, and
|
||||
// bare forms — the running-tag parse (inventory.ts parseImageRef).
|
||||
func TestImageRefSplit(t *testing.T) {
|
||||
cases := []struct {
|
||||
ref string
|
||||
wantRepo string
|
||||
wantTag string
|
||||
}{
|
||||
{"ghcr.io/hanzoai/iam:v1.28.16", "ghcr.io/hanzoai/iam", "v1.28.16"},
|
||||
{"ghcr.io/hanzoai/cloud:1.785.32", "ghcr.io/hanzoai/cloud", "1.785.32"},
|
||||
{"docker.io/otel/opentelemetry-collector-contrib:0.154.0", "docker.io/otel/opentelemetry-collector-contrib", "0.154.0"},
|
||||
{"registry:5000/hanzoai/iam:v1", "registry:5000/hanzoai/iam", "v1"}, // host:port must not be read as tag
|
||||
{"ghcr.io/hanzoai/iam@sha256:abc123", "ghcr.io/hanzoai/iam", "sha256:abc123"},
|
||||
{"ghcr.io/hanzoai/iam", "ghcr.io/hanzoai/iam", ""}, // no tag
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := repoFromImageRef(tc.ref); got != tc.wantRepo {
|
||||
t.Errorf("repoFromImageRef(%q) = %q, want %q", tc.ref, got, tc.wantRepo)
|
||||
}
|
||||
if got := tagFromImageRef(tc.ref); got != tc.wantTag {
|
||||
t.Errorf("tagFromImageRef(%q) = %q, want %q", tc.ref, got, tc.wantTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunningTagFromDeployment proves the container match ignores sidecars and
|
||||
// falls back to the first container (inventory.ts runningTagFromDeployment).
|
||||
func TestRunningTagFromDeployment(t *testing.T) {
|
||||
dep := &unstructured.Unstructured{Object: map[string]any{
|
||||
"spec": map[string]any{"template": map[string]any{"spec": map[string]any{"containers": []any{
|
||||
map[string]any{"name": "replicate", "image": "ghcr.io/hanzoai/replicate:v9"}, // sidecar first
|
||||
map[string]any{"name": "app", "image": "ghcr.io/hanzoai/iam:v1.28.16"},
|
||||
}}}},
|
||||
}}
|
||||
// Exact repo match picks the app container, not the sidecar.
|
||||
if got := runningTagFromDeployment(dep, "ghcr.io/hanzoai/iam"); got != "v1.28.16" {
|
||||
t.Errorf("matched tag = %q, want v1.28.16 (must skip sidecar)", got)
|
||||
}
|
||||
// Unknown repo → first container fallback.
|
||||
if got := runningTagFromDeployment(dep, "ghcr.io/hanzoai/unknown"); got != "v9" {
|
||||
t.Errorf("fallback tag = %q, want v9 (first container)", got)
|
||||
}
|
||||
// No containers → empty.
|
||||
empty := &unstructured.Unstructured{Object: map[string]any{"spec": map[string]any{}}}
|
||||
if got := runningTagFromDeployment(empty, "x"); got != "" {
|
||||
t.Errorf("no-containers tag = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveCRUnrolled proves the un-rolled flag fires when the running tag lags
|
||||
// the declared tag — the core value of the Deployment join.
|
||||
func TestObserveCRUnrolled(t *testing.T) {
|
||||
obj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{"name": "iam"},
|
||||
"spec": map[string]any{"image": map[string]any{"repository": "ghcr.io/hanzoai/iam", "tag": "v1.28.16"}},
|
||||
"status": map[string]any{"phase": "Running", "replicas": int64(2), "readyReplicas": int64(2)},
|
||||
}}
|
||||
// declared v1.28.16, running v1.28.15 → un-rolled (yellow) + no-release (red).
|
||||
v := observeCR(obj, "hanzo", "main", "v1.28.15")
|
||||
if v.Drift.Severity != SeverityRed {
|
||||
t.Fatalf("severity = %q, want red (no-release dominates)", v.Drift.Severity)
|
||||
}
|
||||
ks := kinds(v.Drift.Flags)
|
||||
hasUnrolled := false
|
||||
for _, k := range ks {
|
||||
if k == DriftUnrolled {
|
||||
hasUnrolled = true
|
||||
}
|
||||
}
|
||||
if !hasUnrolled {
|
||||
t.Errorf("expected un-rolled flag for running!=declared, got %v", ks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanOrder pins production-first namespace ordering (a bare deploy targets
|
||||
// main).
|
||||
func TestScanOrder(t *testing.T) {
|
||||
got := scanOrder()
|
||||
want := []string{"hanzo", "hanzo-testnet", "hanzo-devnet"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("scanOrder = %v, want %v", got, want)
|
||||
}
|
||||
for _, ns := range got {
|
||||
if _, ok := nsEnv[ns]; !ok {
|
||||
t.Errorf("scanOrder namespace %q missing from nsEnv map", ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Package plansvc mounts the @hanzo/plans catalog into the unified cloud
|
||||
// binary under /v1/plans/*, per HIP-0106.
|
||||
//
|
||||
// STRATEGY: wrap, don't rewrite. @hanzo/plans is a Node data package (JSON
|
||||
// catalog + entitlements.mjs transforms). We do NOT reimplement the entitlement
|
||||
// vocabulary in Go and we do NOT copy the catalog into cloud. Instead:
|
||||
//
|
||||
// - github.com/hanzoai/plans (the service repo's Go embed module) ships
|
||||
// goja/bundle.js — the ESM-free port of entitlements.mjs + the /v1/plans
|
||||
// route table — plus the embedded *.json catalog (plans.Data()).
|
||||
// - This wrapper loads that bundle into a goja runtime (clients/gojahost),
|
||||
// injects the catalog as globalThis.__PLANS_DATA__, and registers thin zip
|
||||
// handlers that call globalThis.handle({route, params, tenant}). The
|
||||
// entitlement transforms (fromLegacy/toLicenseFeatures/resolvePlan) run in
|
||||
// goja — real JS, not a Go reimplementation.
|
||||
//
|
||||
// The plans data is read-only public-catalog content; there are no secrets
|
||||
// here. The licensing SIGNER/fingerprint that consumes toLicenseFeatures stays
|
||||
// in hanzoai/licensing. This wrapper is pure glue.
|
||||
//
|
||||
// IAM gating + X-Org-Id tenant scope: every /v1/plans route reads the
|
||||
// gateway-minted identity off the zip.Ctx (c.Org()) and threads it into the
|
||||
// bundle as the tenant, so a reseller org (tenant_id != "hanzo") sees its own
|
||||
// catalog overrides. The plan catalog is readable by any authenticated caller;
|
||||
// no admin scope is required for reads.
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/gojahost"
|
||||
hplans "github.com/hanzoai/plans"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// host is the process-global goja host for the plans bundle. nil before Mount.
|
||||
var host *gojahost.Host
|
||||
|
||||
// Mount registers the /v1/plans/* surface on app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("plan.Mount: nil zip.App")
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
return fmt.Errorf("plan.Mount: nil deps.Logger")
|
||||
}
|
||||
logger = logger.New("subsystem", "plans")
|
||||
|
||||
bundle, err := hplans.Bundle()
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan.Mount: load bundle: %w", err)
|
||||
}
|
||||
data, err := hplans.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan.Mount: load catalog: %w", err)
|
||||
}
|
||||
|
||||
h, err := gojahost.New(gojahost.Config{
|
||||
Name: "plans",
|
||||
Bundle: bundle,
|
||||
Globals: map[string]any{"__PLANS_DATA__": data},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan.Mount: goja host: %w", err)
|
||||
}
|
||||
host = h
|
||||
|
||||
// Native health endpoint — always answers, no JS, no auth.
|
||||
app.Get("/v1/plans/health", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "service": "plans"})
|
||||
})
|
||||
|
||||
// Fixed-route handlers. Each maps a path to a bundle route name.
|
||||
// gateway-minted identity (c.Org()) becomes the tenant for catalog scoping.
|
||||
type binding struct{ path, route string }
|
||||
fixed := []binding{
|
||||
{"/v1/plans", "plans"},
|
||||
{"/v1/plans/subscriptions", "subscriptions"},
|
||||
{"/v1/plans/cloud", "cloud"},
|
||||
{"/v1/plans/blockchain", "blockchain"},
|
||||
{"/v1/plans/dns", "dns"},
|
||||
{"/v1/plans/gpu", "gpu"},
|
||||
{"/v1/plans/regions", "regions"},
|
||||
{"/v1/plans/storage", "storage"},
|
||||
{"/v1/plans/tools", "tools"},
|
||||
{"/v1/plans/policy", "policy"},
|
||||
{"/v1/plans/schema", "schema"},
|
||||
{"/v1/plans/vocab", "vocab"},
|
||||
}
|
||||
for _, b := range fixed {
|
||||
route := b.route
|
||||
app.Get(b.path, func(c *zip.Ctx) error {
|
||||
return dispatch(c, route, nil)
|
||||
})
|
||||
}
|
||||
|
||||
// Parameterized: resolve + entitlements take a plan id.
|
||||
app.Get("/v1/plans/resolve/:id", func(c *zip.Ctx) error {
|
||||
return dispatch(c, "resolve", map[string]string{"id": c.Param("id")})
|
||||
})
|
||||
app.Get("/v1/plans/entitlements/:id", func(c *zip.Ctx) error {
|
||||
return dispatch(c, "entitlements", map[string]string{"id": c.Param("id")})
|
||||
})
|
||||
|
||||
logger.Info("plans mounted",
|
||||
"prefix", "/v1/plans",
|
||||
"routes", len(fixed)+2,
|
||||
"brand", deps.Brand,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch runs one bundle route on the shared goja host and writes the
|
||||
// {status, body} back as JSON. The tenant is the gateway-minted org (X-Org-Id
|
||||
// per HIP-0026) so reseller catalogs resolve correctly.
|
||||
func dispatch(c *zip.Ctx, route string, params map[string]string) error {
|
||||
if host == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]any{
|
||||
"error": "plans not initialised",
|
||||
})
|
||||
}
|
||||
tenant := c.Org()
|
||||
if tenant == "" {
|
||||
tenant = "hanzo"
|
||||
}
|
||||
resp, err := host.Dispatch(c.Context(), gojahost.Request{
|
||||
Route: route,
|
||||
Params: params,
|
||||
Tenant: tenant,
|
||||
})
|
||||
if err != nil {
|
||||
c.Log().Error("plans dispatch failed", "route", route, "err", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{
|
||||
"error": "plans dispatch failed",
|
||||
})
|
||||
}
|
||||
return c.Bytes(resp.Status, withContentType(c, resp.Body))
|
||||
}
|
||||
|
||||
// withContentType sets application/json and returns the bytes unchanged.
|
||||
func withContentType(c *zip.Ctx, b []byte) []byte {
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return b
|
||||
}
|
||||
|
||||
func init() {
|
||||
cloud.Register("plans", 111, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("plan.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
|
||||
// Shutdown drops the goja host. Idempotent.
|
||||
func Shutdown(context.Context) error {
|
||||
if host == nil {
|
||||
return nil
|
||||
}
|
||||
err := host.Close()
|
||||
host = nil
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/gojahost"
|
||||
hplans "github.com/hanzoai/plans"
|
||||
)
|
||||
|
||||
// newHost loads the REAL @hanzo/plans goja bundle + embedded catalog, so this
|
||||
// test exercises the actual entitlements.mjs port running in goja.
|
||||
func newHost(t *testing.T) *gojahost.Host {
|
||||
t.Helper()
|
||||
bundle, err := hplans.Bundle()
|
||||
if err != nil {
|
||||
t.Fatalf("Bundle: %v", err)
|
||||
}
|
||||
data, err := hplans.Data()
|
||||
if err != nil {
|
||||
t.Fatalf("Data: %v", err)
|
||||
}
|
||||
h, err := gojahost.New(gojahost.Config{
|
||||
Name: "plans",
|
||||
Bundle: bundle,
|
||||
Globals: map[string]any{"__PLANS_DATA__": data},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("gojahost.New: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestPlans_Vocab(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), gojahost.Request{Route: "vocab", Tenant: "hanzo"})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Namespaces []string `json:"namespaces"`
|
||||
Keys map[string]any `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(body.Namespaces) != 9 {
|
||||
t.Fatalf("namespaces = %d, want 9", len(body.Namespaces))
|
||||
}
|
||||
if len(body.Keys) < 40 {
|
||||
t.Fatalf("entitlement keys = %d, want >=40", len(body.Keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlans_ResolveProducesLicenseFeatures(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), gojahost.Request{Route: "resolve", Tenant: "hanzo", Params: map[string]string{"id": "pro"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if resp.Status != 200 {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.Status, resp.Body)
|
||||
}
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Entitlements map[string]any `json:"entitlements"`
|
||||
LicenseFeatures []string `json:"license_features"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.ID != "pro" || body.TenantID != "hanzo" {
|
||||
t.Fatalf("id/tenant = %q/%q", body.ID, body.TenantID)
|
||||
}
|
||||
if len(body.Entitlements) == 0 {
|
||||
t.Fatal("expected non-empty entitlements for pro")
|
||||
}
|
||||
if body.LicenseFeatures == nil {
|
||||
t.Fatal("expected license_features array (the engine gate input)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlans_Resolve404(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), gojahost.Request{Route: "resolve", Tenant: "hanzo", Params: map[string]string{"id": "does-not-exist"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if resp.Status != 404 {
|
||||
t.Fatalf("status = %d, want 404", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlans_TenantScopingFallsBackToHanzo(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
// A reseller with no overrides sees the hanzo default catalog.
|
||||
resp, err := h.Dispatch(context.Background(), gojahost.Request{Route: "subscriptions", Tenant: "acme-reseller"})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Plans []map[string]any `json:"plans"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(body.Plans) == 0 {
|
||||
t.Fatal("reseller should fall back to hanzo default catalog (non-empty)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Package pluginsvc is the runtime plugin loader for the unified cloud binary.
|
||||
//
|
||||
// cloud is a thin host: its native Go subsystems are compiled in, but
|
||||
// everything else mounts at RUNTIME from a manifest — no cloud rebuild to add
|
||||
// or update a service. Two plugin kinds, both reduced to "produce an
|
||||
// http.Handler, then app.Mount(prefix, h)":
|
||||
//
|
||||
// - wasm — a polyglot service (Rust/WASM, or Python/TS via goa) loaded
|
||||
// in-process through github.com/hanzoai/goa (wazero/gpython/goja,
|
||||
// pure Go, CGO_ENABLED=0). Drop a .wasm + manifest entry → mounted.
|
||||
// - proxy — a standalone server (e.g. the beego apps ai, vm) reached over a
|
||||
// pluggable transport. The "zap" transport is registered by the ZAP
|
||||
// client when available; until then proxying uses plain HTTP. Either
|
||||
// way cloud never recompiles to point at a service.
|
||||
//
|
||||
// The manifest path comes from CLOUD_PLUGINS (a JSON file); if unset, pluginsvc
|
||||
// mounts nothing. Adding a service = edit the manifest + drop a .wasm or
|
||||
// redeploy the standalone — cloud is unchanged unless its own core changes.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/goa"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// Plugin is one manifest entry. Kind selects which fields apply.
|
||||
type Plugin struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "wasm" | "proxy"
|
||||
Prefix string `json:"prefix"` // mount point, e.g. /v1/pricing
|
||||
|
||||
// kind=wasm (goa): a polyglot module + its route table.
|
||||
Lang string `json:"lang,omitempty"` // "rust"/"wasm", "python", "javascript"…
|
||||
Source string `json:"source,omitempty"` // path to .wasm/.py/.ts, relative to the manifest
|
||||
Pool int `json:"pool,omitempty"` // pooled interpreters (default 8)
|
||||
Routes []goa.Route `json:"routes,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
|
||||
// kind=proxy: a standalone server reached over a transport.
|
||||
Target string `json:"target,omitempty"` // e.g. http://ai.internal:8080
|
||||
Via string `json:"via,omitempty"` // transport: "http" (default) | "zap"
|
||||
StripPrefix bool `json:"stripPrefix,omitempty"` // strip Prefix before forwarding
|
||||
}
|
||||
|
||||
// Manifest is the runtime plugin set.
|
||||
type Manifest struct {
|
||||
Plugins []Plugin `json:"plugins"`
|
||||
}
|
||||
|
||||
// --- transport seam ------------------------------------------------------
|
||||
//
|
||||
// The proxy kind dials its target through an http.RoundTripper chosen by
|
||||
// Plugin.Via. "http" is built in; "zap" (and any future transport) is
|
||||
// registered here by its client package, so pluginsvc has no hard dependency
|
||||
// on the ZAP wire code and works today over HTTP.
|
||||
|
||||
var (
|
||||
transportsMu sync.RWMutex
|
||||
transports = map[string]http.RoundTripper{}
|
||||
)
|
||||
|
||||
// RegisterTransport makes rt selectable as Plugin.Via == name. Called from the
|
||||
// transport client's init() (e.g. the ZAP client registers "zap").
|
||||
func RegisterTransport(name string, rt http.RoundTripper) {
|
||||
transportsMu.Lock()
|
||||
defer transportsMu.Unlock()
|
||||
transports[name] = rt
|
||||
}
|
||||
|
||||
func transportFor(via string) (http.RoundTripper, error) {
|
||||
if via == "" || via == "http" {
|
||||
return http.DefaultTransport, nil
|
||||
}
|
||||
transportsMu.RLock()
|
||||
defer transportsMu.RUnlock()
|
||||
if rt, ok := transports[via]; ok {
|
||||
return rt, nil
|
||||
}
|
||||
return nil, fmt.Errorf("transport %q not registered (its client package must call plugin.RegisterTransport)", via)
|
||||
}
|
||||
|
||||
// --- mounting ------------------------------------------------------------
|
||||
|
||||
// mounted tracks goa services so Shutdown can release their pools.
|
||||
var (
|
||||
mu sync.Mutex
|
||||
mounted []*goa.Service
|
||||
)
|
||||
|
||||
// Mount reads the plugin manifest and mounts every plugin onto app. Missing or
|
||||
// unset manifest is a no-op (cloud runs fine with zero plugins).
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("plugin.Mount: nil zip.App")
|
||||
}
|
||||
log := deps.Logger.New("subsystem", "plugins")
|
||||
|
||||
path := os.Getenv("CLOUD_PLUGINS")
|
||||
if path == "" {
|
||||
log.Debug("no plugin manifest (CLOUD_PLUGINS unset); mounting none")
|
||||
return nil
|
||||
}
|
||||
man, err := load(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin.Mount: %w", err)
|
||||
}
|
||||
baseDir := filepath.Dir(path)
|
||||
|
||||
for _, p := range man.Plugins {
|
||||
h, err := build(context.Background(), p, baseDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin.Mount: plugin %q: %w", p.Name, err)
|
||||
}
|
||||
app.Mount(p.Prefix, h)
|
||||
log.Info("plugin mounted", "name", p.Name, "kind", p.Kind, "prefix", p.Prefix)
|
||||
}
|
||||
|
||||
// Introspection: list what is mounted.
|
||||
plugins := man.Plugins
|
||||
app.Get("/v1/plugins", func(c *zip.Ctx) error {
|
||||
out := make([]map[string]string, 0, len(plugins))
|
||||
for _, p := range plugins {
|
||||
out = append(out, map[string]string{"name": p.Name, "kind": p.Kind, "prefix": p.Prefix})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"plugins": out})
|
||||
})
|
||||
|
||||
log.Info("plugins loaded", "count", len(man.Plugins), "brand", deps.Brand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func load(path string) (*Manifest, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, fmt.Errorf("manifest %s: %w", path, err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// build turns a plugin into a mountable http.Handler.
|
||||
func build(ctx context.Context, p Plugin, baseDir string) (http.Handler, error) {
|
||||
switch p.Kind {
|
||||
case "wasm", "goa", "": // polyglot via goa (default)
|
||||
man := goa.Manifest{
|
||||
Name: p.Name, Lang: p.Lang, Source: p.Source,
|
||||
Pool: p.Pool, Prefix: p.Prefix, Routes: p.Routes, Env: p.Env,
|
||||
}
|
||||
svc, err := man.Build(ctx, os.DirFS(baseDir))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
mounted = append(mounted, svc)
|
||||
mu.Unlock()
|
||||
return svc.Handler(), nil
|
||||
case "proxy":
|
||||
return buildProxy(p)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown kind %q (want wasm|proxy)", p.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func buildProxy(p Plugin) (http.Handler, error) {
|
||||
if p.Target == "" {
|
||||
return nil, fmt.Errorf("proxy plugin needs a target")
|
||||
}
|
||||
u, err := url.Parse(p.Target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad target %q: %w", p.Target, err)
|
||||
}
|
||||
rt, err := transportFor(p.Via)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
rp.Transport = rt
|
||||
var h http.Handler = rp
|
||||
if p.StripPrefix {
|
||||
h = http.StripPrefix(p.Prefix, rp)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
cloud.Register("plugins", 900, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
|
||||
// Shutdown releases every mounted goa service pool. Idempotent.
|
||||
func Shutdown(context.Context) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, s := range mounted {
|
||||
if s != nil && s.Pool != nil {
|
||||
_ = s.Pool.Close()
|
||||
}
|
||||
}
|
||||
mounted = nil
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/goa"
|
||||
)
|
||||
|
||||
// TestBuildWasm mounts the goa Rust echo guest (testdata/echo.wasm) as a wasm
|
||||
// plugin and exercises it end to end through the produced http.Handler.
|
||||
func TestBuildWasm(t *testing.T) {
|
||||
p := Plugin{
|
||||
Name: "echo", Kind: "wasm", Lang: "rust", Source: "echo.wasm",
|
||||
Prefix: "/v1/echo", Pool: 2,
|
||||
Routes: []goa.Route{{Method: "POST", Path: "/echo", Func: "echo"}},
|
||||
}
|
||||
h, err := build(context.Background(), p, "testdata")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(h)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/v1/echo/echo", "application/json", strings.NewReader(`{"name":"ada"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var got map[string]map[string]string
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("response not JSON: %q", body)
|
||||
}
|
||||
if got["echo"]["name"] != "ada" {
|
||||
t.Fatalf("got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildProxy forwards to a standalone backend over the default HTTP
|
||||
// transport, with and without prefix stripping.
|
||||
func TestBuildProxy(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "hit:"+r.URL.Path)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
strip bool
|
||||
want string
|
||||
}{
|
||||
{"keep-prefix", false, "hit:/v1/svc/foo"},
|
||||
{"strip-prefix", true, "hit:/foo"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, err := build(context.Background(),
|
||||
Plugin{Name: "svc", Kind: "proxy", Prefix: "/v1/svc", Target: backend.URL, StripPrefix: tc.strip}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
front := httptest.NewServer(h)
|
||||
defer front.Close()
|
||||
resp, err := http.Get(front.URL + "/v1/svc/foo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if string(b) != tc.want {
|
||||
t.Fatalf("got %q want %q", b, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransportSeam: "http" is built in; a custom transport is selectable once
|
||||
// registered; an unknown transport errors.
|
||||
func TestTransportSeam(t *testing.T) {
|
||||
if _, err := transportFor("http"); err != nil {
|
||||
t.Fatalf("http transport: %v", err)
|
||||
}
|
||||
if _, err := transportFor(""); err != nil {
|
||||
t.Fatalf("default transport: %v", err)
|
||||
}
|
||||
if _, err := transportFor("zap"); err == nil {
|
||||
t.Fatal("unregistered transport should error")
|
||||
}
|
||||
RegisterTransport("zap", roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok")), Header: http.Header{}}, nil
|
||||
}))
|
||||
if _, err := transportFor("zap"); err != nil {
|
||||
t.Fatalf("zap transport after register: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func TestUnknownKind(t *testing.T) {
|
||||
if _, err := build(context.Background(), Plugin{Name: "x", Kind: "bogus"}, ""); err == nil {
|
||||
t.Fatal("unknown kind should error")
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,223 @@
|
||||
// Admin surface for the catalog enablement overlay (global-admin only).
|
||||
//
|
||||
// GET /v1/admin/catalog full catalog + every entry's state
|
||||
// PATCH /v1/admin/catalog/models/* upsert one model overlay (id may
|
||||
// contain '/', e.g. anthropic/x)
|
||||
// PATCH /v1/admin/catalog/providers/:name upsert one provider overlay
|
||||
//
|
||||
// Gating mirrors the rest of cloud: c.IsAdmin() is the gateway-minted
|
||||
// X-User-IsAdmin claim, set only on the JWT-validated path (HIP-0026) for
|
||||
// members of the global `admin` org. Same trust model the pricing /sync trigger
|
||||
// and provisioningsvc already rely on. Non-admins get 403, never the catalog
|
||||
// state.
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// Bounds on the admin-supplied overrides patch. Overrides are small metadata/
|
||||
// pricing patches; these caps are cheap guards that bound the blast radius of a
|
||||
// forged-admin write (the recursive merge is depth-sensitive). See FIX #5.
|
||||
const (
|
||||
maxOverrideBytes = 64 << 10 // 64 KiB
|
||||
maxOverrideDepth = 32
|
||||
)
|
||||
|
||||
// patchBody is the PATCH payload. Every field is optional (a pointer): only
|
||||
// fields present in the request body are changed; absent fields preserve the
|
||||
// existing overlay. A brand-new overlay defaults to enabled (the catalog
|
||||
// default), so PATCH {"enabled":false} is the first act that hides an entry.
|
||||
type patchBody struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
BetaOrgs *[]string `json:"betaOrgs,omitempty"`
|
||||
Overrides *json.RawMessage `json:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// adminCatalog returns the full catalog (every model + provider) annotated with
|
||||
// overlay state, for the admin UI. The catalog shape is tenant-independent, so
|
||||
// the admin always sees the canonical full list (isAdmin gate => nothing hidden).
|
||||
func adminCatalog(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
}
|
||||
if cat == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]any{"error": "catalog overlay not initialised"})
|
||||
}
|
||||
|
||||
mstatus, mbody, err := rawDispatch(c, "models", nil)
|
||||
if err != nil || mstatus != http.StatusOK {
|
||||
return c.JSON(http.StatusBadGateway, map[string]any{"error": "catalog unavailable"})
|
||||
}
|
||||
var mp struct {
|
||||
Updated any `json:"updated"`
|
||||
Models []Model `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(mbody, &mp); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "catalog decode failed"})
|
||||
}
|
||||
|
||||
var pwrap struct {
|
||||
Providers map[string]any `json:"providers"`
|
||||
}
|
||||
if pstatus, pbody, perr := rawDispatch(c, "providers", nil); perr == nil && pstatus == http.StatusOK {
|
||||
_ = json.Unmarshal(pbody, &pwrap)
|
||||
}
|
||||
|
||||
snap, err := cat.Snapshot(c.Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "overlay read failed"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"updated": mp.Updated,
|
||||
"models": VisibleCatalog(mp.Models, snap, "", true),
|
||||
"providers": VisibleProviders(pwrap.Providers, snap, "", true),
|
||||
})
|
||||
}
|
||||
|
||||
// adminPatchModel upserts the overlay for one model id. The id is a greedy
|
||||
// wildcard so slashed ids (anthropic/claude-opus-4.6) route intact.
|
||||
func adminPatchModel(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("*"))
|
||||
if id == "" {
|
||||
return zip.ErrBadRequest("model id required")
|
||||
}
|
||||
return adminPatch(c, kindModel, id)
|
||||
}
|
||||
|
||||
// adminPatchProvider upserts the overlay for one provider name.
|
||||
func adminPatchProvider(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return zip.ErrForbidden("global admin required")
|
||||
}
|
||||
name := strings.TrimSpace(c.Param("name"))
|
||||
if name == "" {
|
||||
return zip.ErrBadRequest("provider name required")
|
||||
}
|
||||
return adminPatch(c, kindProvider, name)
|
||||
}
|
||||
|
||||
// adminPatch reads the current overlay (or the enabled-default), applies only
|
||||
// the fields present in the body, and writes it back. Returns the new effective
|
||||
// overlay so the admin UI can reflect it without a re-fetch.
|
||||
func adminPatch(c *zip.Ctx, kind, id string) error {
|
||||
if cat == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]any{"error": "catalog overlay not initialised"})
|
||||
}
|
||||
|
||||
var body patchBody
|
||||
if raw := c.Body(); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return zip.ErrBadRequest("invalid JSON body: " + err.Error())
|
||||
}
|
||||
}
|
||||
if body.Overrides != nil {
|
||||
if err := checkOverride(*body.Overrides); err != nil {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Context()
|
||||
cur, ok, err := cat.Get(ctx, kind, id)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "overlay read failed"})
|
||||
}
|
||||
if !ok {
|
||||
cur = Overlay{Kind: kind, ID: id, Enabled: true} // catalog default.
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
cur.Enabled = *body.Enabled
|
||||
}
|
||||
if body.BetaOrgs != nil {
|
||||
cur.BetaOrgs = normalizeOrgs(*body.BetaOrgs)
|
||||
}
|
||||
if body.Overrides != nil {
|
||||
cur.Overrides = normalizeOverride(*body.Overrides)
|
||||
}
|
||||
cur.Kind, cur.ID = kind, id
|
||||
cur.UpdatedAt = time.Now().Unix()
|
||||
|
||||
if err := cat.Upsert(ctx, cur); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "overlay write failed"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, cur)
|
||||
}
|
||||
|
||||
// normalizeOrgs trims, drops empties, and de-duplicates a beta-org list.
|
||||
func normalizeOrgs(in []string) []string {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkOverride validates an overrides patch at the boundary: a JSON object or
|
||||
// null (RFC 7386 — an array or scalar is rejected), within a byte budget, and
|
||||
// not pathologically deep (the merge is recursive). Cheap guards that bound a
|
||||
// forged-admin's blast radius.
|
||||
func checkOverride(raw json.RawMessage) error {
|
||||
if len(raw) > maxOverrideBytes {
|
||||
return fmt.Errorf("overrides too large (max %d bytes)", maxOverrideBytes)
|
||||
}
|
||||
t := strings.TrimSpace(string(raw))
|
||||
if t == "" || t == "null" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return fmt.Errorf("overrides must be a JSON object or null")
|
||||
}
|
||||
if jsonDepth(m) > maxOverrideDepth {
|
||||
return fmt.Errorf("overrides nested too deep (max %d)", maxOverrideDepth)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonDepth returns the maximum object/array nesting depth of a decoded value.
|
||||
func jsonDepth(v any) int {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
max := 0
|
||||
for _, e := range t {
|
||||
if d := jsonDepth(e); d > max {
|
||||
max = d
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
case []any:
|
||||
max := 0
|
||||
for _, e := range t {
|
||||
if d := jsonDepth(e); d > max {
|
||||
max = d
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeOverride stores the patch verbatim, or clears it (empty/null/{}).
|
||||
func normalizeOverride(raw json.RawMessage) json.RawMessage {
|
||||
t := strings.TrimSpace(string(raw))
|
||||
if t == "" || t == "null" || t == "{}" {
|
||||
return nil
|
||||
}
|
||||
return json.RawMessage(t)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v3"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// TestAdminCatalog_HTTP drives the real subsystem over HTTP: it Mounts
|
||||
// pricingsvc on a zip app and exercises the admin write surface + the gated
|
||||
// read path end-to-end. This verifies the load-bearing pieces the pure-gate
|
||||
// unit tests can't: the greedy-wildcard route for slashed model ids, the
|
||||
// IsAdmin gate, and the enable→customer-sees flow.
|
||||
func TestAdminCatalog_HTTP(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
deps := cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo", DataDir: t.TempDir()}
|
||||
if err := Mount(app, deps); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
defer func() { _ = Shutdown(context.Background()) }()
|
||||
fa := app.Fiber()
|
||||
|
||||
do := func(method, path, body string, hdr map[string]string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
var req *http.Request
|
||||
if body != "" {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, b
|
||||
}
|
||||
|
||||
const slashID = "anthropic/claude-opus-4.6"
|
||||
admin := map[string]string{"X-User-IsAdmin": "true"}
|
||||
acme := map[string]string{"X-Org-Id": "acme"}
|
||||
other := map[string]string{"X-Org-Id": "other"}
|
||||
|
||||
// --- gating of the admin surface itself ---------------------------------
|
||||
if resp, _ := do("PATCH", "/v1/admin/catalog/models/"+slashID, `{"enabled":false}`, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("non-admin PATCH must be 403, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := do("GET", "/v1/admin/catalog", "", acme); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("non-admin GET /v1/admin/catalog must be 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// --- admin disables a slashed-id model (verifies wildcard routing) ------
|
||||
resp, body := do("PATCH", "/v1/admin/catalog/models/"+slashID, `{"enabled":false}`, admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin PATCH must be 200 (wildcard route), got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var ov Overlay
|
||||
if err := json.Unmarshal(body, &ov); err != nil {
|
||||
t.Fatalf("decode overlay echo: %v", err)
|
||||
}
|
||||
if ov.ID != slashID || ov.Enabled {
|
||||
t.Errorf("overlay echo wrong: %+v", ov)
|
||||
}
|
||||
|
||||
// --- the gate: disabled model hidden from customers, visible to admin ---
|
||||
if _, lb := do("GET", "/v1/pricing/models", "", acme); modelsContain(lb, slashID) {
|
||||
t.Errorf("disabled model leaked into public /v1/pricing/models")
|
||||
}
|
||||
if _, lab := do("GET", "/v1/pricing/models", "", admin); !modelsContain(lab, slashID) {
|
||||
t.Errorf("admin must still see the disabled model in /v1/pricing/models")
|
||||
}
|
||||
|
||||
// --- per-customer beta: add acme, only acme sees it ---------------------
|
||||
if resp, _ := do("PATCH", "/v1/admin/catalog/models/"+slashID, `{"betaOrgs":["acme"]}`, admin); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin PATCH betaOrgs must be 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if _, lbeta := do("GET", "/v1/pricing/models", "", acme); !modelsContain(lbeta, slashID) {
|
||||
t.Errorf("beta org acme must see the disabled-but-beta model")
|
||||
}
|
||||
if _, lother := do("GET", "/v1/pricing/models", "", other); modelsContain(lother, slashID) {
|
||||
t.Errorf("non-beta org must not see the beta model")
|
||||
}
|
||||
|
||||
// --- FIX #2: the root /v1/pricing blob is gated like the leaves ---------
|
||||
// slashID is disabled with beta=[acme] at this point.
|
||||
_, rOther := do("GET", "/v1/pricing", "", other)
|
||||
if rootContainsModel(rOther, slashID) {
|
||||
t.Errorf("FIX#2: disabled model leaked into the root /v1/pricing for a non-beta org")
|
||||
}
|
||||
if rootFreeContains(rOther, slashID) {
|
||||
t.Errorf("FIX#2: disabled model id leaked into root freeModels for a non-beta org")
|
||||
}
|
||||
if _, rAcme := do("GET", "/v1/pricing", "", acme); !rootContainsModel(rAcme, slashID) {
|
||||
t.Errorf("FIX#2: beta org acme must see the beta model in the root blob")
|
||||
}
|
||||
if _, rAdmin := do("GET", "/v1/pricing", "", admin); !rootContainsModel(rAdmin, slashID) {
|
||||
t.Errorf("FIX#2: admin must see the disabled model in the root blob")
|
||||
}
|
||||
|
||||
// --- FIX #5: oversized / over-deep overrides are rejected at the boundary
|
||||
deep := `{"betaOrgs":[],"overrides":` + strings.Repeat(`{"a":`, 40) + "1" + strings.Repeat("}", 40) + `}`
|
||||
if resp, _ := do("PATCH", "/v1/admin/catalog/models/zen5", deep, admin); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("FIX#5: over-deep override must be 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// --- single-model gate (single-segment id) 404s without an oracle ------
|
||||
if resp, _ := do("PATCH", "/v1/admin/catalog/models/zen4", `{"enabled":false}`, admin); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin PATCH zen4 must be 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := do("GET", "/v1/pricing/model/zen4", "", acme); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("disabled zen4 single-lookup must 404 for public, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := do("GET", "/v1/pricing/model/zen4", "", admin); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("admin single-lookup of disabled zen4 must be 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// --- admin catalog returns annotated entries ----------------------------
|
||||
resp, ab := do("GET", "/v1/admin/catalog", "", admin)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin GET /v1/admin/catalog must be 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var ac struct {
|
||||
Models []Model `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(ab, &ac); err != nil {
|
||||
t.Fatalf("decode admin catalog: %v", err)
|
||||
}
|
||||
m := catFind(ac.Models, slashID)
|
||||
if m == nil {
|
||||
t.Fatal("admin catalog missing the disabled model")
|
||||
}
|
||||
if _, ok := m["_overlay"].(map[string]any); !ok {
|
||||
t.Errorf("admin catalog model must carry _overlay state")
|
||||
}
|
||||
}
|
||||
|
||||
func modelsContain(body []byte, id string) bool {
|
||||
var p struct {
|
||||
Models []Model `json:"models"`
|
||||
}
|
||||
if json.Unmarshal(body, &p) != nil {
|
||||
return false
|
||||
}
|
||||
return catHasID(p.Models, id)
|
||||
}
|
||||
|
||||
// rootContainsModel reports whether the root /v1/pricing blob exposes a model id
|
||||
// in either of its raw arrays.
|
||||
func rootContainsModel(body []byte, id string) bool {
|
||||
var p struct {
|
||||
HanzoModels []Model `json:"hanzoModels"`
|
||||
ThirdPartyModels []Model `json:"thirdPartyModels"`
|
||||
}
|
||||
if json.Unmarshal(body, &p) != nil {
|
||||
return false
|
||||
}
|
||||
return catHasID(p.HanzoModels, id) || catHasID(p.ThirdPartyModels, id)
|
||||
}
|
||||
|
||||
func rootFreeContains(body []byte, id string) bool {
|
||||
var p struct {
|
||||
FreeModels []string `json:"freeModels"`
|
||||
}
|
||||
if json.Unmarshal(body, &p) != nil {
|
||||
return false
|
||||
}
|
||||
for _, s := range p.FreeModels {
|
||||
if s == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestMount_EmptyDataDir_FailsClosed proves FIX #3: the overlay is a security
|
||||
// control, so an empty DataDir is a hard boot error — never a silent in-memory
|
||||
// (fail-open) downgrade that would re-expose admin-hidden models on restart.
|
||||
func TestMount_EmptyDataDir_FailsClosed(t *testing.T) {
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
defer func() { _ = Shutdown(context.Background()) }()
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), Brand: "hanzo", DataDir: ""}); err == nil {
|
||||
t.Fatal("Mount with empty DataDir must fail closed (got nil error)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// Catalog enablement overlay: the ONE mutable state Hanzo Cloud lays over the
|
||||
// static @hanzo/pricing catalog, plus the ONE gate that applies it on read.
|
||||
//
|
||||
// Decomplected: the goja bundle (data/pricing.json) stays the sole source of
|
||||
// truth for catalog CONTENT and SHAPE. This file adds only per-entry STATE —
|
||||
// {enabled, betaOrgs, overrides} keyed by (kind,id) — and a pure function that
|
||||
// filters + merges that state onto the bundle's output. Go never reshapes a
|
||||
// model; it only hides entries and merges an admin override patch on top.
|
||||
//
|
||||
// Default is "everything enabled": a model/provider with no overlay row is
|
||||
// visible to every org, unchanged. An empty store therefore leaves the catalog
|
||||
// exactly as the bundle ships it — no fabricated state, no regression for live
|
||||
// customers until an admin acts.
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
// modernc.org/sqlite is the pure-Go SQLite driver already in the cloud dep
|
||||
// graph (provisioningsvc uses it). Blank import registers the "sqlite" name.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Overlay entity kinds. A model is keyed by its id (id||name); a provider by its
|
||||
// name (the bundle's `provider` string).
|
||||
const (
|
||||
kindModel = "model"
|
||||
kindProvider = "provider"
|
||||
)
|
||||
|
||||
// Model is one catalog entry exactly as the @hanzo/pricing bundle emits it (see
|
||||
// goja/bundle.js 'models'): an opaque JSON object. The gate reads only the
|
||||
// identifier (id, falling back to name) and provider, and passes every other
|
||||
// field through untouched — keeping the bundle authoritative for shape.
|
||||
type Model map[string]any
|
||||
|
||||
func str(v any) string { s, _ := v.(string); return s }
|
||||
|
||||
// modelID is the overlay key for a model: its slugged id when present
|
||||
// (third-party, e.g. "anthropic/claude-opus-4.6"), else its name (Hanzo/Zen
|
||||
// models, e.g. "zen4"). Mirrors the bundle's own lookup, which matches name OR
|
||||
// id.
|
||||
func modelID(m Model) string {
|
||||
if id := strings.TrimSpace(str(m["id"])); id != "" {
|
||||
return id
|
||||
}
|
||||
return strings.TrimSpace(str(m["name"]))
|
||||
}
|
||||
|
||||
func providerID(m Model) string { return strings.TrimSpace(str(m["provider"])) }
|
||||
|
||||
func overlayKey(kind, id string) string { return kind + "\x00" + id }
|
||||
|
||||
// Overlay is the mutable enablement STATE for one catalog entry. Zero value (no
|
||||
// row) == enabled, no beta orgs, no override; the gate treats an absent row as
|
||||
// visible, so an empty store is a no-op.
|
||||
type Overlay struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BetaOrgs []string `json:"betaOrgs,omitempty"`
|
||||
Overrides json.RawMessage `json:"overrides,omitempty"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// visibleTo reports whether org may see the entry this overlay governs: enabled
|
||||
// for everyone, OR org is on the beta list (private beta of an otherwise-hidden
|
||||
// entry). The absent-row case (default-visible) is handled by the caller.
|
||||
func (o Overlay) visibleTo(org string) bool {
|
||||
if o.Enabled {
|
||||
return true
|
||||
}
|
||||
if org == "" {
|
||||
return false
|
||||
}
|
||||
for _, b := range o.BetaOrgs {
|
||||
if b == org {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VisibleCatalog applies the enablement overlay to the bundle's full model list
|
||||
// for org. A model is visible iff its OWN overlay AND its provider's overlay
|
||||
// both admit org (enabled, or org on the beta list); an entry with no overlay
|
||||
// row is visible by default, so a provider with no row never hides its models.
|
||||
// Returned models carry any admin override merged on top (RFC 7386). isAdmin
|
||||
// callers receive EVERY model — disabled ones included — each annotated under
|
||||
// "_overlay" so the admin UI can render and toggle it.
|
||||
//
|
||||
// Pure over (full, snap, org, isAdmin): no IO, no globals. This is the unit
|
||||
// under test; the wiring layer fetches `full` from goja and `snap` from the
|
||||
// store, then calls it.
|
||||
func VisibleCatalog(full []Model, snap map[string]Overlay, org string, isAdmin bool) []Model {
|
||||
out := make([]Model, 0, len(full))
|
||||
for _, m := range full {
|
||||
mo, mok := snap[overlayKey(kindModel, modelID(m))]
|
||||
po, pok := snap[overlayKey(kindProvider, providerID(m))]
|
||||
visible := (!mok || mo.visibleTo(org)) && (!pok || po.visibleTo(org))
|
||||
if !isAdmin && !visible {
|
||||
continue
|
||||
}
|
||||
merged := mergeModel(m, mo.Overrides)
|
||||
if isAdmin {
|
||||
merged["_overlay"] = modelAdminState(mo, mok, po, pok)
|
||||
}
|
||||
out = append(out, merged)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VisibleProviders filters a provider dict (name -> info) by the provider
|
||||
// overlay for org, merging provider overrides (RFC 7386). isAdmin callers get
|
||||
// every provider with state annotated under each provider's "_overlay".
|
||||
func VisibleProviders(providers map[string]any, snap map[string]Overlay, org string, isAdmin bool) map[string]any {
|
||||
out := make(map[string]any, len(providers))
|
||||
for name, info := range providers {
|
||||
po, ok := snap[overlayKey(kindProvider, name)]
|
||||
visible := !ok || po.visibleTo(org)
|
||||
if !isAdmin && !visible {
|
||||
continue
|
||||
}
|
||||
infoMap, isMap := info.(map[string]any)
|
||||
if !isMap {
|
||||
out[name] = info // opaque value: nothing to override or annotate.
|
||||
continue
|
||||
}
|
||||
merged := applyMergePatch(infoMap, overridePatch(po.Overrides))
|
||||
if isAdmin {
|
||||
merged["_overlay"] = providerAdminState(po, ok)
|
||||
}
|
||||
out[name] = merged
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GateRootData gates the kitchen-sink root payload (GET /v1/pricing returns the
|
||||
// whole pricing blob) IN PLACE, so the root shows the exact same gated catalog
|
||||
// as the leaf routes — never an un-gated second source. It filters every field
|
||||
// that carries a model or provider identity:
|
||||
// - hanzoModels + thirdPartyModels via VisibleCatalog,
|
||||
// - providers via VisibleProviders,
|
||||
// - the id-reference lists freeModels and families[].models, kept only if the
|
||||
// referenced model survived the gate (customers); admins keep every ref.
|
||||
//
|
||||
// Aggregate summary counts and non-catalog sections (tools/infrastructure/cloud)
|
||||
// carry no catalog identity and are left untouched.
|
||||
func GateRootData(data map[string]any, snap map[string]Overlay, org string, isAdmin bool) {
|
||||
visible := map[string]bool{}
|
||||
|
||||
if arr, ok := modelArray(data["thirdPartyModels"]); ok {
|
||||
g := VisibleCatalog(arr, snap, org, isAdmin)
|
||||
data["thirdPartyModels"] = g
|
||||
for _, m := range g {
|
||||
visible[modelID(m)] = true
|
||||
}
|
||||
}
|
||||
// hanzoModels carry no provider field in raw data; tag "Hanzo" for the gate
|
||||
// exactly as the bundle's models route does, so disabling the "Hanzo"
|
||||
// provider cascades to hide them here too.
|
||||
if arr, ok := modelArray(data["hanzoModels"]); ok {
|
||||
tagged := make([]Model, len(arr))
|
||||
for i, m := range arr {
|
||||
tagged[i] = withProvider(m, "Hanzo")
|
||||
}
|
||||
g := VisibleCatalog(tagged, snap, org, isAdmin)
|
||||
data["hanzoModels"] = g
|
||||
for _, m := range g {
|
||||
visible[modelID(m)] = true
|
||||
}
|
||||
}
|
||||
if pm, ok := data["providers"].(map[string]any); ok {
|
||||
data["providers"] = VisibleProviders(pm, snap, org, isAdmin)
|
||||
}
|
||||
|
||||
// Admins see every id reference; customers see only references to models that
|
||||
// survived the gate above.
|
||||
if isAdmin {
|
||||
return
|
||||
}
|
||||
if fm, ok := data["freeModels"].([]any); ok {
|
||||
data["freeModels"] = filterIDList(fm, visible)
|
||||
}
|
||||
if fams, ok := data["families"].([]any); ok {
|
||||
for _, f := range fams {
|
||||
fmap, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ms, ok := fmap["models"].([]any); ok {
|
||||
fmap["models"] = filterIDList(ms, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modelArray coerces a decoded JSON array of objects into []Model.
|
||||
func modelArray(v any) ([]Model, bool) {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]Model, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
out = append(out, Model(m))
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// withProvider returns a copy of m with provider set when absent/empty.
|
||||
func withProvider(m Model, provider string) Model {
|
||||
t := make(Model, len(m)+1)
|
||||
for k, v := range m {
|
||||
t[k] = v
|
||||
}
|
||||
if p, _ := t["provider"].(string); p == "" {
|
||||
t["provider"] = provider
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// filterIDList keeps only the string entries present in visible; non-string
|
||||
// entries pass through untouched.
|
||||
func filterIDList(list []any, visible map[string]bool) []any {
|
||||
out := make([]any, 0, len(list))
|
||||
for _, e := range list {
|
||||
if s, ok := e.(string); ok {
|
||||
if visible[s] {
|
||||
out = append(out, e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func modelAdminState(mo Overlay, mok bool, po Overlay, pok bool) map[string]any {
|
||||
st := map[string]any{
|
||||
"modelEnabled": !mok || mo.Enabled,
|
||||
"providerEnabled": !pok || po.Enabled,
|
||||
}
|
||||
if mok {
|
||||
if len(mo.BetaOrgs) > 0 {
|
||||
st["modelBetaOrgs"] = mo.BetaOrgs
|
||||
}
|
||||
if len(mo.Overrides) > 0 {
|
||||
st["modelOverrides"] = mo.Overrides
|
||||
}
|
||||
}
|
||||
if pok && len(po.BetaOrgs) > 0 {
|
||||
st["providerBetaOrgs"] = po.BetaOrgs
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func providerAdminState(po Overlay, ok bool) map[string]any {
|
||||
st := map[string]any{"providerEnabled": !ok || po.Enabled}
|
||||
if ok {
|
||||
if len(po.BetaOrgs) > 0 {
|
||||
st["betaOrgs"] = po.BetaOrgs
|
||||
}
|
||||
if len(po.Overrides) > 0 {
|
||||
st["overrides"] = po.Overrides
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// ----- RFC 7386 JSON Merge Patch -------------------------------------------
|
||||
//
|
||||
// One override semantics, the standard one: object values merge recursively,
|
||||
// null deletes a key, anything else replaces. The same patch a JSON Merge Patch
|
||||
// client (the admin UI) would expect. Input maps are never mutated.
|
||||
|
||||
func mergeModel(base Model, raw json.RawMessage) Model {
|
||||
return Model(applyMergePatch(base, overridePatch(raw)))
|
||||
}
|
||||
|
||||
func overridePatch(raw json.RawMessage) map[string]any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil // malformed override never corrupts the catalog; ignored.
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func applyMergePatch(target, patch map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(target)+len(patch))
|
||||
for k, v := range target {
|
||||
out[k] = v
|
||||
}
|
||||
for k, pv := range patch {
|
||||
if pv == nil {
|
||||
delete(out, k)
|
||||
continue
|
||||
}
|
||||
if pm, ok := pv.(map[string]any); ok {
|
||||
if tm, ok2 := out[k].(map[string]any); ok2 {
|
||||
out[k] = applyMergePatch(tm, pm)
|
||||
} else {
|
||||
out[k] = applyMergePatch(map[string]any{}, pm)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out[k] = pv
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ----- store ----------------------------------------------------------------
|
||||
|
||||
// catalog is the enablement overlay store: one SQLite table mapping (kind,id) ->
|
||||
// {enabled, betaOrgs, overrides}. ONE file holds every entry; reads load a full
|
||||
// snapshot once per request and the gate then runs purely in memory.
|
||||
type catalog struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// openCatalog opens (creating if needed) the overlay DB at path and migrates it.
|
||||
// path may be ":memory:" for an ephemeral, non-persistent overlay (degraded
|
||||
// mode when no DataDir is configured). The "sqlite" driver is modernc's pure-Go
|
||||
// build. MaxOpenConns(1) serializes writes against the file lock without retry.
|
||||
func openCatalog(path string) (*catalog, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
c := &catalog{db: db}
|
||||
if err := c.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *catalog) migrate() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS catalog_overlay (
|
||||
kind TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
beta_orgs TEXT NOT NULL DEFAULT '[]',
|
||||
overrides TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (kind, id)
|
||||
);`
|
||||
if _, err := c.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *catalog) Close() error {
|
||||
if c == nil || c.db == nil {
|
||||
return nil
|
||||
}
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
const overlayCols = `kind,id,enabled,beta_orgs,overrides,updated_at`
|
||||
|
||||
func scanOverlay(sc interface{ Scan(...any) error }) (Overlay, error) {
|
||||
var (
|
||||
o Overlay
|
||||
enabled int
|
||||
betaJSON string
|
||||
ovr string
|
||||
)
|
||||
if err := sc.Scan(&o.Kind, &o.ID, &enabled, &betaJSON, &ovr, &o.UpdatedAt); err != nil {
|
||||
return Overlay{}, err
|
||||
}
|
||||
o.Enabled = enabled != 0
|
||||
if betaJSON != "" && betaJSON != "[]" {
|
||||
_ = json.Unmarshal([]byte(betaJSON), &o.BetaOrgs)
|
||||
}
|
||||
if ovr != "" {
|
||||
o.Overrides = json.RawMessage(ovr)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Snapshot loads every overlay row keyed overlayKey(kind,id). One read per
|
||||
// request; an empty store yields an empty map (the gate leaves the catalog
|
||||
// untouched).
|
||||
func (c *catalog) Snapshot(ctx context.Context) (map[string]Overlay, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `SELECT `+overlayCols+` FROM catalog_overlay`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snapshot: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[string]Overlay{}
|
||||
for rows.Next() {
|
||||
o, err := scanOverlay(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
out[overlayKey(o.Kind, o.ID)] = o
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Get returns the overlay for (kind,id) and whether a row exists.
|
||||
func (c *catalog) Get(ctx context.Context, kind, id string) (Overlay, bool, error) {
|
||||
row := c.db.QueryRowContext(ctx,
|
||||
`SELECT `+overlayCols+` FROM catalog_overlay WHERE kind=? AND id=?`, kind, id)
|
||||
o, err := scanOverlay(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Overlay{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Overlay{}, false, fmt.Errorf("get: %w", err)
|
||||
}
|
||||
return o, true, nil
|
||||
}
|
||||
|
||||
// Upsert writes the full overlay row, replacing any existing (kind,id) row.
|
||||
func (c *catalog) Upsert(ctx context.Context, o Overlay) error {
|
||||
beta := "[]"
|
||||
if len(o.BetaOrgs) > 0 {
|
||||
b, err := json.Marshal(o.BetaOrgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal betaOrgs: %w", err)
|
||||
}
|
||||
beta = string(b)
|
||||
}
|
||||
enabled := 0
|
||||
if o.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`INSERT INTO catalog_overlay (`+overlayCols+`) VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(kind,id) DO UPDATE SET
|
||||
enabled=excluded.enabled,
|
||||
beta_orgs=excluded.beta_orgs,
|
||||
overrides=excluded.overrides,
|
||||
updated_at=excluded.updated_at`,
|
||||
o.Kind, o.ID, enabled, beta, string(o.Overrides), o.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Models fetches the live overlay snapshot and gates `full` for org — the ONE
|
||||
// call the read path makes.
|
||||
func (c *catalog) Models(ctx context.Context, full []Model, org string, isAdmin bool) ([]Model, error) {
|
||||
snap, err := c.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return VisibleCatalog(full, snap, org, isAdmin), nil
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// catModel builds one catalog entry. id "" means a Hanzo-style model whose
|
||||
// overlay key is its name (mirrors the bundle: id || name).
|
||||
func catModel(id, name, provider string, extra map[string]any) Model {
|
||||
m := Model{}
|
||||
if id != "" {
|
||||
m["id"] = id
|
||||
}
|
||||
if name != "" {
|
||||
m["name"] = name
|
||||
}
|
||||
if provider != "" {
|
||||
m["provider"] = provider
|
||||
}
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// catSample is a 3-model fixture: one third-party (slashed id), one Hanzo
|
||||
// (id==name), one other third-party.
|
||||
func catSample() []Model {
|
||||
return []Model{
|
||||
catModel("anthropic/claude-opus-4.6", "Anthropic: Claude Opus 4.6", "Anthropic",
|
||||
map[string]any{"pricing": map[string]any{"input": float64(15), "output": float64(75)}}),
|
||||
catModel("", "zen4", "Hanzo", map[string]any{"tier": "ultra"}),
|
||||
catModel("openrouter/free-thing", "Free Thing", "OpenRouter", nil),
|
||||
}
|
||||
}
|
||||
|
||||
func catHasID(ms []Model, id string) bool {
|
||||
for _, m := range ms {
|
||||
if modelID(m) == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func catFind(ms []Model, id string) Model {
|
||||
for _, m := range ms {
|
||||
if modelID(m) == id {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Default (empty overlay) => every model visible, unchanged, no admin metadata.
|
||||
func TestVisibleCatalog_DefaultAllVisible(t *testing.T) {
|
||||
full := catSample()
|
||||
got := VisibleCatalog(full, map[string]Overlay{}, "acme", false)
|
||||
if len(got) != len(full) {
|
||||
t.Fatalf("default must show all: want %d, got %d", len(full), len(got))
|
||||
}
|
||||
for _, m := range got {
|
||||
if _, ok := m["_overlay"]; ok {
|
||||
t.Errorf("non-admin output must not carry _overlay: %v", m)
|
||||
}
|
||||
}
|
||||
// A no-org, non-admin caller also sees everything by default.
|
||||
if n := len(VisibleCatalog(full, map[string]Overlay{}, "", false)); n != len(full) {
|
||||
t.Errorf("empty-org default must show all: got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled entry is hidden from everyone EXCEPT orgs on its beta list.
|
||||
func TestVisibleCatalog_DisabledHiddenExceptBeta(t *testing.T) {
|
||||
full := catSample()
|
||||
snap := map[string]Overlay{
|
||||
overlayKey(kindModel, "zen4"): {Kind: kindModel, ID: "zen4", Enabled: false, BetaOrgs: []string{"acme"}},
|
||||
}
|
||||
|
||||
other := VisibleCatalog(full, snap, "other", false)
|
||||
if catHasID(other, "zen4") {
|
||||
t.Errorf("disabled zen4 must be hidden for non-beta org")
|
||||
}
|
||||
if len(other) != 2 {
|
||||
t.Errorf("want 2 visible for non-beta org, got %d", len(other))
|
||||
}
|
||||
|
||||
acme := VisibleCatalog(full, snap, "acme", false)
|
||||
if !catHasID(acme, "zen4") {
|
||||
t.Errorf("disabled zen4 must be visible for beta org acme")
|
||||
}
|
||||
if len(acme) != 3 {
|
||||
t.Errorf("want 3 visible for beta org, got %d", len(acme))
|
||||
}
|
||||
|
||||
if catHasID(VisibleCatalog(full, snap, "", false), "zen4") {
|
||||
t.Errorf("disabled zen4 must be hidden for empty org")
|
||||
}
|
||||
}
|
||||
|
||||
// Override merges over the model (RFC 7386): nested object merges, a new key is
|
||||
// added, and a null deletes a key.
|
||||
func TestVisibleCatalog_OverrideMerged(t *testing.T) {
|
||||
full := catSample()
|
||||
patch := json.RawMessage(`{"pricing":{"input":5},"badge":"promo","name":null}`)
|
||||
snap := map[string]Overlay{
|
||||
overlayKey(kindModel, "anthropic/claude-opus-4.6"): {
|
||||
Kind: kindModel, ID: "anthropic/claude-opus-4.6", Enabled: true, Overrides: patch,
|
||||
},
|
||||
}
|
||||
got := VisibleCatalog(full, snap, "acme", false)
|
||||
m := catFind(got, "anthropic/claude-opus-4.6")
|
||||
if m == nil {
|
||||
t.Fatal("overridden model missing from output")
|
||||
}
|
||||
pricing, ok := m["pricing"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("pricing must remain an object, got %T", m["pricing"])
|
||||
}
|
||||
if pricing["input"] != float64(5) {
|
||||
t.Errorf("nested override: input want 5, got %v", pricing["input"])
|
||||
}
|
||||
if pricing["output"] != float64(75) {
|
||||
t.Errorf("deep-merge must preserve untouched output=75, got %v", pricing["output"])
|
||||
}
|
||||
if m["badge"] != "promo" {
|
||||
t.Errorf("override must add badge=promo, got %v", m["badge"])
|
||||
}
|
||||
if _, ok := m["name"]; ok {
|
||||
t.Errorf("RFC 7386 null must delete name, still present: %v", m["name"])
|
||||
}
|
||||
// The override must NOT mutate the caller's input model.
|
||||
if _, ok := full[0]["badge"]; ok {
|
||||
t.Errorf("gate mutated the input catalog (badge leaked onto source)")
|
||||
}
|
||||
}
|
||||
|
||||
// Admin sees every entry, disabled included, each annotated with overlay state.
|
||||
func TestVisibleCatalog_AdminSeesAll(t *testing.T) {
|
||||
full := catSample()
|
||||
snap := map[string]Overlay{
|
||||
overlayKey(kindModel, "zen4"): {Kind: kindModel, ID: "zen4", Enabled: false},
|
||||
overlayKey(kindProvider, "OpenRouter"): {Kind: kindProvider, ID: "OpenRouter", Enabled: false, BetaOrgs: []string{"acme"}},
|
||||
}
|
||||
got := VisibleCatalog(full, snap, "", true)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("admin must see all 3 models, got %d", len(got))
|
||||
}
|
||||
zen := catFind(got, "zen4")
|
||||
ov, ok := zen["_overlay"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("admin model must carry _overlay annotation")
|
||||
}
|
||||
if ov["modelEnabled"] != false {
|
||||
t.Errorf("annotation modelEnabled want false, got %v", ov["modelEnabled"])
|
||||
}
|
||||
// The OpenRouter model is disabled via its PROVIDER; admin still sees it,
|
||||
// annotated providerEnabled=false.
|
||||
free := catFind(got, "openrouter/free-thing")
|
||||
fov := free["_overlay"].(map[string]any)
|
||||
if fov["providerEnabled"] != false {
|
||||
t.Errorf("annotation providerEnabled want false, got %v", fov["providerEnabled"])
|
||||
}
|
||||
}
|
||||
|
||||
// Disabling a PROVIDER cascades to hide all its models (except provider-beta orgs).
|
||||
func TestVisibleCatalog_ProviderCascade(t *testing.T) {
|
||||
full := catSample()
|
||||
snap := map[string]Overlay{
|
||||
overlayKey(kindProvider, "Anthropic"): {Kind: kindProvider, ID: "Anthropic", Enabled: false, BetaOrgs: []string{"acme"}},
|
||||
}
|
||||
other := VisibleCatalog(full, snap, "other", false)
|
||||
if catHasID(other, "anthropic/claude-opus-4.6") {
|
||||
t.Errorf("model under a disabled provider must be hidden")
|
||||
}
|
||||
if len(other) != 2 {
|
||||
t.Errorf("want 2 visible, got %d", len(other))
|
||||
}
|
||||
acme := VisibleCatalog(full, snap, "acme", false)
|
||||
if !catHasID(acme, "anthropic/claude-opus-4.6") {
|
||||
t.Errorf("provider beta org must see the provider's models")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleProviders(t *testing.T) {
|
||||
providers := map[string]any{
|
||||
"Anthropic": map[string]any{"total": float64(10)},
|
||||
"OpenRouter": map[string]any{"total": float64(5)},
|
||||
}
|
||||
snap := map[string]Overlay{
|
||||
overlayKey(kindProvider, "Anthropic"): {
|
||||
Kind: kindProvider, ID: "Anthropic", Enabled: false,
|
||||
Overrides: json.RawMessage(`{"label":"hidden"}`),
|
||||
},
|
||||
}
|
||||
got := VisibleProviders(providers, snap, "other", false)
|
||||
if _, ok := got["Anthropic"]; ok {
|
||||
t.Errorf("disabled provider must be hidden for non-beta org")
|
||||
}
|
||||
if _, ok := got["OpenRouter"]; !ok {
|
||||
t.Errorf("enabled provider must remain visible")
|
||||
}
|
||||
|
||||
adm := VisibleProviders(providers, snap, "", true)
|
||||
a, ok := adm["Anthropic"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("admin must see disabled provider")
|
||||
}
|
||||
if a["label"] != "hidden" {
|
||||
t.Errorf("provider override must merge: label want hidden, got %v", a["label"])
|
||||
}
|
||||
ov := a["_overlay"].(map[string]any)
|
||||
if ov["providerEnabled"] != false {
|
||||
t.Errorf("admin provider annotation providerEnabled want false, got %v", ov["providerEnabled"])
|
||||
}
|
||||
}
|
||||
|
||||
// Store: idempotent open, empty=clean, upsert/get/update round-trip, and the
|
||||
// end-to-end gate through the store.
|
||||
func TestCatalogStore_RoundTrip(t *testing.T) {
|
||||
c, err := openCatalog(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("openCatalog: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := c.migrate(); err != nil { // idempotent re-create
|
||||
t.Fatalf("re-migrate must be idempotent: %v", err)
|
||||
}
|
||||
if snap, err := c.Snapshot(ctx); err != nil || len(snap) != 0 {
|
||||
t.Fatalf("fresh store must be empty: len=%d err=%v", len(snap), err)
|
||||
}
|
||||
|
||||
o := Overlay{
|
||||
Kind: kindModel, ID: "anthropic/claude-opus-4.6", Enabled: false,
|
||||
BetaOrgs: []string{"acme", "beta"}, Overrides: json.RawMessage(`{"badge":"x"}`), UpdatedAt: 123,
|
||||
}
|
||||
if err := c.Upsert(ctx, o); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
got, ok, err := c.Get(ctx, kindModel, "anthropic/claude-opus-4.6")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.Enabled || len(got.BetaOrgs) != 2 || string(got.Overrides) != `{"badge":"x"}` || got.UpdatedAt != 123 {
|
||||
t.Errorf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// Re-upsert (PK conflict path) flips enabled and clears beta/overrides.
|
||||
if err := c.Upsert(ctx, Overlay{Kind: kindModel, ID: "anthropic/claude-opus-4.6", Enabled: true, UpdatedAt: 200}); err != nil {
|
||||
t.Fatalf("update upsert: %v", err)
|
||||
}
|
||||
got2, _, _ := c.Get(ctx, kindModel, "anthropic/claude-opus-4.6")
|
||||
if !got2.Enabled || len(got2.BetaOrgs) != 0 || len(got2.Overrides) != 0 {
|
||||
t.Errorf("update did not replace row: %+v", got2)
|
||||
}
|
||||
|
||||
// Absent row.
|
||||
if _, ok, _ := c.Get(ctx, kindModel, "nope"); ok {
|
||||
t.Errorf("absent row must report ok=false")
|
||||
}
|
||||
|
||||
// Gate through the store.
|
||||
_ = c.Upsert(ctx, Overlay{Kind: kindModel, ID: "zen4", Enabled: false, BetaOrgs: []string{"acme"}})
|
||||
full := catSample()
|
||||
if got, err := c.Models(ctx, full, "other", false); err != nil || catHasID(got, "zen4") {
|
||||
t.Errorf("Models: zen4 must be hidden for non-beta org (err=%v)", err)
|
||||
}
|
||||
if got, _ := c.Models(ctx, full, "acme", false); !catHasID(got, "zen4") {
|
||||
t.Errorf("Models: zen4 must be visible for beta org acme")
|
||||
}
|
||||
if got, _ := c.Models(ctx, full, "", true); !catHasID(got, "zen4") {
|
||||
t.Errorf("Models: admin must see disabled zen4")
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []any, s string) bool {
|
||||
for _, e := range list {
|
||||
if e == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// asModels reads a model array that may be []Model (after GateRootData) or []any
|
||||
// (raw decoded JSON).
|
||||
func asModels(v any) []Model {
|
||||
switch t := v.(type) {
|
||||
case []Model:
|
||||
return t
|
||||
case []any:
|
||||
m, _ := modelArray(t)
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The root /v1/pricing blob (raw arrays + id-reference lists + providers dict +
|
||||
// families) must be gated identically to the leaves — no un-gated second source.
|
||||
func TestGateRootData(t *testing.T) {
|
||||
root := func() map[string]any {
|
||||
return map[string]any{
|
||||
"updated": "x",
|
||||
"hanzoModels": []any{
|
||||
map[string]any{"name": "zen4", "tier": "ultra"},
|
||||
map[string]any{"name": "zen5"},
|
||||
},
|
||||
"thirdPartyModels": []any{
|
||||
map[string]any{"id": "anthropic/claude-opus-4.6", "name": "Opus", "provider": "Anthropic"},
|
||||
map[string]any{"id": "openrouter/free-thing", "name": "Free", "provider": "OpenRouter"},
|
||||
},
|
||||
"freeModels": []any{"openrouter/free-thing", "anthropic/claude-opus-4.6"},
|
||||
"providers": map[string]any{
|
||||
"Anthropic": map[string]any{"total": float64(1)},
|
||||
"OpenRouter": map[string]any{"total": float64(1)},
|
||||
},
|
||||
"families": []any{
|
||||
map[string]any{"id": "zen", "models": []any{"zen4", "zen5"}},
|
||||
},
|
||||
"summary": map[string]any{"totalModels": float64(4)},
|
||||
}
|
||||
}
|
||||
// zen4 disabled (model); Anthropic disabled (provider) with beta acme.
|
||||
snap := map[string]Overlay{
|
||||
overlayKey(kindModel, "zen4"): {Kind: kindModel, ID: "zen4", Enabled: false},
|
||||
overlayKey(kindProvider, "Anthropic"): {Kind: kindProvider, ID: "Anthropic", Enabled: false, BetaOrgs: []string{"acme"}},
|
||||
}
|
||||
|
||||
// Non-beta customer: zen4 and the Anthropic model gone from EVERY field.
|
||||
d := root()
|
||||
GateRootData(d, snap, "other", false)
|
||||
hz := asModels(d["hanzoModels"])
|
||||
if catHasID(hz, "zen4") {
|
||||
t.Errorf("root.hanzoModels leaked disabled zen4")
|
||||
}
|
||||
if !catHasID(hz, "zen5") {
|
||||
t.Errorf("root.hanzoModels dropped enabled zen5")
|
||||
}
|
||||
tp := asModels(d["thirdPartyModels"])
|
||||
if catHasID(tp, "anthropic/claude-opus-4.6") {
|
||||
t.Errorf("root.thirdPartyModels leaked provider-disabled model")
|
||||
}
|
||||
if fm := d["freeModels"].([]any); containsStr(fm, "anthropic/claude-opus-4.6") {
|
||||
t.Errorf("root.freeModels leaked provider-disabled id")
|
||||
} else if !containsStr(fm, "openrouter/free-thing") {
|
||||
t.Errorf("root.freeModels dropped a visible id")
|
||||
}
|
||||
famModels := d["families"].([]any)[0].(map[string]any)["models"].([]any)
|
||||
if containsStr(famModels, "zen4") {
|
||||
t.Errorf("root.families leaked disabled zen4 ref")
|
||||
}
|
||||
if !containsStr(famModels, "zen5") {
|
||||
t.Errorf("root.families dropped enabled zen5 ref")
|
||||
}
|
||||
provs := d["providers"].(map[string]any)
|
||||
if _, ok := provs["Anthropic"]; ok {
|
||||
t.Errorf("root.providers leaked disabled Anthropic")
|
||||
}
|
||||
if _, ok := provs["OpenRouter"]; !ok {
|
||||
t.Errorf("root.providers dropped OpenRouter")
|
||||
}
|
||||
if d["summary"].(map[string]any)["totalModels"] != float64(4) {
|
||||
t.Errorf("summary aggregate counts must be untouched")
|
||||
}
|
||||
|
||||
// Provider beta org sees the Anthropic model again; zen4 still hidden (no beta).
|
||||
d2 := root()
|
||||
GateRootData(d2, snap, "acme", false)
|
||||
if tp2 := asModels(d2["thirdPartyModels"]); !catHasID(tp2, "anthropic/claude-opus-4.6") {
|
||||
t.Errorf("provider beta org must see the model in root")
|
||||
}
|
||||
if hz2 := asModels(d2["hanzoModels"]); catHasID(hz2, "zen4") {
|
||||
t.Errorf("zen4 must stay hidden for acme (no model beta)")
|
||||
}
|
||||
|
||||
// Admin sees everything; id-reference lists untouched.
|
||||
d3 := root()
|
||||
GateRootData(d3, snap, "", true)
|
||||
if hz3 := asModels(d3["hanzoModels"]); !catHasID(hz3, "zen4") {
|
||||
t.Errorf("admin must see disabled zen4 in root")
|
||||
}
|
||||
if len(d3["freeModels"].([]any)) != 2 {
|
||||
t.Errorf("admin freeModels must be untouched")
|
||||
}
|
||||
if _, ok := d3["providers"].(map[string]any)["Anthropic"]; !ok {
|
||||
t.Errorf("admin must see disabled provider in root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOverride(t *testing.T) {
|
||||
ok := []json.RawMessage{
|
||||
json.RawMessage(`{"a":1}`),
|
||||
json.RawMessage(`null`),
|
||||
json.RawMessage(``),
|
||||
json.RawMessage(`{"pricing":{"input":5}}`),
|
||||
}
|
||||
for _, r := range ok {
|
||||
if err := checkOverride(r); err != nil {
|
||||
t.Errorf("checkOverride(%s) must pass: %v", r, err)
|
||||
}
|
||||
}
|
||||
bad := []json.RawMessage{
|
||||
json.RawMessage(`[1,2]`), // array
|
||||
json.RawMessage(`"x"`), // scalar
|
||||
json.RawMessage(`123`), // number
|
||||
}
|
||||
for _, r := range bad {
|
||||
if checkOverride(r) == nil {
|
||||
t.Errorf("checkOverride(%s) must be rejected", r)
|
||||
}
|
||||
}
|
||||
// Size cap.
|
||||
big := json.RawMessage(`{"a":"` + strings.Repeat("x", maxOverrideBytes) + `"}`)
|
||||
if checkOverride(big) == nil {
|
||||
t.Errorf("oversize override must be rejected")
|
||||
}
|
||||
// Depth cap: N nested objects => jsonDepth N.
|
||||
tooDeep := json.RawMessage(strings.Repeat(`{"a":`, maxOverrideDepth+2) + "1" + strings.Repeat("}", maxOverrideDepth+2))
|
||||
if checkOverride(tooDeep) == nil {
|
||||
t.Errorf("over-deep override must be rejected")
|
||||
}
|
||||
atLimit := json.RawMessage(strings.Repeat(`{"a":`, maxOverrideDepth-1) + "1" + strings.Repeat("}", maxOverrideDepth-1))
|
||||
if err := checkOverride(atLimit); err != nil {
|
||||
t.Errorf("at-limit depth override must pass: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
hplans "github.com/hanzoai/plans"
|
||||
)
|
||||
|
||||
// loadPlansCatalog returns the @hanzo/plans catalog the pricing bundle reads
|
||||
// for its subscription/blockchain/policy/tools/gpu endpoints. Sourced from the
|
||||
// plans embed module so cloud has ONE copy of the plan catalog feeding both
|
||||
// /v1/plans/* (plansvc) and /v1/pricing/{subscriptions,blockchain,…} (here).
|
||||
func loadPlansCatalog() (map[string]any, error) {
|
||||
return hplans.Data()
|
||||
}
|
||||
|
||||
// parseFloatEnv reads a float env var with a default (markup knobs).
|
||||
func parseFloatEnv(key string, dflt float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return dflt
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// Package pricingsvc mounts the @hanzo/pricing service into the unified cloud
|
||||
// binary under /v1/pricing/* (+ the /v1/models, /v1/gpu, /v1/tools aliases),
|
||||
// per HIP-0106.
|
||||
//
|
||||
// HONEST GOJA STATUS: @hanzo/pricing is an EXPRESS app. Express needs Node's
|
||||
// http/net stack and CANNOT run in goja. So the Express *transport* is dropped
|
||||
// and replaced by native zip routes; the pricing *handlers* (pure transforms
|
||||
// over data/pricing.json + the @hanzo/plans catalog) run in goja via the
|
||||
// goja/bundle.js shipped by github.com/hanzoai/pricing. The sync.mjs MARKUP
|
||||
// logic (toMTok/roundPrice/processOpenRouterModel/…) also runs in goja through
|
||||
// the bundle's applyMarkup(); the only thing that does NOT run in goja is the
|
||||
// live network fetch (OpenRouter/HuggingFace — no fetch/AbortController in
|
||||
// goja), which this wrapper performs with Go's net/http and then feeds the raw
|
||||
// JSON into applyMarkup. See SyncEnabled.
|
||||
//
|
||||
// Module boundary: pricing source + markup logic live in hanzoai/pricing. This
|
||||
// wrapper is glue. No pricing data or markup math is reimplemented in Go.
|
||||
//
|
||||
// IAM gating + X-Org-Id: read endpoints are open to any authenticated caller
|
||||
// (the public pricing catalog). The sync trigger is admin-only (c.IsAdmin()).
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/gojahost"
|
||||
hpricing "github.com/hanzoai/pricing"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
var (
|
||||
host *gojahost.Host
|
||||
// cat is the catalog enablement overlay (SQLite/Base). It lays mutable
|
||||
// {enabled,betaOrgs,overrides} state over the static bundle and gates the
|
||||
// catalog read path. nil only before Mount; the read handlers fall back to
|
||||
// the raw bundle output when it is nil.
|
||||
cat *catalog
|
||||
)
|
||||
|
||||
// Mount registers the pricing surface on app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("pricing.Mount: nil zip.App")
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
return fmt.Errorf("pricing.Mount: nil deps.Logger")
|
||||
}
|
||||
logger = logger.New("subsystem", "pricing")
|
||||
|
||||
bundle, err := hpricing.Bundle()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pricing.Mount: load bundle: %w", err)
|
||||
}
|
||||
pricingData, err := hpricing.Pricing()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pricing.Mount: load pricing.json: %w", err)
|
||||
}
|
||||
plansExtra, err := hpricing.PlansExtra()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pricing.Mount: load plans-extra: %w", err)
|
||||
}
|
||||
// The pricing bundle also reads the @hanzo/plans catalog for the
|
||||
// subscription/blockchain/policy/tools/gpu endpoints. We pull that from the
|
||||
// plans embed module so both subsystems share ONE source of truth.
|
||||
plansData, err := loadPlansCatalog()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pricing.Mount: load plans catalog: %w", err)
|
||||
}
|
||||
|
||||
h, err := gojahost.New(gojahost.Config{
|
||||
Name: "pricing",
|
||||
Bundle: bundle,
|
||||
Globals: map[string]any{
|
||||
"__PRICING_DATA__": pricingData,
|
||||
"__PLANS_EXTRA__": plansExtra,
|
||||
"__PLANS_DATA__": plansData,
|
||||
"__MARKUP__": map[string]any{
|
||||
"thirdParty": parseFloatEnv("THIRD_PARTY_MARKUP", 1.0),
|
||||
"computeMonthly": parseFloatEnv("COMPUTE_MARKUP_MONTHLY", 1.0),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("pricing.Mount: goja host: %w", err)
|
||||
}
|
||||
host = h
|
||||
|
||||
// Catalog enablement overlay. It is a SECURITY CONTROL: an admin-hidden model
|
||||
// must stay hidden across restarts. A non-persistent (in-memory) overlay would
|
||||
// silently re-expose hidden models on the next pod start — a fail-OPEN
|
||||
// degradation of a security control. So an empty DataDir is a hard boot error
|
||||
// (prod sets CLOUD_DATA_DIR), never a silent downgrade. provisioningsvc already
|
||||
// requires DataDir, so the unified binary always provides one.
|
||||
if deps.DataDir == "" {
|
||||
return fmt.Errorf("pricing.Mount: empty DataDir — the catalog enablement overlay requires a persistent data dir (set CLOUD_DATA_DIR); refusing to boot with a non-persistent overlay that would re-expose admin-hidden models on restart")
|
||||
}
|
||||
if err := os.MkdirAll(deps.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("pricing.Mount: data dir: %w", err)
|
||||
}
|
||||
dbPath := filepath.Join(deps.DataDir, "catalog.db")
|
||||
cstore, err := openCatalog(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pricing.Mount: open catalog overlay: %w", err)
|
||||
}
|
||||
cat = cstore
|
||||
|
||||
app.Get("/v1/pricing/health", func(c *zip.Ctx) error {
|
||||
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "service": "pricing"})
|
||||
})
|
||||
|
||||
// /v1/pricing/* read surface (mirrors server.mjs handler-for-handler). The
|
||||
// catalog routes (the root blob + models/free/featured/providers/summary +
|
||||
// the single model lookup) are gated below by the enablement overlay;
|
||||
// everything else is a straight pass-through of the bundle's output. The
|
||||
// `fixed` list is audited to carry NO model/provider identities (plans/infra/
|
||||
// tools/gpu/policy only) — confirmed against the plans catalog.
|
||||
type binding struct{ path, route string }
|
||||
fixed := []binding{
|
||||
{"/v1/pricing/compute", "compute"},
|
||||
{"/v1/pricing/compute/presets", "compute/presets"},
|
||||
{"/v1/pricing/cloud", "cloud"},
|
||||
{"/v1/pricing/cloud/plans", "cloud/plans"},
|
||||
{"/v1/pricing/cloud/regions", "cloud/regions"},
|
||||
{"/v1/pricing/cloud/storage", "cloud/storage"},
|
||||
{"/v1/pricing/subscriptions", "subscriptions"},
|
||||
{"/v1/pricing/blockchain", "blockchain"},
|
||||
{"/v1/pricing/iam", "iam"},
|
||||
{"/v1/pricing/base", "base"},
|
||||
{"/v1/pricing/paas", "paas"},
|
||||
{"/v1/pricing/policy", "policy"},
|
||||
{"/v1/pricing/tools", "tools"},
|
||||
{"/v1/pricing/gpu", "gpu"},
|
||||
}
|
||||
for _, b := range fixed {
|
||||
route := b.route
|
||||
app.Get(b.path, func(c *zip.Ctx) error { return dispatch(c, route, nil) })
|
||||
}
|
||||
|
||||
// Gated catalog read path: the overlay filters disabled/beta entries and
|
||||
// merges overrides for the calling org (admins see everything, flagged).
|
||||
// The root /v1/pricing returns the WHOLE blob (hanzoModels, thirdPartyModels,
|
||||
// providers, freeModels, families) so it MUST be gated too — else it is an
|
||||
// un-gated second source for everything the leaves hide.
|
||||
app.Get("/v1/pricing", gatedRoot)
|
||||
app.Get("/v1/pricing/models", gatedModelList("models"))
|
||||
app.Get("/v1/pricing/free", gatedModelList("free"))
|
||||
app.Get("/v1/pricing/featured", gatedModelList("featured"))
|
||||
app.Get("/v1/pricing/providers", gatedProviders)
|
||||
app.Get("/v1/pricing/summary", gatedSummary)
|
||||
app.Get("/v1/pricing/model/:name", gatedModel)
|
||||
|
||||
// Admin write surface for the overlay (global-admin only; see admin.go).
|
||||
app.Get("/v1/admin/catalog", adminCatalog)
|
||||
app.Patch("/v1/admin/catalog/models/*", adminPatchModel)
|
||||
app.Patch("/v1/admin/catalog/providers/:name", adminPatchProvider)
|
||||
|
||||
// Convenience aliases (the cleaner top-level surface from server.mjs).
|
||||
// NOTE: the bare /v1/models alias is DELIBERATELY NOT mounted here. In the
|
||||
// unified binary the AI subsystem owns the OpenAI-compatible /v1/models
|
||||
// (the {data:[{id,…}]} model list the api.hanzo.ai gateway forwards and
|
||||
// clients like cowork's model picker consume). Pricing's annotated catalog
|
||||
// already lives at /v1/pricing/models, so the bare alias would only shadow
|
||||
// AI's contract route with a different shape — a regression. Keep pricing
|
||||
// strictly under /v1/pricing/*. (Same reasoning the note below records for
|
||||
// /v1/plans, /v1/tools, /v1/gpu, /v1/cloud, /v1/subscriptions, /v1/iam —
|
||||
// all owned by other subsystems at the top level to avoid collisions.)
|
||||
app.Get("/v1/pricing-policy", func(c *zip.Ctx) error { return dispatch(c, "policy", nil) })
|
||||
|
||||
// Live sync trigger — admin only. Network fetch in Go, markup in goja.
|
||||
app.Post("/v1/pricing/sync", func(c *zip.Ctx) error {
|
||||
if !c.IsAdmin() {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]any{"error": "admin required"})
|
||||
}
|
||||
updated, err := RunSync(c.Context())
|
||||
if err != nil {
|
||||
c.Log().Error("pricing sync failed", "err", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "sync failed", "message": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "updated": updated})
|
||||
})
|
||||
|
||||
logger.Info("pricing mounted",
|
||||
"prefix", "/v1/pricing",
|
||||
"fixed_routes", len(fixed),
|
||||
"gated_routes", 6, // models, free, featured, providers, summary, model/:name
|
||||
"admin_routes", 3, // GET /v1/admin/catalog + PATCH models/* + PATCH providers/:name
|
||||
"overlay_db", dbPath,
|
||||
"express", false,
|
||||
"goja", true,
|
||||
"brand", deps.Brand,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rawDispatch runs a goja route and returns its status + JSON body — the single
|
||||
// point that touches the goja host on the read path. The gated handlers
|
||||
// post-filter this body through the overlay; dispatch writes it verbatim.
|
||||
func rawDispatch(c *zip.Ctx, route string, params map[string]string) (int, json.RawMessage, error) {
|
||||
if host == nil {
|
||||
return 0, nil, fmt.Errorf("pricing not initialised")
|
||||
}
|
||||
tenant := c.Org()
|
||||
if tenant == "" {
|
||||
tenant = "hanzo"
|
||||
}
|
||||
resp, err := host.Dispatch(c.Context(), gojahost.Request{Route: route, Params: params, Tenant: tenant})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return resp.Status, resp.Body, nil
|
||||
}
|
||||
|
||||
// dispatch writes a goja route's output verbatim — the ungated pass-through for
|
||||
// the non-catalog read routes.
|
||||
func dispatch(c *zip.Ctx, route string, params map[string]string) error {
|
||||
status, body, err := rawDispatch(c, route, params)
|
||||
if err != nil {
|
||||
c.Log().Error("pricing dispatch failed", "route", route, "err", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "pricing dispatch failed"})
|
||||
}
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
|
||||
func passthrough(c *zip.Ctx, status int, body json.RawMessage) error {
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return c.Bytes(status, body)
|
||||
}
|
||||
|
||||
// gatedModelList gates a {updated,total,models[]} route (models/free/featured)
|
||||
// through the overlay for the calling org, re-counting `total` over the gated set.
|
||||
func gatedModelList(route string) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
status, body, err := rawDispatch(c, route, nil)
|
||||
if err != nil {
|
||||
c.Log().Error("pricing dispatch failed", "route", route, "err", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "pricing dispatch failed"})
|
||||
}
|
||||
if status != http.StatusOK || cat == nil {
|
||||
return passthrough(c, status, body) // upstream error or no overlay: verbatim.
|
||||
}
|
||||
var payload struct {
|
||||
Updated any `json:"updated"`
|
||||
Models []Model `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return passthrough(c, status, body) // unrecognised shape: never corrupt it.
|
||||
}
|
||||
gated, err := cat.Models(c.Context(), payload.Models, strings.TrimSpace(c.Org()), c.IsAdmin())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "catalog gate failed"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"updated": payload.Updated,
|
||||
"total": len(gated),
|
||||
"models": gated,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// gatedModel gates the single-model lookup: a model hidden for the caller's org
|
||||
// 404s — indistinguishable from absent, so disabled models get no existence
|
||||
// oracle on the direct path.
|
||||
func gatedModel(c *zip.Ctx) error {
|
||||
name := c.Param("name")
|
||||
status, body, err := rawDispatch(c, "model", map[string]string{"name": name})
|
||||
if err != nil {
|
||||
c.Log().Error("pricing dispatch failed", "route", "model", "err", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "pricing dispatch failed"})
|
||||
}
|
||||
if status != http.StatusOK || cat == nil {
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
var m Model
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
gated, err := cat.Models(c.Context(), []Model{m}, strings.TrimSpace(c.Org()), c.IsAdmin())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "catalog gate failed"})
|
||||
}
|
||||
if len(gated) == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]any{"error": "Model not found: " + name})
|
||||
}
|
||||
return c.JSON(http.StatusOK, gated[0])
|
||||
}
|
||||
|
||||
// gatedProviders gates the {updated,providers{}} dict route.
|
||||
func gatedProviders(c *zip.Ctx) error {
|
||||
status, body, err := rawDispatch(c, "providers", nil)
|
||||
if err != nil {
|
||||
c.Log().Error("pricing dispatch failed", "route", "providers", "err", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "pricing dispatch failed"})
|
||||
}
|
||||
if status != http.StatusOK || cat == nil {
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
var payload struct {
|
||||
Updated any `json:"updated"`
|
||||
Providers map[string]any `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
snap, err := cat.Snapshot(c.Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "catalog gate failed"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"updated": payload.Updated,
|
||||
"providers": VisibleProviders(payload.Providers, snap, strings.TrimSpace(c.Org()), c.IsAdmin()),
|
||||
})
|
||||
}
|
||||
|
||||
// gatedSummary passes the stats summary through but filters its providers
|
||||
// sub-dict so a disabled provider's name never leaks. Aggregate counts are the
|
||||
// bundle's catalog-wide stats, intentionally left untouched.
|
||||
func gatedSummary(c *zip.Ctx) error {
|
||||
status, body, err := rawDispatch(c, "summary", nil)
|
||||
if err != nil {
|
||||
c.Log().Error("pricing dispatch failed", "route", "summary", "err", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "pricing dispatch failed"})
|
||||
}
|
||||
if status != http.StatusOK || cat == nil {
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
if provs, ok := payload["providers"].(map[string]any); ok {
|
||||
snap, err := cat.Snapshot(c.Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "catalog gate failed"})
|
||||
}
|
||||
payload["providers"] = VisibleProviders(provs, snap, strings.TrimSpace(c.Org()), c.IsAdmin())
|
||||
}
|
||||
return c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// gatedRoot gates the kitchen-sink root payload (GET /v1/pricing returns the
|
||||
// whole blob) so it never leaks a model/provider that the leaf routes hide.
|
||||
func gatedRoot(c *zip.Ctx) error {
|
||||
status, body, err := rawDispatch(c, "pricing", nil)
|
||||
if err != nil {
|
||||
c.Log().Error("pricing dispatch failed", "route", "pricing", "err", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "pricing dispatch failed"})
|
||||
}
|
||||
if status != http.StatusOK || cat == nil {
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return passthrough(c, status, body)
|
||||
}
|
||||
snap, err := cat.Snapshot(c.Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "catalog gate failed"})
|
||||
}
|
||||
GateRootData(data, snap, strings.TrimSpace(c.Org()), c.IsAdmin())
|
||||
return c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// RunSync performs the live third-party model sync: fetch upstream listings
|
||||
// (network — Go's net/http, since goja has no fetch), run the markup transform
|
||||
// in goja via the bundle's applyMarkup(), and swap the shaped third-party
|
||||
// section into the served catalog. Returns an ISO timestamp.
|
||||
//
|
||||
// This is the HONEST split: network IO in Go, markup math in JS. Only the
|
||||
// dynamic third-party section is refreshed here; the Zen catalog + cloud/DO
|
||||
// pricing in sync.mjs need the zen-gateway + DO credentials and stay on the
|
||||
// standalone sync path for now.
|
||||
func RunSync(ctx context.Context) (string, error) {
|
||||
if host == nil {
|
||||
return "", fmt.Errorf("pricing not initialised")
|
||||
}
|
||||
raw := map[string]any{}
|
||||
|
||||
// OpenRouter — public, no auth.
|
||||
if or, err := fetchJSON(ctx, "https://openrouter.ai/api/v1/models"); err == nil {
|
||||
if m, ok := or.(map[string]any); ok {
|
||||
raw["openrouter"] = m["data"]
|
||||
}
|
||||
}
|
||||
// (HuggingFace router needs a token; left to the standalone path unless
|
||||
// HF_TOKEN is wired. We still call applyMarkup with whatever we fetched so
|
||||
// the markup logic runs in goja.)
|
||||
|
||||
rawJSON, _ := json.Marshal(raw)
|
||||
shapedJSON, err := host.Eval(ctx, "applyMarkup", rawJSON)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("applyMarkup: %w", err)
|
||||
}
|
||||
var shaped map[string]any
|
||||
if err := json.Unmarshal(shapedJSON, &shaped); err != nil {
|
||||
return "", fmt.Errorf("decode shaped: %w", err)
|
||||
}
|
||||
// Merge the freshly-shaped third-party section into the served catalog and
|
||||
// re-inject so subsequent reads see it.
|
||||
cur, _ := hpricing.Pricing()
|
||||
if m, ok := cur.(map[string]any); ok {
|
||||
if v, ok := shaped["thirdPartyModels"]; ok {
|
||||
m["thirdPartyModels"] = v
|
||||
}
|
||||
if v, ok := shaped["providers"]; ok {
|
||||
m["providers"] = v
|
||||
}
|
||||
if v, ok := shaped["freeModels"]; ok {
|
||||
m["freeModels"] = v
|
||||
}
|
||||
ts := time.Now().UTC().Format(time.RFC3339)
|
||||
m["updated"] = ts
|
||||
host.SetGlobal("__PRICING_DATA__", m)
|
||||
return ts, nil
|
||||
}
|
||||
return time.Now().UTC().Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
func fetchJSON(ctx context.Context, url string) (any, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: status %d", url, resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
cloud.Register("pricing", 112, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("pricing.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
|
||||
// Shutdown drops the goja host and closes the catalog overlay store. Idempotent.
|
||||
func Shutdown(context.Context) error {
|
||||
var herr error
|
||||
if host != nil {
|
||||
herr = host.Close()
|
||||
host = nil
|
||||
}
|
||||
if cat != nil {
|
||||
_ = cat.Close()
|
||||
cat = nil
|
||||
}
|
||||
return herr
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/gojahost"
|
||||
hplans "github.com/hanzoai/plans"
|
||||
hpricing "github.com/hanzoai/pricing"
|
||||
)
|
||||
|
||||
// newHost loads the REAL @hanzo/pricing goja bundle + embedded catalogs, so
|
||||
// this exercises the actual server.mjs handler port + sync.mjs markup port
|
||||
// running in goja (Express dropped).
|
||||
func newHost(t *testing.T) *gojahost.Host {
|
||||
t.Helper()
|
||||
bundle, err := hpricing.Bundle()
|
||||
if err != nil {
|
||||
t.Fatalf("Bundle: %v", err)
|
||||
}
|
||||
pricingData, err := hpricing.Pricing()
|
||||
if err != nil {
|
||||
t.Fatalf("Pricing: %v", err)
|
||||
}
|
||||
plansExtra, err := hpricing.PlansExtra()
|
||||
if err != nil {
|
||||
t.Fatalf("PlansExtra: %v", err)
|
||||
}
|
||||
plansData, err := hplans.Data()
|
||||
if err != nil {
|
||||
t.Fatalf("plans.Data: %v", err)
|
||||
}
|
||||
h, err := gojahost.New(gojahost.Config{
|
||||
Name: "pricing",
|
||||
Bundle: bundle,
|
||||
Globals: map[string]any{
|
||||
"__PRICING_DATA__": pricingData,
|
||||
"__PLANS_EXTRA__": plansExtra,
|
||||
"__PLANS_DATA__": plansData,
|
||||
"__MARKUP__": map[string]any{"thirdParty": 1.0, "computeMonthly": 1.0},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("gojahost.New: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestPricing_Summary(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), gojahost.Request{Route: "summary"})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
TotalModels int `json:"totalModels"`
|
||||
ZenModels int `json:"zenModels"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.TotalModels <= 0 || body.ZenModels <= 0 {
|
||||
t.Fatalf("expected non-zero model counts, got total=%d zen=%d", body.TotalModels, body.ZenModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricing_PublicStripsInternal(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), gojahost.Request{Route: "pricing"})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Cloud map[string]json.RawMessage `json:"cloud"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, leaked := body.Cloud["_internal"]; leaked {
|
||||
t.Fatal("cloud._internal (provider costs / routing) leaked into public /v1/pricing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricing_Model404(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), gojahost.Request{Route: "model", Params: map[string]string{"name": "no-such-model"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if resp.Status != 404 {
|
||||
t.Fatalf("status = %d, want 404", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricing_SubscriptionsFromPlans(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
resp, err := h.Dispatch(context.Background(), gojahost.Request{Route: "subscriptions"})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
var body struct {
|
||||
Plans []map[string]any `json:"plans"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(body.Plans) == 0 {
|
||||
t.Fatal("expected subscription plans from @hanzo/plans")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPricing_ApplyMarkupRunsInGoja proves the sync.mjs markup transform runs
|
||||
// in goja: feed a raw OpenRouter-shaped model, assert toMTok markup math.
|
||||
func TestPricing_ApplyMarkupRunsInGoja(t *testing.T) {
|
||||
h := newHost(t)
|
||||
defer h.Close()
|
||||
raw := `{"openrouter":[{"id":"openai/gpt-4o","name":"GPT-4o","context_length":128000,"pricing":{"prompt":"0.0000025","completion":"0.00001"}}],"huggingface":[{"id":"meta-llama/Llama-3.1-8B"}]}`
|
||||
out, err := h.Eval(context.Background(), "applyMarkup", []byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("Eval applyMarkup: %v", err)
|
||||
}
|
||||
var shaped struct {
|
||||
ThirdPartyModels []struct {
|
||||
ID string `json:"id"`
|
||||
Pricing struct {
|
||||
Input float64 `json:"input"`
|
||||
Output float64 `json:"output"`
|
||||
} `json:"pricing"`
|
||||
} `json:"thirdPartyModels"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &shaped); err != nil {
|
||||
t.Fatalf("unmarshal shaped: %v", err)
|
||||
}
|
||||
if len(shaped.ThirdPartyModels) != 2 {
|
||||
t.Fatalf("expected 2 shaped models, got %d", len(shaped.ThirdPartyModels))
|
||||
}
|
||||
// toMTok: 0.0000025 * 1e6 * 1.0 = 2.5 ; 0.00001 * 1e6 = 10
|
||||
var gpt4o *struct {
|
||||
ID string `json:"id"`
|
||||
Pricing struct {
|
||||
Input float64 `json:"input"`
|
||||
Output float64 `json:"output"`
|
||||
} `json:"pricing"`
|
||||
}
|
||||
for i := range shaped.ThirdPartyModels {
|
||||
if shaped.ThirdPartyModels[i].ID == "openai/gpt-4o" {
|
||||
gpt4o = &shaped.ThirdPartyModels[i]
|
||||
}
|
||||
}
|
||||
if gpt4o == nil {
|
||||
t.Fatal("gpt-4o not in shaped output")
|
||||
}
|
||||
if gpt4o.Pricing.Input != 2.5 || gpt4o.Pricing.Output != 10 {
|
||||
t.Fatalf("markup math wrong: input=%v output=%v, want 2.5/10", gpt4o.Pricing.Input, gpt4o.Pricing.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Package productsvc exposes the read-only Search and Vector product surfaces
|
||||
// the Hanzo console panels call at api.cloud.hanzo.ai, per HIP-0106.
|
||||
//
|
||||
// The console's Search/Indexes and Vector panels call
|
||||
// https://api.hanzo.ai/v1/search-docs/* and /v1/vector/* with a
|
||||
// bearer key (HANZO_SEARCH_API_KEY / HANZO_VECTOR_API_KEY). cloud-api is the
|
||||
// single edge that owns those paths: this subsystem proxies them to the
|
||||
// in-cluster Meilisearch (search.hanzo.svc) and Qdrant (vector.hanzo.svc)
|
||||
// services and translates each upstream response into the exact JSON shape the
|
||||
// console's tRPC routers decode. No search/vector logic is reimplemented — this
|
||||
// is a shape-translating proxy.
|
||||
//
|
||||
// Auth: the gateway middleware (order 80) bypasses these paths via
|
||||
// AUTH_PUBLIC_PATHS (the bearer is an opaque service key, not a JWT). This
|
||||
// subsystem enforces the key itself with a constant-time compare against the
|
||||
// configured upstream master key, so the endpoints are never open.
|
||||
//
|
||||
// Module boundary: search lives in the Meilisearch service, vectors in Qdrant.
|
||||
// This wrapper is glue; it holds no index/collection state.
|
||||
package product
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// config is resolved once from env at Mount. Endpoints default to the
|
||||
// in-cluster service DNS; keys come from the search/vector secrets already
|
||||
// mounted on the cloud-api deployment.
|
||||
type config struct {
|
||||
searchURL string
|
||||
searchKey string
|
||||
vectorURL string
|
||||
vectorKey string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
return config{
|
||||
searchURL: getenv("searchEndpoint", "http://search.hanzo.svc.cluster.local:7700"),
|
||||
searchKey: os.Getenv("searchApiKey"),
|
||||
vectorURL: getenv("vectorEndpoint", "http://vector.hanzo.svc.cluster.local:6333"),
|
||||
vectorKey: os.Getenv("vectorApiKey"),
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, dflt string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return dflt
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// Mount registers the product (search + vector) read surface on app per
|
||||
// HIP-0106. Read-only: every panel degrades to an honest empty state in the
|
||||
// console when this surface is unreachable, so these handlers prefer returning
|
||||
// an empty-but-valid body over a 5xx whenever the upstream hiccups.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("product.Mount: nil zip.App")
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
return fmt.Errorf("product.Mount: nil deps.Logger")
|
||||
}
|
||||
logger = logger.New("subsystem", "product")
|
||||
cfg := loadConfig()
|
||||
|
||||
// ── Search (Meilisearch-backed) ───────────────────────────────────
|
||||
app.Get("/v1/search-docs/indexes", func(c *zip.Ctx) error {
|
||||
if err := authorize(c, cfg.searchKey); err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := meiliStats(cfg)
|
||||
if err != nil {
|
||||
logger.Warn("search indexes: meili stats unreachable", "err", err)
|
||||
return c.JSON(http.StatusOK, map[string]any{"indexes": []searchIndex{}})
|
||||
}
|
||||
created := meiliIndexCreatedAt(cfg) // best-effort; may be empty
|
||||
out := make([]searchIndex, 0, len(stats.Indexes))
|
||||
for name, ix := range stats.Indexes {
|
||||
ts := created[name]
|
||||
out = append(out, searchIndex{
|
||||
Name: name,
|
||||
DocCount: ix.NumberOfDocuments,
|
||||
LastIndexedAt: nullableTS(ts.updatedAt),
|
||||
CreatedAt: orNow(ts.createdAt),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return c.JSON(http.StatusOK, map[string]any{"indexes": out})
|
||||
})
|
||||
|
||||
app.Get("/v1/search-docs/stats", func(c *zip.Ctx) error {
|
||||
if err := authorize(c, cfg.searchKey); err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := meiliStats(cfg)
|
||||
if err != nil {
|
||||
logger.Warn("search stats: meili unreachable", "err", err)
|
||||
return c.JSON(http.StatusOK, searchStats{SearchesPerDay: []dayCount{}})
|
||||
}
|
||||
var total int64
|
||||
for _, ix := range stats.Indexes {
|
||||
total += ix.NumberOfDocuments
|
||||
}
|
||||
return c.JSON(http.StatusOK, searchStats{
|
||||
TotalDocuments: total,
|
||||
// Meilisearch keeps no query-history counters; sessions/searches and
|
||||
// the per-day series are not derivable from the index. Report the
|
||||
// honest zero/empty rather than a fabricated number.
|
||||
TotalSearches: 0,
|
||||
TotalSessions: 0,
|
||||
SearchesPerDay: []dayCount{},
|
||||
})
|
||||
})
|
||||
|
||||
// ── Vector (Qdrant-backed) ────────────────────────────────────────
|
||||
app.Get("/v1/vector/collections", func(c *zip.Ctx) error {
|
||||
if err := authorize(c, cfg.vectorKey); err != nil {
|
||||
return err
|
||||
}
|
||||
cols, err := qdrantCollections(cfg)
|
||||
if err != nil {
|
||||
logger.Warn("vector collections: qdrant unreachable", "err", err)
|
||||
return c.JSON(http.StatusOK, map[string]any{"collections": []vectorCollection{}})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"collections": cols})
|
||||
})
|
||||
|
||||
app.Get("/v1/vector/stats", func(c *zip.Ctx) error {
|
||||
if err := authorize(c, cfg.vectorKey); err != nil {
|
||||
return err
|
||||
}
|
||||
cols, err := qdrantCollections(cfg)
|
||||
if err != nil {
|
||||
logger.Warn("vector stats: qdrant unreachable", "err", err)
|
||||
return c.JSON(http.StatusOK, vectorStats{})
|
||||
}
|
||||
var vectors, storage int64
|
||||
for _, col := range cols {
|
||||
vectors += col.VectorCount
|
||||
storage += col.StorageBytes
|
||||
}
|
||||
return c.JSON(http.StatusOK, vectorStats{
|
||||
TotalCollections: int64(len(cols)),
|
||||
TotalVectors: vectors,
|
||||
TotalStorageBytes: storage,
|
||||
})
|
||||
})
|
||||
|
||||
logger.Info("product surface mounted",
|
||||
"search", cfg.searchURL, "vector", cfg.vectorURL,
|
||||
"searchKey", cfg.searchKey != "", "vectorKey", cfg.vectorKey != "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorize enforces the bearer key against the configured upstream key with a
|
||||
// constant-time compare. It returns a *zip.HTTPError (which zip's errorHandler
|
||||
// renders as a JSON body with the right status) on rejection and writes
|
||||
// nothing itself, so the handler simply `return`s the error — no double-write.
|
||||
// An unset key fails closed (503) so a mis-provisioned deploy never silently
|
||||
// serves an open endpoint.
|
||||
func authorize(c *zip.Ctx, want string) error {
|
||||
if want == "" {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "product surface not configured")
|
||||
}
|
||||
got := bearer(c.Header("Authorization"))
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||
return zip.ErrUnauthorized("invalid api key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bearer(h string) string {
|
||||
const p = "Bearer "
|
||||
if len(h) > len(p) && h[:len(p)] == p {
|
||||
return h[len(p):]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ── console response shapes (must match web/src/features/{search,vector}/types.ts) ──
|
||||
|
||||
type searchIndex struct {
|
||||
Name string `json:"name"`
|
||||
DocCount int64 `json:"docCount"`
|
||||
LastIndexedAt *string `json:"lastIndexedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type dayCount struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type searchStats struct {
|
||||
TotalDocuments int64 `json:"totalDocuments"`
|
||||
TotalSearches int64 `json:"totalSearches"`
|
||||
TotalSessions int64 `json:"totalSessions"`
|
||||
SearchesPerDay []dayCount `json:"searchesPerDay"`
|
||||
}
|
||||
|
||||
type vectorCollection struct {
|
||||
Name string `json:"name"`
|
||||
VectorCount int64 `json:"vectorCount"`
|
||||
Dimension int64 `json:"dimension"`
|
||||
DistanceMetric string `json:"distanceMetric"`
|
||||
StorageBytes int64 `json:"storageBytes,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type vectorStats struct {
|
||||
TotalCollections int64 `json:"totalCollections"`
|
||||
TotalVectors int64 `json:"totalVectors"`
|
||||
TotalStorageBytes int64 `json:"totalStorageBytes"`
|
||||
}
|
||||
|
||||
// ── Meilisearch upstream ──────────────────────────────────────────────
|
||||
|
||||
type meiliStatsResp struct {
|
||||
DatabaseSize int64 `json:"databaseSize"`
|
||||
LastUpdate string `json:"lastUpdate"`
|
||||
Indexes map[string]struct {
|
||||
NumberOfDocuments int64 `json:"numberOfDocuments"`
|
||||
RawDocumentDbSize int64 `json:"rawDocumentDbSize"`
|
||||
IsIndexing bool `json:"isIndexing"`
|
||||
} `json:"indexes"`
|
||||
}
|
||||
|
||||
func meiliStats(cfg config) (*meiliStatsResp, error) {
|
||||
var out meiliStatsResp
|
||||
if err := getJSON(cfg.searchURL+"/stats", "Authorization", "Bearer "+cfg.searchKey, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
type indexTimes struct{ createdAt, updatedAt string }
|
||||
|
||||
// meiliIndexCreatedAt fetches the index list for createdAt/updatedAt. Best
|
||||
// effort: if it fails the caller falls back to now() / null.
|
||||
func meiliIndexCreatedAt(cfg config) map[string]indexTimes {
|
||||
out := map[string]indexTimes{}
|
||||
var resp struct {
|
||||
Results []struct {
|
||||
UID string `json:"uid"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := getJSON(cfg.searchURL+"/indexes?limit=1000", "Authorization", "Bearer "+cfg.searchKey, &resp); err != nil {
|
||||
return out
|
||||
}
|
||||
for _, r := range resp.Results {
|
||||
out[r.UID] = indexTimes{createdAt: r.CreatedAt, updatedAt: r.UpdatedAt}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Qdrant upstream ───────────────────────────────────────────────────
|
||||
|
||||
func qdrantCollections(cfg config) ([]vectorCollection, error) {
|
||||
var list struct {
|
||||
Result struct {
|
||||
Collections []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"collections"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := getJSON(cfg.vectorURL+"/collections", "api-key", cfg.vectorKey, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]vectorCollection, 0, len(list.Result.Collections))
|
||||
for _, c := range list.Result.Collections {
|
||||
col := vectorCollection{Name: c.Name, DistanceMetric: "cosine"}
|
||||
// Per-collection detail carries dimension/distance/point-count. Best
|
||||
// effort: a single missing collection should not blank the whole panel.
|
||||
var info struct {
|
||||
Result struct {
|
||||
PointsCount int64 `json:"points_count"`
|
||||
Config struct {
|
||||
Params struct {
|
||||
Vectors struct {
|
||||
Size int64 `json:"size"`
|
||||
Distance string `json:"distance"`
|
||||
} `json:"vectors"`
|
||||
} `json:"params"`
|
||||
} `json:"config"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := getJSON(cfg.vectorURL+"/collections/"+c.Name, "api-key", cfg.vectorKey, &info); err == nil {
|
||||
col.VectorCount = info.Result.PointsCount
|
||||
col.Dimension = info.Result.Config.Params.Vectors.Size
|
||||
if d := info.Result.Config.Params.Vectors.Distance; d != "" {
|
||||
col.DistanceMetric = d
|
||||
}
|
||||
}
|
||||
out = append(out, col)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── http helper ───────────────────────────────────────────────────────
|
||||
|
||||
func getJSON(url, hdrKey, hdrVal string, into any) error {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hdrKey != "" {
|
||||
req.Header.Set(hdrKey, hdrVal)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: status %d: %s", url, resp.StatusCode, truncate(body, 200))
|
||||
}
|
||||
return json.Unmarshal(body, into)
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) > n {
|
||||
return string(b[:n])
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func nullableTS(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func orNow(s string) string {
|
||||
if s == "" {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func init() {
|
||||
cloud.Register("product", 145, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("product.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# `/v1/projects` — Shared Projects API (contract)
|
||||
|
||||
The ONE org-scoped store of buildable/deployable sites. The SAME records are
|
||||
read/written by **hanzo.app** (the builder) and **console.hanzo.ai** (the
|
||||
Projects module). Both call this surface through the gateway; there is no second
|
||||
copy of project state.
|
||||
|
||||
Owner: `hanzoai/cloud` → `clients/projectsvc`. Mounted into the unified cloud
|
||||
binary (HIP-0106), reachable at `https://api.hanzo.ai/v1/projects`.
|
||||
|
||||
## Auth & tenancy (HIP-0111)
|
||||
|
||||
- Authenticate via the `@hanzo/iam` SDK; send the IAM bearer token to
|
||||
`api.hanzo.ai`. The **gateway** validates the JWT and injects `X-Org-Id`,
|
||||
`X-User-Id`, `X-User-Email` (and strips any client-supplied copies).
|
||||
- Every route is **org-scoped**: the tenant is the gateway-minted `X-Org-Id`
|
||||
(the JWT `owner` claim). No org → `403` (admins fall back to an `admin`
|
||||
bucket). Two orgs can hold the same `slug`; `(org, slug)` is unique.
|
||||
- Never send `X-Org-Id` from the browser — it is ignored/stripped at the edge.
|
||||
|
||||
## Resources
|
||||
|
||||
### Project
|
||||
|
||||
```ts
|
||||
type Project = {
|
||||
id: string; // "proj_<token>"
|
||||
org: string; // tenant slug (from JWT)
|
||||
slug: string; // org-unique handle; ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$
|
||||
name: string; // display name
|
||||
description?: string;
|
||||
repo: { url?: string; branch?: string; provider?: string }; // provider: github|gitlab|bitbucket|git
|
||||
framework: string; // static|vite|next|react|astro|svelte|vue|remix|nuxt
|
||||
status: "draft" | "building" | "live" | "error";
|
||||
liveUrl?: string; // set once deployed
|
||||
bucket?: string; // S3 bucket holding the site
|
||||
currentDeploymentId?: string; // "dep_<token>"
|
||||
createdAt: number; // unix seconds
|
||||
updatedAt: number;
|
||||
};
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```ts
|
||||
type Deployment = {
|
||||
id: string; // "dep_<token>"
|
||||
projectId: string;
|
||||
version: number; // monotonic per project, 1-based
|
||||
status: "queued" | "building" | "uploading" | "live" | "error";
|
||||
source: "upload" | "git";
|
||||
commit?: string;
|
||||
liveUrl?: string;
|
||||
bucket?: string;
|
||||
prefix?: string; // "<org>/<slug>"
|
||||
files: number;
|
||||
bytes: number;
|
||||
message?: string; // error or note
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Body | Returns |
|
||||
|--------|------|------|---------|
|
||||
| `POST` | `/v1/projects` | `CreateProject` | `201 Project` |
|
||||
| `GET` | `/v1/projects` | — | `200 Project[]` (org, newest-updated first) |
|
||||
| `GET` | `/v1/projects/:slug` | — | `200 Project` / `404` |
|
||||
| `PATCH` | `/v1/projects/:slug` | `UpdateProject` | `200 Project` |
|
||||
| `DELETE` | `/v1/projects/:slug` | — | `204` (also purges the live S3 site) |
|
||||
| `POST` | `/v1/projects/:slug/deploy` | artifact **or** `GitDeploy` | `200 Deployment` (upload) / `202 Deployment` (git) |
|
||||
| `GET` | `/v1/projects/:slug/deployments` | — | `200 Deployment[]` (newest version first) |
|
||||
| `GET` | `/v1/projects/:slug/deployments/:id` | — | `200 Deployment` / `404` |
|
||||
| `POST` | `/v1/projects/:slug/deployments/:id/complete` | `Complete` | `200 Deployment` (CI hook, git path) |
|
||||
|
||||
```ts
|
||||
type CreateProject = {
|
||||
name: string; // required
|
||||
slug?: string; // defaults to slugify(name)
|
||||
description?: string;
|
||||
framework?: string; // defaults to "static"
|
||||
repo?: { url?: string; branch?: string };
|
||||
};
|
||||
|
||||
type UpdateProject = { // all optional; only provided fields change
|
||||
name?: string;
|
||||
description?: string;
|
||||
framework?: string;
|
||||
repo?: { url?: string; branch?: string };
|
||||
};
|
||||
|
||||
type GitDeploy = { source: "git"; commit?: string; branch?: string }; // Content-Type: application/json
|
||||
type Complete = { status: "live" | "error"; commit?: string; liveUrl?: string; message?: string; files?: number; bytes?: number };
|
||||
```
|
||||
|
||||
Errors are JSON `{ "error": string, "code": number }` with the matching HTTP
|
||||
status (`400` validation, `403` no org, `404` not found, `409` slug taken,
|
||||
`502/503` deploy/storage failures).
|
||||
|
||||
## Deploy pipeline (two modes, one endpoint)
|
||||
|
||||
1. **Artifact (builder one-click).** `POST /v1/projects/:slug/deploy` with the
|
||||
request body set to a **tar** or **tar.gz** of the BUILT site (must contain
|
||||
`index.html` at the root; bounded by the gateway body limit). The site is
|
||||
unpacked to OUR S3 at `s3://<bucket>/<org>/<slug>/`, the bucket is marked
|
||||
public-read, and the deployment lands `live` with a `liveUrl`. Synchronous.
|
||||
|
||||
2. **Git (CI, never local).** `POST /v1/projects/:slug/deploy` with
|
||||
`Content-Type: application/json` and `{"source":"git"}`. Returns `202` with a
|
||||
`queued` deployment. CI (the reusable build workflow) checks out the linked
|
||||
repo, builds it, syncs `dist/` to the SAME prefix, then calls
|
||||
`/v1/projects/:slug/deployments/:id/complete` to flip it `live`. For sites
|
||||
too large to stream through the API.
|
||||
|
||||
`liveUrl` is `https://s3.hanzo.ai/<bucket>/<org>/<slug>/index.html` by default,
|
||||
or `https://<sites-host>/<org>/<slug>/` when the `hanzoai/static` container
|
||||
(the static-app image) is configured to serve the bucket behind the gateway.
|
||||
GitHub export is an optional, separate step — going live never requires it.
|
||||
|
||||
## Console2 module notes
|
||||
|
||||
- List view → `GET /v1/projects`. Status badge from `status`; "Open" links to
|
||||
`https://hanzo.app/build/<slug>`; "Visit" links to `liveUrl`.
|
||||
- Detail view → `GET /v1/projects/:slug` + `GET /v1/projects/:slug/deployments`
|
||||
for the deploy history/timeline.
|
||||
- Create/rename/link-repo → `POST`/`PATCH`. Deploy/redeploy buttons can call the
|
||||
deploy endpoint directly (git mode) for repo-linked projects.
|
||||
- Do not cache across orgs; the org is implicit in the token. Switching org in
|
||||
the console re-fetches with the new token (new `X-Org-Id`).
|
||||
@@ -0,0 +1,317 @@
|
||||
package projectsvc
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// Deploy artifact guards. A builder one-click deploy ships a small tar(.gz) of
|
||||
// the built site through the API (bounded by the app/gateway BodyLimit); large
|
||||
// sites use the git/CI path that syncs to S3 directly and never streams bytes
|
||||
// through this handler.
|
||||
const (
|
||||
maxFiles = 5000 // total entries in one deploy artifact
|
||||
maxFileBytes = 64 << 20 // per-file cap (64 MiB)
|
||||
maxTotalBytes = 512 << 20 // total uncompressed cap (512 MiB)
|
||||
)
|
||||
|
||||
// blobStore writes a project's built static files into S3 under a deterministic
|
||||
// prefix and serves them publicly. It reuses the SAME shared admin credentials
|
||||
// the provisioning control plane uses (CLOUD_S3_ADMIN_*), so there is one S3
|
||||
// access path for the whole cloud binary.
|
||||
type blobStore struct {
|
||||
endpoint string
|
||||
ak string
|
||||
sk string
|
||||
secure bool
|
||||
region string
|
||||
bucket string
|
||||
publicURL string // public base for built sites, e.g. https://s3.hanzo.ai
|
||||
sitesURL string // optional pretty base served by the static container, e.g. https://sites.hanzo.app
|
||||
}
|
||||
|
||||
func openBlobStore() *blobStore {
|
||||
return &blobStore{
|
||||
endpoint: env("CLOUD_S3_ADMIN_ENDPOINT", "s3.hanzo.svc:9000"),
|
||||
ak: os.Getenv("CLOUD_S3_ADMIN_ACCESS_KEY"),
|
||||
sk: os.Getenv("CLOUD_S3_ADMIN_SECRET_KEY"),
|
||||
secure: boolEnv("CLOUD_S3_SECURE", false),
|
||||
region: env("CLOUD_S3_REGION", "us-east-1"),
|
||||
bucket: env("CLOUD_PROJECTS_BUCKET", "hanzo-sites"),
|
||||
publicURL: strings.TrimRight(env("CLOUD_PROJECTS_PUBLIC_URL", "https://s3.hanzo.ai"), "/"),
|
||||
sitesURL: strings.TrimRight(os.Getenv("CLOUD_PROJECTS_SITES_URL"), "/"),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *blobStore) configured() bool { return b.ak != "" && b.sk != "" }
|
||||
|
||||
func (b *blobStore) client() (*minio.Client, error) {
|
||||
return minio.New(b.endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(b.ak, b.sk, ""),
|
||||
Secure: b.secure,
|
||||
Region: b.region,
|
||||
})
|
||||
}
|
||||
|
||||
// prefix is the deterministic S3 key prefix for a project's current live site:
|
||||
// "<org>/<slug>". org and slug are both validated slugs, so the join is
|
||||
// unambiguous and globally unique (slug is unique per org).
|
||||
func sitePrefix(org, slug string) string { return org + "/" + slug }
|
||||
|
||||
// liveURL is the canonical public URL for a deployed project. Prefer the pretty
|
||||
// static-container base when configured; otherwise the direct S3 object URL,
|
||||
// which is reachable as soon as the bucket has a public-read policy.
|
||||
func (b *blobStore) liveURL(org, slug string) string {
|
||||
pfx := sitePrefix(org, slug)
|
||||
if b.sitesURL != "" {
|
||||
return b.sitesURL + "/" + pfx + "/"
|
||||
}
|
||||
return b.publicURL + "/" + b.bucket + "/" + pfx + "/index.html"
|
||||
}
|
||||
|
||||
// site is the in-memory representation of a deploy artifact: relative path →
|
||||
// bytes. walkTarGz produces it; uploadSite consumes it. Splitting the tar walk
|
||||
// from the S3 put makes the parsing/guards unit-testable without S3.
|
||||
type site struct {
|
||||
files map[string][]byte
|
||||
bytes int64
|
||||
}
|
||||
|
||||
// walkTarGz parses a tar, optionally gzip-compressed, into a normalized
|
||||
// path→bytes map. It enforces the artifact guards and rejects path traversal
|
||||
// ("..", absolute, or escaping entries). Directory and non-regular entries are
|
||||
// skipped. A leading single top-level directory (e.g. "dist/") is NOT stripped
|
||||
// here — callers that build the artifact decide the layout; the builder ships
|
||||
// files at the root and CI ships dist/* at the root via `tar -C dist`.
|
||||
func walkTarGz(r io.Reader) (*site, error) {
|
||||
br := bufioPeek(r)
|
||||
var tr *tar.Reader
|
||||
if isGzip(br.head) {
|
||||
zr, err := gzip.NewReader(br)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer func() { _ = zr.Close() }()
|
||||
tr = tar.NewReader(zr)
|
||||
} else {
|
||||
tr = tar.NewReader(br)
|
||||
}
|
||||
|
||||
out := &site{files: make(map[string][]byte)}
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
continue
|
||||
}
|
||||
clean, ok := safeRel(hdr.Name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsafe path in artifact: %q", hdr.Name)
|
||||
}
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
if hdr.Size > maxFileBytes {
|
||||
return nil, fmt.Errorf("file %q exceeds %d bytes", clean, maxFileBytes)
|
||||
}
|
||||
if len(out.files) >= maxFiles {
|
||||
return nil, fmt.Errorf("artifact exceeds %d files", maxFiles)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(tr, maxFileBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %q: %w", clean, err)
|
||||
}
|
||||
if int64(len(data)) > maxFileBytes {
|
||||
return nil, fmt.Errorf("file %q exceeds %d bytes", clean, maxFileBytes)
|
||||
}
|
||||
out.bytes += int64(len(data))
|
||||
if out.bytes > maxTotalBytes {
|
||||
return nil, fmt.Errorf("artifact exceeds %d bytes total", maxTotalBytes)
|
||||
}
|
||||
out.files[clean] = data
|
||||
}
|
||||
if len(out.files) == 0 {
|
||||
return nil, errors.New("artifact contains no files")
|
||||
}
|
||||
if _, ok := out.files["index.html"]; !ok {
|
||||
return nil, errors.New("artifact missing index.html at root")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// uploadSite replaces the project's live prefix with the artifact's files:
|
||||
// it purges the existing prefix, then puts every file with a content-type
|
||||
// derived from its extension. Returns the file count and total bytes written.
|
||||
func (b *blobStore) uploadSite(ctx context.Context, org, slug string, st *site) (prefix string, files int, total int64, err error) {
|
||||
cli, err := b.client()
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("s3 connect: %w", err)
|
||||
}
|
||||
if err := b.ensureBucket(ctx, cli); err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
prefix = sitePrefix(org, slug)
|
||||
if err := purgePrefix(ctx, cli, b.bucket, prefix); err != nil {
|
||||
return "", 0, 0, fmt.Errorf("purge prefix: %w", err)
|
||||
}
|
||||
for rel, data := range st.files {
|
||||
key := prefix + "/" + rel
|
||||
ct := mime.TypeByExtension(path.Ext(rel))
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
_, err := cli.PutObject(ctx, b.bucket, key, bytes.NewReader(data), int64(len(data)),
|
||||
minio.PutObjectOptions{ContentType: ct, CacheControl: cacheControlFor(rel)})
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("put %q: %w", key, err)
|
||||
}
|
||||
files++
|
||||
total += int64(len(data))
|
||||
}
|
||||
return prefix, files, total, nil
|
||||
}
|
||||
|
||||
// ensureBucket creates the projects bucket if absent and installs an anonymous
|
||||
// read-only policy so deployed sites are reachable directly over S3.
|
||||
func (b *blobStore) ensureBucket(ctx context.Context, cli *minio.Client) error {
|
||||
exists, err := cli.BucketExists(ctx, b.bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bucket exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
if err := cli.MakeBucket(ctx, b.bucket, minio.MakeBucketOptions{Region: b.region}); err != nil {
|
||||
if ex, _ := cli.BucketExists(ctx, b.bucket); !ex {
|
||||
return fmt.Errorf("make bucket: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
policy := publicReadPolicy(b.bucket)
|
||||
if err := cli.SetBucketPolicy(ctx, b.bucket, policy); err != nil {
|
||||
return fmt.Errorf("set bucket policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgePrefix removes every object under prefix so a redeploy never leaves stale
|
||||
// files behind (a deploy is the full site, not a diff).
|
||||
func purgePrefix(ctx context.Context, cli *minio.Client, bucket, prefix string) error {
|
||||
objCh := cli.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix + "/", Recursive: true})
|
||||
toDelete := make(chan minio.ObjectInfo)
|
||||
go func() {
|
||||
defer close(toDelete)
|
||||
for obj := range objCh {
|
||||
if obj.Err != nil {
|
||||
continue
|
||||
}
|
||||
toDelete <- obj
|
||||
}
|
||||
}()
|
||||
for rmErr := range cli.RemoveObjects(ctx, bucket, toDelete, minio.RemoveObjectsOptions{}) {
|
||||
if rmErr.Err != nil {
|
||||
return rmErr.Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publicReadPolicy is the canonical anonymous read-only bucket policy. Listing
|
||||
// is denied; only GetObject is public, so deployed assets are fetchable but the
|
||||
// bucket is not enumerable.
|
||||
func publicReadPolicy(bucket string) string {
|
||||
return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::` + bucket + `/*"]}]}`
|
||||
}
|
||||
|
||||
// cacheControlFor returns a sensible Cache-Control per asset class: HTML is
|
||||
// always revalidated (so a redeploy is seen immediately); hashed assets cache
|
||||
// long. Heuristic by extension — content-hashed bundles are the common case.
|
||||
func cacheControlFor(rel string) string {
|
||||
switch path.Ext(rel) {
|
||||
case ".html", ".json", ".xml", ".txt":
|
||||
return "no-cache"
|
||||
case ".js", ".css", ".woff", ".woff2", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".ico":
|
||||
return "public, max-age=31536000, immutable"
|
||||
default:
|
||||
return "public, max-age=3600"
|
||||
}
|
||||
}
|
||||
|
||||
// safeRel normalizes a tar entry name to a clean relative path or rejects it.
|
||||
// Rejects absolute paths and any path that escapes the root via "..".
|
||||
func safeRel(name string) (string, bool) {
|
||||
n := strings.TrimPrefix(strings.ReplaceAll(name, `\`, "/"), "./")
|
||||
if n == "" || n == "." {
|
||||
return "", true
|
||||
}
|
||||
if strings.HasPrefix(n, "/") {
|
||||
return "", false
|
||||
}
|
||||
clean := path.Clean(n)
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// ---- tiny gzip sniff (avoid forcing callers to know the encoding) ----
|
||||
|
||||
type peekReader struct {
|
||||
head []byte
|
||||
r io.Reader
|
||||
used bool
|
||||
}
|
||||
|
||||
func bufioPeek(r io.Reader) *peekReader {
|
||||
head := make([]byte, 2)
|
||||
n, _ := io.ReadFull(r, head)
|
||||
return &peekReader{head: head[:n], r: r}
|
||||
}
|
||||
|
||||
func (p *peekReader) Read(b []byte) (int, error) {
|
||||
if !p.used && len(p.head) > 0 {
|
||||
n := copy(b, p.head)
|
||||
p.head = p.head[n:]
|
||||
if len(p.head) == 0 {
|
||||
p.used = true
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return p.r.Read(b)
|
||||
}
|
||||
|
||||
func isGzip(head []byte) bool { return len(head) >= 2 && head[0] == 0x1f && head[1] == 0x8b }
|
||||
|
||||
// ---- env helpers (local to projectsvc; mirror provisioningsvc conventions) ----
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func boolEnv(key string, def bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package projectsvc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// deploy ships a project live. Two modes, one endpoint:
|
||||
//
|
||||
// - Artifact (default): the request body is a tar or tar.gz of the BUILT site
|
||||
// (must contain index.html at the root). The handler unpacks it to OUR S3
|
||||
// under "<org>/<slug>/", marks the bucket public-read, and records a "live"
|
||||
// deployment. This is the builder's one-click deploy (small artifacts,
|
||||
// bounded by the app/gateway BodyLimit) — no CI round-trip needed.
|
||||
//
|
||||
// - Git (Content-Type: application/json, {"source":"git", ...}): records a
|
||||
// "queued" deployment and returns 202. CI (the reusable build workflow)
|
||||
// checks out the linked repo, builds it, syncs dist/ to the SAME S3 prefix,
|
||||
// then calls .../deployments/:id/complete to flip it live. This is the
|
||||
// "link repo → build (CI, never local) → deploy" path for large sites.
|
||||
func (s *svc) deploy(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
p, err := s.store.GetProject(c.Context(), org, slugParam(c))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(c.Header("Content-Type")), "application/json") {
|
||||
return s.deployGit(c, org, p)
|
||||
}
|
||||
return s.deployArtifact(c, org, p)
|
||||
}
|
||||
|
||||
type gitDeployReq struct {
|
||||
Source string `json:"source"`
|
||||
Commit string `json:"commit"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
func (s *svc) deployGit(c *zip.Ctx, org string, p Project) error {
|
||||
var body gitDeployReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.RepoURL == "" {
|
||||
return zip.ErrBadRequest("project has no linked repo; link a repo or deploy an artifact")
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
version, err := s.store.NextVersion(c.Context(), p.ID)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "version: %v", err)
|
||||
}
|
||||
id, err := genID("dep")
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
d := Deployment{
|
||||
ID: id, ProjectID: p.ID, Org: org, Version: version, Status: "queued",
|
||||
Source: "git", Commit: strings.TrimSpace(body.Commit), Bucket: s.blob.bucket,
|
||||
Prefix: sitePrefix(org, p.Slug), CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.store.InsertDeployment(c.Context(), d); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist deployment: %v", err)
|
||||
}
|
||||
p.Status = "building"
|
||||
p.UpdatedAt = now
|
||||
if err := s.store.UpdateProject(c.Context(), p); err != nil {
|
||||
s.log.Warn("set building failed (continuing)", "slug", p.Slug, "err", err)
|
||||
}
|
||||
return c.JSON(http.StatusAccepted, toDeploymentView(d))
|
||||
}
|
||||
|
||||
func (s *svc) deployArtifact(c *zip.Ctx, org string, p Project) error {
|
||||
if !s.blob.configured() {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "object storage not configured (set CLOUD_S3_ADMIN_*)")
|
||||
}
|
||||
raw := c.Body()
|
||||
if len(raw) == 0 {
|
||||
return zip.ErrBadRequest("empty artifact; send a tar or tar.gz of the built site")
|
||||
}
|
||||
st, err := walkTarGz(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("invalid artifact: " + err.Error())
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
version, err := s.store.NextVersion(c.Context(), p.ID)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "version: %v", err)
|
||||
}
|
||||
id, err := genID("dep")
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
d := Deployment{
|
||||
ID: id, ProjectID: p.ID, Org: org, Version: version, Status: "uploading",
|
||||
Source: "upload", Bucket: s.blob.bucket, Prefix: sitePrefix(org, p.Slug),
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.store.InsertDeployment(c.Context(), d); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist deployment: %v", err)
|
||||
}
|
||||
|
||||
prefix, files, total, upErr := s.blob.uploadSite(c.Context(), org, p.Slug, st)
|
||||
if upErr != nil {
|
||||
d.Status = "error"
|
||||
d.Message = upErr.Error()
|
||||
d.UpdatedAt = time.Now().Unix()
|
||||
_ = s.store.UpdateDeployment(c.Context(), d)
|
||||
s.log.Error("deploy upload failed", "org", org, "slug", p.Slug, "err", upErr)
|
||||
return zip.Errorf(http.StatusBadGateway, "upload failed: %v", upErr)
|
||||
}
|
||||
|
||||
live := s.blob.liveURL(org, p.Slug)
|
||||
d.Status, d.LiveURL, d.Prefix, d.Files, d.Bytes, d.UpdatedAt = "live", live, prefix, files, total, time.Now().Unix()
|
||||
if err := s.store.UpdateDeployment(c.Context(), d); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "finalize deployment: %v", err)
|
||||
}
|
||||
|
||||
p.Status, p.LiveURL, p.CurrentDeploy, p.Bucket, p.UpdatedAt = "live", live, d.ID, s.blob.bucket, time.Now().Unix()
|
||||
if err := s.store.UpdateProject(c.Context(), p); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "finalize project: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, toDeploymentView(d))
|
||||
}
|
||||
|
||||
type completeReq struct {
|
||||
Status string `json:"status"` // live | error
|
||||
Commit string `json:"commit"`
|
||||
LiveURL string `json:"liveUrl"`
|
||||
Message string `json:"message"`
|
||||
Files int `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// completeDeployment is the CI completion hook for the git path: after CI syncs
|
||||
// the built site to S3 it flips the queued deployment to live (or error). It is
|
||||
// org-scoped like every other route; CI authenticates with an org-scoped token
|
||||
// through the gateway, so the X-Org-Id binds the call to the right tenant.
|
||||
func (s *svc) completeDeployment(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
p, err := s.store.GetProject(c.Context(), org, slugParam(c))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
d, err := s.store.GetDeployment(c.Context(), org, p.ID, strings.TrimSpace(c.Param("id")))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("deployment not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get deployment: %v", err)
|
||||
}
|
||||
var body completeReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(body.Status))
|
||||
if status != "live" && status != "error" {
|
||||
return zip.ErrBadRequest("status must be live or error")
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
d.Status = status
|
||||
d.UpdatedAt = now
|
||||
if body.Commit != "" {
|
||||
d.Commit = strings.TrimSpace(body.Commit)
|
||||
}
|
||||
d.Message = strings.TrimSpace(body.Message)
|
||||
d.Files, d.Bytes = body.Files, body.Bytes
|
||||
if status == "live" {
|
||||
d.LiveURL = strings.TrimSpace(body.LiveURL)
|
||||
if d.LiveURL == "" {
|
||||
d.LiveURL = s.blob.liveURL(org, p.Slug)
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateDeployment(c.Context(), d); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "update deployment: %v", err)
|
||||
}
|
||||
|
||||
p.UpdatedAt = now
|
||||
if status == "live" {
|
||||
p.Status, p.LiveURL, p.CurrentDeploy = "live", d.LiveURL, d.ID
|
||||
} else {
|
||||
p.Status = "error"
|
||||
}
|
||||
if err := s.store.UpdateProject(c.Context(), p); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "update project: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, toDeploymentView(d))
|
||||
}
|
||||
|
||||
func (s *svc) listDeployments(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
p, err := s.store.GetProject(c.Context(), org, slugParam(c))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
rows, err := s.store.ListDeployments(c.Context(), org, p.ID)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list deployments: %v", err)
|
||||
}
|
||||
out := make([]deploymentView, 0, len(rows))
|
||||
for _, d := range rows {
|
||||
out = append(out, toDeploymentView(d))
|
||||
}
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *svc) getDeployment(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
p, err := s.store.GetProject(c.Context(), org, slugParam(c))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
d, err := s.store.GetDeployment(c.Context(), org, p.ID, strings.TrimSpace(c.Param("id")))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("deployment not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get deployment: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, toDeploymentView(d))
|
||||
}
|
||||
|
||||
// genID returns "<prefix>_<22-char-url-safe-token>" (96 bits of entropy).
|
||||
func genID(prefix string) (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + "_" + base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
// Package projectsvc is the Hanzo Cloud projects control plane: the ONE
|
||||
// org-scoped store of buildable/deployable sites, shared by every surface that
|
||||
// shows a user's projects.
|
||||
//
|
||||
// Why it exists: hanzo.app (the builder) and console.hanzo.ai (the Projects
|
||||
// module) must show the SAME projects for the same org. They do, because both
|
||||
// call this one /v1/projects surface through the gateway, which mints the
|
||||
// tenant (X-Org-Id) from the validated IAM JWT (HIP-0111). There is no second
|
||||
// copy of project state anywhere — this SQLite-backed store is the source of
|
||||
// truth; the builder keeps only per-project working state (chat, draft files)
|
||||
// in Hanzo Base.
|
||||
//
|
||||
// Surface (all org-scoped; see CONTRACT.md — the published shape console2
|
||||
// consumes):
|
||||
//
|
||||
// POST /v1/projects create
|
||||
// GET /v1/projects list (org)
|
||||
// GET /v1/projects/:slug get
|
||||
// PATCH /v1/projects/:slug update
|
||||
// DELETE /v1/projects/:slug delete (+ purge S3 site)
|
||||
// POST /v1/projects/:slug/deploy deploy (tar body | git json)
|
||||
// GET /v1/projects/:slug/deployments deploy history
|
||||
// GET /v1/projects/:slug/deployments/:id one deployment
|
||||
// POST /v1/projects/:slug/deployments/:id/complete CI completion hook
|
||||
//
|
||||
// Deploy pipeline: a deploy uploads the built static site to OUR S3
|
||||
// (CLOUD_PROJECTS_BUCKET on s3.hanzo.ai) under "<org>/<slug>/", marks the
|
||||
// bucket public-read, and records a live URL. The hanzoai/static container
|
||||
// (the static-app image) serves the same bucket behind the gateway for a pretty
|
||||
// host; GitHub export is an optional second step that never blocks going live.
|
||||
package projectsvc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// slugRE constrains a project slug to a DNS/identifier-safe token. The slug is
|
||||
// the org-unique handle AND the S3 key segment AND part of the public URL, so
|
||||
// this is the injection/traversal guard at the boundary.
|
||||
var slugRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`)
|
||||
|
||||
// frameworks is the closed set of build hints the builder/CI understand. It
|
||||
// never gates deploy (any artifact is just static files); it tells the pipeline
|
||||
// how to BUILD a linked repo. "static" means "already built / no build step".
|
||||
var frameworks = map[string]bool{
|
||||
"static": true, "vite": true, "next": true, "react": true,
|
||||
"astro": true, "svelte": true, "vue": true, "remix": true, "nuxt": true,
|
||||
}
|
||||
|
||||
type svc struct {
|
||||
store *Store
|
||||
blob *blobStore
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
// mounted is the active service so Shutdown can release the store. The unified
|
||||
// binary mounts one projects surface.
|
||||
var mounted *svc
|
||||
|
||||
// ---- HTTP response shapes (the published contract) ----
|
||||
|
||||
type repoView struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
type projectView struct {
|
||||
ID string `json:"id"`
|
||||
Org string `json:"org"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Repo repoView `json:"repo"`
|
||||
Framework string `json:"framework"`
|
||||
Status string `json:"status"`
|
||||
LiveURL string `json:"liveUrl,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
CurrentDeploymentID string `json:"currentDeploymentId,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func toProjectView(p Project) projectView {
|
||||
return projectView{
|
||||
ID: p.ID, Org: p.Org, Slug: p.Slug, Name: p.Name, Description: p.Description,
|
||||
Repo: repoView{URL: p.RepoURL, Branch: p.RepoBranch, Provider: p.RepoProvider},
|
||||
Framework: p.Framework, Status: p.Status, LiveURL: p.LiveURL, Bucket: p.Bucket,
|
||||
CurrentDeploymentID: p.CurrentDeploy, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type deploymentView struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Version int `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
LiveURL string `json:"liveUrl,omitempty"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Files int `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func toDeploymentView(d Deployment) deploymentView {
|
||||
return deploymentView{
|
||||
ID: d.ID, ProjectID: d.ProjectID, Version: d.Version, Status: d.Status, Source: d.Source,
|
||||
Commit: d.Commit, LiveURL: d.LiveURL, Bucket: d.Bucket, Prefix: d.Prefix,
|
||||
Files: d.Files, Bytes: d.Bytes, Message: d.Message, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Mount wires the projects surface onto app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("projectsvc.Mount: nil zip.App")
|
||||
}
|
||||
log := deps.Logger
|
||||
if log == nil {
|
||||
return fmt.Errorf("projectsvc.Mount: nil deps.Logger")
|
||||
}
|
||||
log = log.New("subsystem", "projects")
|
||||
if deps.DataDir == "" {
|
||||
return fmt.Errorf("projectsvc.Mount: empty DataDir")
|
||||
}
|
||||
if err := os.MkdirAll(deps.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("projectsvc.Mount: data dir: %w", err)
|
||||
}
|
||||
store, err := openStore(filepath.Join(deps.DataDir, "projects.db"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectsvc.Mount: open store: %w", err)
|
||||
}
|
||||
|
||||
s := &svc{store: store, blob: openBlobStore(), log: log}
|
||||
mounted = s
|
||||
|
||||
app.Post("/v1/projects", s.create)
|
||||
app.Get("/v1/projects", s.list)
|
||||
app.Get("/v1/projects/:slug", s.get)
|
||||
app.Patch("/v1/projects/:slug", s.update)
|
||||
app.Delete("/v1/projects/:slug", s.del)
|
||||
|
||||
app.Post("/v1/projects/:slug/deploy", s.deploy)
|
||||
app.Get("/v1/projects/:slug/deployments", s.listDeployments)
|
||||
app.Get("/v1/projects/:slug/deployments/:id", s.getDeployment)
|
||||
app.Post("/v1/projects/:slug/deployments/:id/complete", s.completeDeployment)
|
||||
|
||||
log.Info("projects mounted", "bucket", s.blob.bucket, "s3", s.blob.configured(), "brand", deps.Brand)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
cloud.Register("projects", 125, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("projectsvc.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
type createReq struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Framework string `json:"framework"`
|
||||
Repo struct {
|
||||
URL string `json:"url"`
|
||||
Branch string `json:"branch"`
|
||||
} `json:"repo"`
|
||||
}
|
||||
|
||||
func (s *svc) create(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
var body createReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
return zip.ErrBadRequest("name is required")
|
||||
}
|
||||
slug := strings.ToLower(strings.TrimSpace(body.Slug))
|
||||
if slug == "" {
|
||||
slug = slugify(name)
|
||||
}
|
||||
if !slugRE.MatchString(slug) {
|
||||
return zip.ErrBadRequest("slug must match ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$")
|
||||
}
|
||||
framework := strings.ToLower(strings.TrimSpace(body.Framework))
|
||||
if framework == "" {
|
||||
framework = "static"
|
||||
}
|
||||
if !frameworks[framework] {
|
||||
return zip.ErrBadRequest("unsupported framework")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
id, err := genID("proj")
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
p := Project{
|
||||
ID: id, Org: org, Slug: slug, Name: name, Description: strings.TrimSpace(body.Description),
|
||||
RepoURL: strings.TrimSpace(body.Repo.URL), RepoBranch: strings.TrimSpace(body.Repo.Branch),
|
||||
RepoProvider: providerFromURL(body.Repo.URL), Framework: framework,
|
||||
Status: "draft", Bucket: s.blob.bucket, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if p.RepoBranch == "" && p.RepoURL != "" {
|
||||
p.RepoBranch = "main"
|
||||
}
|
||||
if err := s.store.CreateProject(c.Context(), p); err != nil {
|
||||
if errors.Is(err, errConflict) {
|
||||
return zip.ErrConflict("project slug already exists in this org")
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, toProjectView(p))
|
||||
}
|
||||
|
||||
func (s *svc) list(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
rows, err := s.store.ListProjects(c.Context(), org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list: %v", err)
|
||||
}
|
||||
out := make([]projectView, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
out = append(out, toProjectView(p))
|
||||
}
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *svc) get(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
p, err := s.store.GetProject(c.Context(), org, slugParam(c))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, toProjectView(p))
|
||||
}
|
||||
|
||||
type updateReq struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Framework *string `json:"framework"`
|
||||
Repo *struct {
|
||||
URL string `json:"url"`
|
||||
Branch string `json:"branch"`
|
||||
} `json:"repo"`
|
||||
}
|
||||
|
||||
func (s *svc) update(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
p, err := s.store.GetProject(c.Context(), org, slugParam(c))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
var body updateReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if body.Name != nil {
|
||||
n := strings.TrimSpace(*body.Name)
|
||||
if n == "" {
|
||||
return zip.ErrBadRequest("name cannot be empty")
|
||||
}
|
||||
p.Name = n
|
||||
}
|
||||
if body.Description != nil {
|
||||
p.Description = strings.TrimSpace(*body.Description)
|
||||
}
|
||||
if body.Framework != nil {
|
||||
f := strings.ToLower(strings.TrimSpace(*body.Framework))
|
||||
if !frameworks[f] {
|
||||
return zip.ErrBadRequest("unsupported framework")
|
||||
}
|
||||
p.Framework = f
|
||||
}
|
||||
if body.Repo != nil {
|
||||
p.RepoURL = strings.TrimSpace(body.Repo.URL)
|
||||
p.RepoBranch = strings.TrimSpace(body.Repo.Branch)
|
||||
p.RepoProvider = providerFromURL(p.RepoURL)
|
||||
if p.RepoBranch == "" && p.RepoURL != "" {
|
||||
p.RepoBranch = "main"
|
||||
}
|
||||
}
|
||||
p.UpdatedAt = time.Now().Unix()
|
||||
if err := s.store.UpdateProject(c.Context(), p); err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "update: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, toProjectView(p))
|
||||
}
|
||||
|
||||
func (s *svc) del(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
slug := slugParam(c)
|
||||
p, deleted, err := s.store.DeleteProject(c.Context(), org, slug)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
return zip.ErrNotFound("project not found")
|
||||
}
|
||||
// Best-effort purge of the live site; metadata is already gone, so a purge
|
||||
// failure must not resurrect the project — log and continue.
|
||||
if s.blob.configured() {
|
||||
if cli, cErr := s.blob.client(); cErr == nil {
|
||||
if pErr := purgePrefix(c.Context(), cli, s.blob.bucket, sitePrefix(org, p.Slug)); pErr != nil {
|
||||
s.log.Warn("purge site failed (continuing)", "org", org, "slug", p.Slug, "err", pErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func slugParam(c *zip.Ctx) string { return strings.ToLower(strings.TrimSpace(c.Param("slug"))) }
|
||||
|
||||
// tenant resolves the org for a request. Empty org is allowed only for admins
|
||||
// (bucketed under the literal "admin" org), matching the provisioning control
|
||||
// plane. The gateway strips client-supplied identity headers and sets X-Org-Id
|
||||
// / X-User-IsAdmin only on the JWT-validated path (HIP-0026), so neither is
|
||||
// spoofable from the edge.
|
||||
func tenant(c *zip.Ctx) (string, bool) {
|
||||
org := sanitizeOrg(c.Org())
|
||||
if org != "" {
|
||||
return org, true
|
||||
}
|
||||
if c.IsAdmin() {
|
||||
return "admin", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func sanitizeOrg(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if len(out) > 32 {
|
||||
out = strings.Trim(out[:32], "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// slugify derives a slug from a display name: lowercase, non-alnum→'-',
|
||||
// collapse repeats, trim, cap at 40. Used when the caller omits an explicit
|
||||
// slug. The result is validated by slugRE before use.
|
||||
func slugify(name string) string {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
var b strings.Builder
|
||||
prevDash := false
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
} else if !prevDash {
|
||||
b.WriteRune('-')
|
||||
prevDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if len(out) > 40 {
|
||||
out = strings.Trim(out[:40], "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// providerFromURL classifies a git remote into a known provider for display.
|
||||
func providerFromURL(raw string) string {
|
||||
r := strings.ToLower(raw)
|
||||
switch {
|
||||
case r == "":
|
||||
return ""
|
||||
case strings.Contains(r, "github.com"):
|
||||
return "github"
|
||||
case strings.Contains(r, "gitlab"):
|
||||
return "gitlab"
|
||||
case strings.Contains(r, "bitbucket"):
|
||||
return "bitbucket"
|
||||
default:
|
||||
return "git"
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown closes the projects store. Idempotent. Mirrors the provisioning
|
||||
// Shutdown contract so the serve layer releases subsystem resources uniformly.
|
||||
func Shutdown() error {
|
||||
if mounted == nil || mounted.store == nil {
|
||||
return nil
|
||||
}
|
||||
err := mounted.store.Close()
|
||||
mounted = nil
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package projectsvc
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := openStore(filepath.Join(t.TempDir(), "projects.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("openStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func mkProject(org, slug, name string) Project {
|
||||
return Project{
|
||||
ID: "proj_" + org + "_" + slug, Org: org, Slug: slug, Name: name,
|
||||
Framework: "static", Status: "draft", Bucket: "hanzo-sites",
|
||||
CreatedAt: 100, UpdatedAt: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
p := mkProject("hanzo", "maxpower", "MaxPower")
|
||||
if err := s.CreateProject(ctx, p); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetProject(ctx, "hanzo", "maxpower")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Name != "MaxPower" || got.Status != "draft" {
|
||||
t.Fatalf("unexpected project: %+v", got)
|
||||
}
|
||||
|
||||
// Cross-tenant isolation: another org cannot see it.
|
||||
if _, err := s.GetProject(ctx, "acme", "maxpower"); !errors.Is(err, errNotFound) {
|
||||
t.Fatalf("expected notFound for other org, got %v", err)
|
||||
}
|
||||
|
||||
// Duplicate (org,slug) is a conflict.
|
||||
if err := s.CreateProject(ctx, p); !errors.Is(err, errConflict) {
|
||||
t.Fatalf("expected conflict on dup, got %v", err)
|
||||
}
|
||||
// Same slug under a DIFFERENT org is allowed.
|
||||
if err := s.CreateProject(ctx, mkProject("acme", "maxpower", "Acme Max")); err != nil {
|
||||
t.Fatalf("create other-org same-slug: %v", err)
|
||||
}
|
||||
|
||||
// Update mutable fields.
|
||||
got.Name = "Max Power v2"
|
||||
got.Status = "live"
|
||||
got.LiveURL = "https://s3.hanzo.ai/hanzo-sites/hanzo/maxpower/index.html"
|
||||
got.UpdatedAt = 200
|
||||
if err := s.UpdateProject(ctx, got); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
reread, _ := s.GetProject(ctx, "hanzo", "maxpower")
|
||||
if reread.Name != "Max Power v2" || reread.Status != "live" || reread.LiveURL == "" {
|
||||
t.Fatalf("update not persisted: %+v", reread)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrderingAndIsolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
a := mkProject("hanzo", "alpha", "Alpha")
|
||||
a.UpdatedAt = 100
|
||||
b := mkProject("hanzo", "bravo", "Bravo")
|
||||
b.UpdatedAt = 300
|
||||
cc := mkProject("hanzo", "charlie", "Charlie")
|
||||
cc.UpdatedAt = 200
|
||||
other := mkProject("acme", "delta", "Delta")
|
||||
for _, p := range []Project{a, b, cc, other} {
|
||||
if err := s.CreateProject(ctx, p); err != nil {
|
||||
t.Fatalf("create %s: %v", p.Slug, err)
|
||||
}
|
||||
}
|
||||
|
||||
list, err := s.ListProjects(ctx, "hanzo")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("expected 3 hanzo projects, got %d", len(list))
|
||||
}
|
||||
// Most-recently-updated first: bravo(300), charlie(200), alpha(100).
|
||||
if list[0].Slug != "bravo" || list[1].Slug != "charlie" || list[2].Slug != "alpha" {
|
||||
t.Fatalf("bad order: %s,%s,%s", list[0].Slug, list[1].Slug, list[2].Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCascadesDeployments(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
p := mkProject("hanzo", "maxpower", "MaxPower")
|
||||
if err := s.CreateProject(ctx, p); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := s.InsertDeployment(ctx, Deployment{
|
||||
ID: "dep_1", ProjectID: p.ID, Org: "hanzo", Version: 1, Status: "live",
|
||||
Source: "upload", CreatedAt: 100, UpdatedAt: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert deployment: %v", err)
|
||||
}
|
||||
|
||||
deleted, ok, err := s.DeleteProject(ctx, "hanzo", "maxpower")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("delete: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if deleted.ID != p.ID {
|
||||
t.Fatalf("delete returned wrong project: %+v", deleted)
|
||||
}
|
||||
if _, err := s.GetDeployment(ctx, "hanzo", p.ID, "dep_1"); !errors.Is(err, errNotFound) {
|
||||
t.Fatalf("expected deployment gone, got %v", err)
|
||||
}
|
||||
// Deleting a missing project reports not-deleted, not an error.
|
||||
if _, ok, err := s.DeleteProject(ctx, "hanzo", "maxpower"); ok || err != nil {
|
||||
t.Fatalf("expected (false,nil) on missing delete, got (%v,%v)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentVersioning(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
p := mkProject("hanzo", "maxpower", "MaxPower")
|
||||
if err := s.CreateProject(ctx, p); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
v, err := s.NextVersion(ctx, p.ID)
|
||||
if err != nil || v != 1 {
|
||||
t.Fatalf("first version expected 1, got %d (err=%v)", v, err)
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
v, _ := s.NextVersion(ctx, p.ID)
|
||||
if v != i {
|
||||
t.Fatalf("version expected %d, got %d", i, v)
|
||||
}
|
||||
id, _ := genID("dep")
|
||||
if err := s.InsertDeployment(ctx, Deployment{
|
||||
ID: id, ProjectID: p.ID, Org: "hanzo", Version: v, Status: "live",
|
||||
Source: "upload", CreatedAt: int64(i), UpdatedAt: int64(i),
|
||||
}); err != nil {
|
||||
t.Fatalf("insert v%d: %v", v, err)
|
||||
}
|
||||
}
|
||||
deps, err := s.ListDeployments(ctx, "hanzo", p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list deployments: %v", err)
|
||||
}
|
||||
if len(deps) != 3 || deps[0].Version != 3 {
|
||||
t.Fatalf("expected 3 deployments newest-first, got %d (first v=%d)", len(deps), deps[0].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"MaxPower": "maxpower",
|
||||
"Max Power": "max-power",
|
||||
" Dave's MaxPower!! ": "dave-s-maxpower",
|
||||
"a/b\\c": "a-b-c",
|
||||
"---weird---": "weird",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := slugify(in); got != want {
|
||||
t.Errorf("slugify(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// slugify output must satisfy the slug regex (when non-empty).
|
||||
for _, in := range []string{"MaxPower", "Max Power", "Dave's Site"} {
|
||||
if got := slugify(in); !slugRE.MatchString(got) {
|
||||
t.Errorf("slugify(%q)=%q does not match slugRE", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderFromURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"https://github.com/hanzoai/x": "github",
|
||||
"git@github.com:hanzoai/x.git": "github",
|
||||
"https://gitlab.com/g/x": "gitlab",
|
||||
"https://bitbucket.org/b/x": "bitbucket",
|
||||
"https://git.example.com/x": "git",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := providerFromURL(in); got != want {
|
||||
t.Errorf("providerFromURL(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRel(t *testing.T) {
|
||||
bad := []string{"/etc/passwd", "../escape", "a/../../b", "../../x"}
|
||||
for _, p := range bad {
|
||||
if _, ok := safeRel(p); ok {
|
||||
t.Errorf("safeRel(%q) should be rejected", p)
|
||||
}
|
||||
}
|
||||
good := map[string]string{
|
||||
"index.html": "index.html",
|
||||
"./assets/app.js": "assets/app.js",
|
||||
"a/b/c.css": "a/b/c.css",
|
||||
"dir/../index.html": "index.html",
|
||||
}
|
||||
for in, want := range good {
|
||||
got, ok := safeRel(in)
|
||||
if !ok || got != want {
|
||||
t.Errorf("safeRel(%q)=(%q,%v) want (%q,true)", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildTar makes an (optionally gzipped) tar from a path→content map.
|
||||
func buildTar(t *testing.T, gz bool, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
var tw *tar.Writer
|
||||
var zw *gzip.Writer
|
||||
if gz {
|
||||
zw = gzip.NewWriter(&buf)
|
||||
tw = tar.NewWriter(zw)
|
||||
} else {
|
||||
tw = tar.NewWriter(&buf)
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(content)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatalf("tar header: %v", err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("tar write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("tar close: %v", err)
|
||||
}
|
||||
if gz {
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("gzip close: %v", err)
|
||||
}
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestWalkTarGz(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"index.html": "<!doctype html><title>MaxPower</title>",
|
||||
"assets/app.js": "console.log('hi')",
|
||||
"assets/style.css": "body{}",
|
||||
}
|
||||
|
||||
for _, gz := range []bool{false, true} {
|
||||
st, err := walkTarGz(bytes.NewReader(buildTar(t, gz, files)))
|
||||
if err != nil {
|
||||
t.Fatalf("walkTarGz(gz=%v): %v", gz, err)
|
||||
}
|
||||
if len(st.files) != 3 {
|
||||
t.Fatalf("gz=%v: expected 3 files, got %d", gz, len(st.files))
|
||||
}
|
||||
if string(st.files["index.html"]) != files["index.html"] {
|
||||
t.Fatalf("gz=%v: index.html content mismatch", gz)
|
||||
}
|
||||
if _, ok := st.files["assets/app.js"]; !ok {
|
||||
t.Fatalf("gz=%v: nested file missing", gz)
|
||||
}
|
||||
if st.bytes == 0 {
|
||||
t.Fatalf("gz=%v: bytes not counted", gz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkTarGzRejects(t *testing.T) {
|
||||
// Missing index.html at root.
|
||||
if _, err := walkTarGz(bytes.NewReader(buildTar(t, true, map[string]string{"about.html": "x"}))); err == nil {
|
||||
t.Fatal("expected error for missing index.html")
|
||||
}
|
||||
// Empty artifact.
|
||||
if _, err := walkTarGz(bytes.NewReader(buildTar(t, false, map[string]string{}))); err == nil {
|
||||
t.Fatal("expected error for empty artifact")
|
||||
}
|
||||
// Path traversal entry.
|
||||
if _, err := walkTarGz(bytes.NewReader(buildTar(t, false, map[string]string{
|
||||
"index.html": "ok",
|
||||
"../../etc/x": "evil",
|
||||
}))); err == nil {
|
||||
t.Fatal("expected error for path traversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitePrefixAndLiveURL(t *testing.T) {
|
||||
if got := sitePrefix("hanzo", "maxpower"); got != "hanzo/maxpower" {
|
||||
t.Fatalf("sitePrefix=%q", got)
|
||||
}
|
||||
b := &blobStore{bucket: "hanzo-sites", publicURL: "https://s3.hanzo.ai"}
|
||||
want := "https://s3.hanzo.ai/hanzo-sites/hanzo/maxpower/index.html"
|
||||
if got := b.liveURL("hanzo", "maxpower"); got != want {
|
||||
t.Fatalf("liveURL=%q want %q", got, want)
|
||||
}
|
||||
b.sitesURL = "https://sites.hanzo.app"
|
||||
if got := b.liveURL("hanzo", "maxpower"); got != "https://sites.hanzo.app/hanzo/maxpower/" {
|
||||
t.Fatalf("pretty liveURL=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package projectsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
// modernc.org/sqlite is the pure-Go SQLite driver already in the cloud dep
|
||||
// graph (provisioningsvc uses it). Blank import registers the "sqlite" name.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// errConflict is returned by CreateProject when (org,slug) already exists;
|
||||
// errNotFound when a lookup misses. Handlers map these to HTTP 409 / 404.
|
||||
var (
|
||||
errConflict = errors.New("projects: project already exists")
|
||||
errNotFound = errors.New("projects: project not found")
|
||||
)
|
||||
|
||||
// Project is the org-scoped, canonical record of a buildable/deployable site.
|
||||
// It is the SAME record whether read from hanzo.app (the builder) or
|
||||
// console.hanzo.ai (the Projects module): tenant isolation is the org column,
|
||||
// enforced at the query layer, and the gateway-minted X-Org-Id selects the
|
||||
// tenant. Repo fields are flat columns here; the HTTP surface nests them under
|
||||
// "repo" (see projectsvc.go). It never stores a secret.
|
||||
type Project struct {
|
||||
ID string
|
||||
Org string
|
||||
Slug string
|
||||
Name string
|
||||
Description string
|
||||
RepoURL string
|
||||
RepoBranch string
|
||||
RepoProvider string
|
||||
Framework string
|
||||
Status string
|
||||
LiveURL string
|
||||
Bucket string
|
||||
CurrentDeploy string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Deployment is one deploy attempt for a project, versioned monotonically per
|
||||
// project. A deploy moves through queued→building→uploading→live (or →error);
|
||||
// the upload path (tar body) lands directly in "live", the git/CI path starts
|
||||
// "queued" and is flipped by the CI completion call.
|
||||
type Deployment struct {
|
||||
ID string
|
||||
ProjectID string
|
||||
Org string
|
||||
Version int
|
||||
Status string
|
||||
Source string
|
||||
Commit string
|
||||
LiveURL string
|
||||
Bucket string
|
||||
Prefix string
|
||||
Files int
|
||||
Bytes int64
|
||||
Message string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Store is the projects metadata database. ONE SQLite file
|
||||
// ({DataDir}/projects.db) holds every org's records; tenancy is the org column.
|
||||
// MaxOpenConns(1) serializes writes against the file lock without busy retries.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func openStore(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
org TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
repo_url TEXT NOT NULL DEFAULT '',
|
||||
repo_branch TEXT NOT NULL DEFAULT '',
|
||||
repo_provider TEXT NOT NULL DEFAULT '',
|
||||
framework TEXT NOT NULL DEFAULT 'static',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
live_url TEXT NOT NULL DEFAULT '',
|
||||
bucket TEXT NOT NULL DEFAULT '',
|
||||
current_deploy TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_projects_org_slug ON projects(org, slug);
|
||||
CREATE INDEX IF NOT EXISTS ix_projects_org_updated ON projects(org, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deployments (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
org TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'upload',
|
||||
commit_sha TEXT NOT NULL DEFAULT '',
|
||||
live_url TEXT NOT NULL DEFAULT '',
|
||||
bucket TEXT NOT NULL DEFAULT '',
|
||||
prefix TEXT NOT NULL DEFAULT '',
|
||||
files INTEGER NOT NULL DEFAULT 0,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_deployments_project_version ON deployments(project_id, version);
|
||||
CREATE INDEX IF NOT EXISTS ix_deployments_project_created ON deployments(project_id, created_at);
|
||||
`
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
const projectCols = `id,org,slug,name,description,repo_url,repo_branch,repo_provider,framework,status,live_url,bucket,current_deploy,created_at,updated_at`
|
||||
|
||||
func scanProject(sc interface{ Scan(...any) error }) (Project, error) {
|
||||
var p Project
|
||||
err := sc.Scan(&p.ID, &p.Org, &p.Slug, &p.Name, &p.Description,
|
||||
&p.RepoURL, &p.RepoBranch, &p.RepoProvider, &p.Framework,
|
||||
&p.Status, &p.LiveURL, &p.Bucket, &p.CurrentDeploy, &p.CreatedAt, &p.UpdatedAt)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// CreateProject inserts one project. A UNIQUE(org,slug) violation surfaces as
|
||||
// errConflict.
|
||||
func (s *Store) CreateProject(ctx context.Context, p Project) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO projects (`+projectCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Org, p.Slug, p.Name, p.Description,
|
||||
p.RepoURL, p.RepoBranch, p.RepoProvider, p.Framework,
|
||||
p.Status, p.LiveURL, p.Bucket, p.CurrentDeploy, p.CreatedAt, p.UpdatedAt)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return errConflict
|
||||
}
|
||||
return fmt.Errorf("insert project: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProject returns the project for (org,slug) or errNotFound.
|
||||
func (s *Store) GetProject(ctx context.Context, org, slug string) (Project, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+projectCols+` FROM projects WHERE org=? AND slug=?`, org, slug)
|
||||
p, err := scanProject(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Project{}, errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Project{}, fmt.Errorf("get project: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListProjects returns every project for org, most-recently-updated first.
|
||||
func (s *Store) ListProjects(ctx context.Context, org string) ([]Project, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+projectCols+` FROM projects WHERE org=? ORDER BY updated_at DESC, id ASC`, org)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []Project
|
||||
for rows.Next() {
|
||||
p, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan project: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateProject overwrites the mutable fields of an existing project. The caller
|
||||
// reads-modifies-writes the whole Project; org+slug+id+created_at are immutable.
|
||||
func (s *Store) UpdateProject(ctx context.Context, p Project) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE projects SET name=?,description=?,repo_url=?,repo_branch=?,repo_provider=?,framework=?,status=?,live_url=?,bucket=?,current_deploy=?,updated_at=?
|
||||
WHERE org=? AND slug=?`,
|
||||
p.Name, p.Description, p.RepoURL, p.RepoBranch, p.RepoProvider, p.Framework,
|
||||
p.Status, p.LiveURL, p.Bucket, p.CurrentDeploy, p.UpdatedAt, p.Org, p.Slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update project: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProject removes a project and all its deployment rows. Reports whether a
|
||||
// project row was deleted.
|
||||
func (s *Store) DeleteProject(ctx context.Context, org, slug string) (Project, bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Project{}, false, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
row := tx.QueryRowContext(ctx, `SELECT `+projectCols+` FROM projects WHERE org=? AND slug=?`, org, slug)
|
||||
p, err := scanProject(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Project{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Project{}, false, fmt.Errorf("get for delete: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM deployments WHERE project_id=?`, p.ID); err != nil {
|
||||
return Project{}, false, fmt.Errorf("delete deployments: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM projects WHERE id=?`, p.ID); err != nil {
|
||||
return Project{}, false, fmt.Errorf("delete project: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Project{}, false, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return p, true, nil
|
||||
}
|
||||
|
||||
const deploymentCols = `id,project_id,org,version,status,source,commit_sha,live_url,bucket,prefix,files,bytes,message,created_at,updated_at`
|
||||
|
||||
func scanDeployment(sc interface{ Scan(...any) error }) (Deployment, error) {
|
||||
var d Deployment
|
||||
err := sc.Scan(&d.ID, &d.ProjectID, &d.Org, &d.Version, &d.Status, &d.Source,
|
||||
&d.Commit, &d.LiveURL, &d.Bucket, &d.Prefix, &d.Files, &d.Bytes, &d.Message,
|
||||
&d.CreatedAt, &d.UpdatedAt)
|
||||
return d, err
|
||||
}
|
||||
|
||||
// NextVersion returns the next monotonic deploy version for a project (1-based).
|
||||
func (s *Store) NextVersion(ctx context.Context, projectID string) (int, error) {
|
||||
var v sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT MAX(version) FROM deployments WHERE project_id=?`, projectID).Scan(&v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("max version: %w", err)
|
||||
}
|
||||
return int(v.Int64) + 1, nil
|
||||
}
|
||||
|
||||
// InsertDeployment writes one deployment row.
|
||||
func (s *Store) InsertDeployment(ctx context.Context, d Deployment) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO deployments (`+deploymentCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
d.ID, d.ProjectID, d.Org, d.Version, d.Status, d.Source, d.Commit, d.LiveURL,
|
||||
d.Bucket, d.Prefix, d.Files, d.Bytes, d.Message, d.CreatedAt, d.UpdatedAt)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return errConflict
|
||||
}
|
||||
return fmt.Errorf("insert deployment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDeployment overwrites the mutable fields of a deployment (status flow).
|
||||
func (s *Store) UpdateDeployment(ctx context.Context, d Deployment) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE deployments SET status=?,commit_sha=?,live_url=?,bucket=?,prefix=?,files=?,bytes=?,message=?,updated_at=?
|
||||
WHERE id=? AND org=?`,
|
||||
d.Status, d.Commit, d.LiveURL, d.Bucket, d.Prefix, d.Files, d.Bytes, d.Message, d.UpdatedAt, d.ID, d.Org)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update deployment: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeployment returns one deployment scoped to (org, project, id).
|
||||
func (s *Store) GetDeployment(ctx context.Context, org, projectID, id string) (Deployment, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+deploymentCols+` FROM deployments WHERE org=? AND project_id=? AND id=?`, org, projectID, id)
|
||||
d, err := scanDeployment(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Deployment{}, errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Deployment{}, fmt.Errorf("get deployment: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ListDeployments returns deployments for a project, newest version first.
|
||||
func (s *Store) ListDeployments(ctx context.Context, org, projectID string) ([]Deployment, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+deploymentCols+` FROM deployments WHERE org=? AND project_id=? ORDER BY version DESC`, org, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list deployments: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []Deployment
|
||||
for rows.Next() {
|
||||
d, err := scanDeployment(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan deployment: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package promptsvc mounts the Hanzo Cloud /v1/prompts/* surface: a thin facade
|
||||
// over the console (Langfuse) public prompts API — the same console the eval
|
||||
// facade composes. Prompt versions, labels, tags, and history live in the
|
||||
// console; this proxies list / get / create to /api/public/v2/prompts under the
|
||||
// project-scoped console key pair (HTTP Basic), so the console2 Prompts module
|
||||
// resolves REAL prompts instead of an honest-empty "not routed on this host" state.
|
||||
//
|
||||
// No prompt logic is reimplemented here — the console owns storage, versioning,
|
||||
// and labels. Auth mirrors evalsvc: the public/secret key pair (per-org via KMS,
|
||||
// else the global console-keys secret) IS the org → console-project binding, so
|
||||
// there is no separate projectId to thread.
|
||||
//
|
||||
// Order 144: binds /v1/prompts/* BEFORE the AI subsystem's /v1/* catch-all (150),
|
||||
// the same slot the eval facade uses (145). The composition root auto-registers
|
||||
// GET /v1/prompts/health for every subsystem in the registry.
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// config is resolved once at Mount from the canonical console env (the
|
||||
// console-keys / console-langfuse-keys KMS-synced secret). Endpoint defaults to
|
||||
// the real in-cluster console Service DNS; keys have no default and fail closed.
|
||||
type config struct {
|
||||
consoleURL string // console base, no trailing slash
|
||||
publicKey string // console (Langfuse) public key — global fallback
|
||||
secretKey string // console (Langfuse) secret key — global fallback
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
return config{
|
||||
consoleURL: strings.TrimRight(firstNonEmpty(
|
||||
getenv("CONSOLE_HOST"),
|
||||
getenv("consoleEndpoint"),
|
||||
"http://console.hanzo.svc.cluster.local",
|
||||
), "/"),
|
||||
publicKey: firstNonEmpty(getenv("CONSOLE_PUBLIC_KEY"), getenv("LANGFUSE_PUBLIC_KEY")),
|
||||
secretKey: firstNonEmpty(getenv("CONSOLE_SECRET_KEY"), getenv("LANGFUSE_SECRET_KEY")),
|
||||
}
|
||||
}
|
||||
|
||||
type service struct {
|
||||
cfg config
|
||||
log luxlog.Logger
|
||||
kms cloud.KMSClient // per-org key override; nil when KMS is not wired in-process
|
||||
cc *http.Client // console client
|
||||
}
|
||||
|
||||
// Mount registers the /v1/prompts/* surface on app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("prompt.Mount: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("prompt.Mount: nil deps.Logger")
|
||||
}
|
||||
s := &service{
|
||||
cfg: loadConfig(),
|
||||
log: deps.Logger.New("subsystem", "prompts"),
|
||||
kms: deps.KMS,
|
||||
cc: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
|
||||
// Collection: list (what the Prompts module loads) + create. Item: get by
|
||||
// name (detail/history). The console (Langfuse) public v2 prompts API is the
|
||||
// source of truth; bodies/queries pass through verbatim so console validation
|
||||
// surfaces honestly.
|
||||
collection := func(c *zip.Ctx) string { return "/api/public/v2/prompts" }
|
||||
app.Get("/v1/prompts", s.proxy(http.MethodGet, collection))
|
||||
app.Post("/v1/prompts", s.proxy(http.MethodPost, collection))
|
||||
app.Get("/v1/prompts/:name", s.proxy(http.MethodGet, func(c *zip.Ctx) string {
|
||||
return "/api/public/v2/prompts/" + url.PathEscape(c.Fiber().Params("name"))
|
||||
}))
|
||||
|
||||
s.log.Info("prompts surface mounted",
|
||||
"console", s.cfg.consoleURL,
|
||||
"consoleKey", s.cfg.publicKey != "",
|
||||
"brand", deps.Brand,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// proxy forwards method to the console path (computed per-request from route
|
||||
// params) under HTTP Basic, resolving the project key pair from the request's
|
||||
// tenant and returning the console's status + body unchanged.
|
||||
func (s *service) proxy(method string, pathOf func(*zip.Ctx) string) func(c *zip.Ctx) error {
|
||||
return func(c *zip.Ctx) error {
|
||||
pk, sk, err := s.resolveKeys(c.Context(), tenant(c))
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusServiceUnavailable, "%s", err.Error())
|
||||
}
|
||||
target := s.cfg.consoleURL + pathOf(c)
|
||||
if q := c.Fiber().Request().URI().QueryString(); len(q) > 0 {
|
||||
target += "?" + string(q)
|
||||
}
|
||||
var body io.Reader
|
||||
if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch {
|
||||
body = bytes.NewReader(c.Body())
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.Context(), method, target, body)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "prompts: build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Basic "+basic(pk, sk))
|
||||
resp, err := s.cc.Do(req)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusBadGateway, "prompts: console unreachable: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return c.Bytes(resp.StatusCode, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveKeys returns the console key pair for the tenant: per-org KMS keys
|
||||
// (console-pk-{org}/console-sk-{org}) when KMS is wired in-process, else the
|
||||
// global key pair from env. Missing keys fail closed with a precise error.
|
||||
func (s *service) resolveKeys(ctx context.Context, org string) (publicKey, secretKey string, err error) {
|
||||
if s.kms != nil && org != "" {
|
||||
if pkb, e := s.kms.GetSecret(ctx, "console-pk-"+org); e == nil && len(pkb) > 0 {
|
||||
if skb, e2 := s.kms.GetSecret(ctx, "console-sk-"+org); e2 == nil && len(skb) > 0 {
|
||||
return string(pkb), string(skb), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.publicKey != "" && s.cfg.secretKey != "" {
|
||||
return s.cfg.publicKey, s.cfg.secretKey, nil
|
||||
}
|
||||
return "", "", fmt.Errorf(
|
||||
"prompts: no console API key for org %q: set CONSOLE_PUBLIC_KEY/CONSOLE_SECRET_KEY (KMS-synced secret 'console-keys') or per-org KMS 'console-pk-%s'/'console-sk-%s'",
|
||||
org, org, org)
|
||||
}
|
||||
|
||||
// tenant resolves the org slug used to scope console keys, preferring the
|
||||
// canonical X-Project-Id sub-scope (what console2 stamps), then X-Org-Id.
|
||||
func tenant(c *zip.Ctx) string {
|
||||
if v := c.Header("X-Project-Id"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := c.Header("X-Org-Id"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.Org()
|
||||
}
|
||||
|
||||
func basic(pk, sk string) string { return base64.StdEncoding.EncodeToString([]byte(pk + ":" + sk)) }
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getenv(key string) string { return strings.TrimSpace(os.Getenv(key)) }
|
||||
|
||||
func init() {
|
||||
cloud.Register("prompts", 144, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("prompt.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package provisioning
|
||||
|
||||
// Integration tests proving the per-org billing gate is wired into the REAL
|
||||
// create path: an unfunded org is refused 402 before any backend is touched, a
|
||||
// funded org is provisioned and its OWN org ledger is debited, and a free kind
|
||||
// (fee 0) is un-gated. The metering client's DEFAULT org is "hanzo", so every
|
||||
// "billed acme" assertion also proves the debit targets the CALLER org, never
|
||||
// the default — the multitenancy property end-to-end through the handler.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/commerce/metering"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// billServer is a minimal commerce double: it returns a fixed balance and
|
||||
// records the X-IAM-Org-Id header + body of any usage debit.
|
||||
type billServer struct {
|
||||
available int64
|
||||
|
||||
mu sync.Mutex
|
||||
usageOrg string
|
||||
usageBody []byte
|
||||
usages int32
|
||||
}
|
||||
|
||||
func (b *billServer) start(t *testing.T) string {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"available": b.available})
|
||||
})
|
||||
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&b.usages, 1)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
b.mu.Lock()
|
||||
b.usageOrg, b.usageBody = r.Header.Get("X-IAM-Org-Id"), body
|
||||
b.mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, `{"transactionId":"tx_1","type":"usage"}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func (b *billServer) debits() int32 { return atomic.LoadInt32(&b.usages) }
|
||||
func (b *billServer) lastDebit() (string, []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.usageOrg, b.usageBody
|
||||
}
|
||||
|
||||
// newBilledSvc builds a provisioning svc with a mock provisioner and a real
|
||||
// metering client pointed at commerceURL (default org "hanzo").
|
||||
func newBilledSvc(t *testing.T, commerceURL string, kinds ...string) (*svc, *mockProv) {
|
||||
t.Helper()
|
||||
t.Setenv("CLOUD_KMS_NODES", "")
|
||||
t.Setenv("CLOUD_KMS_PASSPHRASE", "")
|
||||
log := luxlog.New("module", "provbilltest")
|
||||
mp := &mockProv{cs: "redis://u:pw@kv.hanzo.svc:6379/0", host: "kv.hanzo.svc", port: 6379, db: "prefix:"}
|
||||
reg := map[string]Provisioner{}
|
||||
for _, k := range kinds {
|
||||
reg[k] = mp
|
||||
}
|
||||
m, err := metering.New(metering.Config{BaseURL: commerceURL, Token: "svc-token", Org: "hanzo"})
|
||||
if err != nil {
|
||||
t.Fatalf("metering.New: %v", err)
|
||||
}
|
||||
s := &svc{
|
||||
store: newTestStore(t),
|
||||
sec: openSecrets("hanzo", log),
|
||||
reg: reg,
|
||||
log: log,
|
||||
bill: cloud.NewResourceMeter(cloud.Deps{Logger: log, Metering: m, Env: "mainnet"}, "provisioning"),
|
||||
}
|
||||
return s, mp
|
||||
}
|
||||
|
||||
func postCreate(t *testing.T, s *svc, kind, org, name string) *http.Response {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Post("/v1/"+kind, s.create(kind))
|
||||
req, _ := http.NewRequest("POST", "/v1/"+kind, strings.NewReader(`{"name":"`+name+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if org != "" {
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Unfunded org → 402 insufficient_balance, and NOTHING is provisioned (the gate
|
||||
// runs before the backend). No free provisioning.
|
||||
func TestCreate_RefusesUnfundedOrg(t *testing.T) {
|
||||
bs := &billServer{available: 0}
|
||||
s, mp := newBilledSvc(t, bs.start(t), "sql")
|
||||
|
||||
resp := postCreate(t, s, "sql", "acme", "orders")
|
||||
if resp.StatusCode != http.StatusPaymentRequired {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d body=%s, want 402", resp.StatusCode, body)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), `"code":"insufficient_balance"`) {
|
||||
t.Fatalf("body %s missing insufficient_balance code", body)
|
||||
}
|
||||
if mp.created != 0 {
|
||||
t.Fatalf("provisioner ran %d times for an unfunded org, want 0 (gate must precede the backend)", mp.created)
|
||||
}
|
||||
if bs.debits() != 0 {
|
||||
t.Fatalf("debits = %d for a refused request, want 0", bs.debits())
|
||||
}
|
||||
}
|
||||
|
||||
// Funded org → 201, resource provisioned, and the CALLER org (acme, not the
|
||||
// client default hanzo) is debited the provision fee.
|
||||
func TestCreate_AllowsAndDebitsCallerOrg(t *testing.T) {
|
||||
bs := &billServer{available: 100000}
|
||||
s, mp := newBilledSvc(t, bs.start(t), "sql")
|
||||
|
||||
resp := postCreate(t, s, "sql", "acme", "orders")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d body=%s, want 201", resp.StatusCode, body)
|
||||
}
|
||||
if mp.created != 1 {
|
||||
t.Fatalf("provisioner ran %d times, want 1", mp.created)
|
||||
}
|
||||
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
|
||||
t.Fatalf("debits = %d, want 1 (a successful provision must bill)", bs.debits())
|
||||
}
|
||||
org, body := bs.lastDebit()
|
||||
if org != "acme" {
|
||||
t.Fatalf("debited org %q, want caller %q (never the default 'hanzo')", org, "acme")
|
||||
}
|
||||
var u struct {
|
||||
User string `json:"user"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &u)
|
||||
if u.User != "acme" {
|
||||
t.Fatalf("debit user = %q, want caller org %q", u.User, "acme")
|
||||
}
|
||||
if u.Amount != cloud.DefaultResourceFeeCents {
|
||||
t.Fatalf("debit amount = %d, want default fee %d", u.Amount, cloud.DefaultResourceFeeCents)
|
||||
}
|
||||
}
|
||||
|
||||
// A free kind (fee 0) is un-gated: even at zero balance the resource is created
|
||||
// and nothing is debited.
|
||||
func TestCreate_FreeKindUngated(t *testing.T) {
|
||||
t.Setenv("CLOUD_PROVISION_FEE_CENTS_SQL", "0")
|
||||
bs := &billServer{available: 0}
|
||||
s, mp := newBilledSvc(t, bs.start(t), "sql")
|
||||
|
||||
resp := postCreate(t, s, "sql", "acme", "orders")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d body=%s, want 201 (free kind is un-gated)", resp.StatusCode, body)
|
||||
}
|
||||
if mp.created != 1 {
|
||||
t.Fatalf("provisioner ran %d times, want 1", mp.created)
|
||||
}
|
||||
// Give any (incorrect) async debit a chance to land, then assert none did.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if bs.debits() != 0 {
|
||||
t.Fatalf("debits = %d for a free kind, want 0", bs.debits())
|
||||
}
|
||||
}
|
||||
|
||||
// Billing unconfigured (no commerce URL) → the gate is a no-op: provisioning
|
||||
// works and nothing is billed (an unconfigured deployment is never blocked).
|
||||
func TestCreate_BillingUnconfiguredNoop(t *testing.T) {
|
||||
s, mp := newBilledSvc(t, "", "sql") // empty commerce URL => !Enabled()
|
||||
resp := postCreate(t, s, "sql", "acme", "orders")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d body=%s, want 201", resp.StatusCode, body)
|
||||
}
|
||||
if mp.created != 1 {
|
||||
t.Fatalf("provisioner ran %d times, want 1", mp.created)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForDebit(cond func() bool) bool {
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return true
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
return cond()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
kms "github.com/hanzoai/kms/sdk/go"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// secrets wraps the Hanzo KMS client (github.com/hanzoai/kms/sdk/go) for
|
||||
// storing provisioned-resource passwords. Encryption is client-side: the CEK
|
||||
// is derived from CLOUD_KMS_PASSPHRASE and never leaves this process; the MPC
|
||||
// nodes only ever see ciphertext.
|
||||
//
|
||||
// SAFE DEGRADE: if KMS is not configured (CLOUD_KMS_NODES or
|
||||
// CLOUD_KMS_PASSPHRASE empty) or cannot be unlocked, Enabled() is false. In
|
||||
// that mode we NEVER write a plaintext password anywhere persistent — the
|
||||
// create handler returns the generated password exactly once in the HTTP
|
||||
// response and stores only metadata (secret_ref left empty). This honors the
|
||||
// hard rule: never store a password in plaintext.
|
||||
type secrets struct {
|
||||
client *kms.Client
|
||||
enabled bool
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
// envs read here:
|
||||
//
|
||||
// CLOUD_KMS_NODES CSV of MPC node URLs (e.g. https://kms-0:9999,https://kms-1:9999). Empty => degraded.
|
||||
// CLOUD_KMS_PASSPHRASE passphrase that derives the client-side CEK. Empty => degraded.
|
||||
// CLOUD_KMS_ORG KMS org slug for sealed secrets (default: deployment brand, else "hanzo").
|
||||
// CLOUD_KMS_THRESHOLD t-of-n quorum (default: number of nodes; clamped to [1,n]).
|
||||
func openSecrets(brand string, log luxlog.Logger) *secrets {
|
||||
s := &secrets{log: log}
|
||||
|
||||
nodesCSV := os.Getenv("CLOUD_KMS_NODES")
|
||||
pass := os.Getenv("CLOUD_KMS_PASSPHRASE")
|
||||
if strings.TrimSpace(nodesCSV) == "" || pass == "" {
|
||||
log.Warn("provisioning KMS degraded: set CLOUD_KMS_NODES + CLOUD_KMS_PASSPHRASE to persist secrets; passwords are returned once on create and not stored")
|
||||
return s
|
||||
}
|
||||
|
||||
var nodes []string
|
||||
for _, n := range strings.Split(nodesCSV, ",") {
|
||||
if t := strings.TrimSpace(n); t != "" {
|
||||
nodes = append(nodes, t)
|
||||
}
|
||||
}
|
||||
|
||||
org := env("CLOUD_KMS_ORG", brand)
|
||||
if org == "" {
|
||||
org = "hanzo"
|
||||
}
|
||||
|
||||
threshold := len(nodes)
|
||||
if v := os.Getenv("CLOUD_KMS_THRESHOLD"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= len(nodes) {
|
||||
threshold = n
|
||||
}
|
||||
}
|
||||
|
||||
client, err := kms.NewClient(kms.Config{Nodes: nodes, OrgSlug: org, Threshold: threshold})
|
||||
if err != nil {
|
||||
log.Error("provisioning KMS init failed; degrading", "err", err)
|
||||
return s
|
||||
}
|
||||
// Unlock derives the CEK from the passphrase client-side. Without it Set/Get
|
||||
// fail closed ("client is locked"), so a failed unlock means degrade.
|
||||
if err := client.Unlock(pass); err != nil {
|
||||
log.Error("provisioning KMS unlock failed; degrading", "err", err)
|
||||
return s
|
||||
}
|
||||
|
||||
s.client = client
|
||||
s.enabled = true
|
||||
log.Info("provisioning KMS enabled", "org", org, "nodes", len(nodes), "threshold", threshold)
|
||||
return s
|
||||
}
|
||||
|
||||
// Enabled reports whether secrets can be persisted to KMS.
|
||||
func (s *secrets) Enabled() bool { return s != nil && s.enabled }
|
||||
|
||||
// Put seals value under ref. Only call when Enabled() is true.
|
||||
func (s *secrets) Put(ref string, value []byte) error {
|
||||
if !s.Enabled() {
|
||||
return errors.New("provisioning: KMS disabled")
|
||||
}
|
||||
return s.client.Set(ref, value)
|
||||
}
|
||||
|
||||
// Get returns the sealed value for ref.
|
||||
func (s *secrets) Get(ref string) ([]byte, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, errors.New("provisioning: KMS disabled")
|
||||
}
|
||||
return s.client.Get(ref)
|
||||
}
|
||||
|
||||
// Delete removes the sealed secret. Best-effort; no-op when degraded.
|
||||
func (s *secrets) Delete(ref string) error {
|
||||
if !s.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return s.client.Delete(ref)
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
|
||||
pgx "github.com/jackc/pgx/v5"
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
redis "github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// errAlreadyExists is returned by a Provisioner when the backend reports the
|
||||
// physical resource already exists. The handler maps it to HTTP 409.
|
||||
var errAlreadyExists = errors.New("provisioning: backend resource already exists")
|
||||
|
||||
// Provisioner creates and drops one kind of logical resource inside a shared,
|
||||
// already-live backend. Create receives the namespaced physical name plus a
|
||||
// per-resource user + password (the handler generates these); it returns a
|
||||
// client connection string, the public host/port of the backend service, and
|
||||
// the logical database/collection/bucket name. Backends without per-resource
|
||||
// auth (Qdrant, Meilisearch, S3) ignore user/password and return an empty
|
||||
// username via the handler's kind map.
|
||||
type Provisioner interface {
|
||||
Create(ctx context.Context, physicalName, user, password string) (connString, host string, port int, db string, err error)
|
||||
Drop(ctx context.Context, physicalName, user string) error
|
||||
}
|
||||
|
||||
// newRegistry builds one Provisioner per kind from environment configuration.
|
||||
// Construction never dials a backend — connections open lazily per request so
|
||||
// a single down backend cannot block startup.
|
||||
func newRegistry() map[string]Provisioner {
|
||||
return map[string]Provisioner{
|
||||
"sql": newPostgres(),
|
||||
"vector": newQdrant(),
|
||||
"datastore": newDatastore(),
|
||||
"kv": newRedis(),
|
||||
"search": newMeili(),
|
||||
"s3": newS3(),
|
||||
"docdb": newDocdb(),
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// ----- Postgres (databases) -------------------------------------------------
|
||||
// env: CLOUD_SQL_ADMIN_DSN (default postgres://postgres@sql.hanzo.svc:5432/postgres?sslmode=disable)
|
||||
|
||||
type postgresProvisioner struct {
|
||||
dsn string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func newPostgres() *postgresProvisioner {
|
||||
dsn := env("CLOUD_SQL_ADMIN_DSN", "postgres://postgres@sql.hanzo.svc:5432/postgres?sslmode=disable")
|
||||
host, port := hostPortFromURL(dsn, 5432)
|
||||
return &postgresProvisioner{dsn: dsn, host: host, port: port}
|
||||
}
|
||||
|
||||
func (p *postgresProvisioner) Create(ctx context.Context, physical, user, pw string) (string, string, int, string, error) {
|
||||
conn, err := pgx.Connect(ctx, p.dsn)
|
||||
if err != nil {
|
||||
return "", "", 0, "", fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close(ctx) }()
|
||||
|
||||
if _, err := conn.Exec(ctx, fmt.Sprintf(`CREATE ROLE %s LOGIN PASSWORD '%s'`, pgIdent(user), sqlLit(pw))); err != nil {
|
||||
if isPGDuplicate(err) {
|
||||
return "", "", 0, "", errAlreadyExists
|
||||
}
|
||||
return "", "", 0, "", fmt.Errorf("create role: %w", err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, pgIdent(physical), pgIdent(user))); err != nil {
|
||||
// Roll back the role we just created so a retry is clean.
|
||||
_, _ = conn.Exec(ctx, fmt.Sprintf(`DROP ROLE IF EXISTS %s`, pgIdent(user)))
|
||||
if isPGDuplicate(err) {
|
||||
return "", "", 0, "", errAlreadyExists
|
||||
}
|
||||
return "", "", 0, "", fmt.Errorf("create database: %w", err)
|
||||
}
|
||||
cs := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", user, pw, p.host, p.port, physical)
|
||||
return cs, p.host, p.port, physical, nil
|
||||
}
|
||||
|
||||
func (p *postgresProvisioner) Drop(ctx context.Context, physical, user string) error {
|
||||
conn, err := pgx.Connect(ctx, p.dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close(ctx) }()
|
||||
if _, err := conn.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %s`, pgIdent(physical))); err != nil {
|
||||
return fmt.Errorf("drop database: %w", err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, fmt.Sprintf(`DROP ROLE IF EXISTS %s`, pgIdent(user))); err != nil {
|
||||
return fmt.Errorf("drop role: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPGDuplicate(err error) bool {
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "already exists") || strings.Contains(s, "duplicate")
|
||||
}
|
||||
|
||||
// ----- datastore (ClickHouse HTTP wire protocol) -----------------------------
|
||||
// env: CLOUD_DATASTORE_ADMIN_ADDR (default datastore.hanzo.svc:8123),
|
||||
// CLOUD_DATASTORE_ADMIN_USER (default), CLOUD_DATASTORE_ADMIN_PASSWORD
|
||||
|
||||
type datastoreProvisioner struct {
|
||||
addr string
|
||||
user string
|
||||
pass string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func newDatastore() *datastoreProvisioner {
|
||||
addr := env("CLOUD_DATASTORE_ADMIN_ADDR", "datastore.hanzo.svc:8123")
|
||||
host, port := splitAddr(addr, 8123)
|
||||
return &datastoreProvisioner{
|
||||
addr: addr,
|
||||
user: env("CLOUD_DATASTORE_ADMIN_USER", "default"),
|
||||
pass: os.Getenv("CLOUD_DATASTORE_ADMIN_PASSWORD"),
|
||||
host: host,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *datastoreProvisioner) open() (interface {
|
||||
Exec(ctx context.Context, query string, args ...any) error
|
||||
Close() error
|
||||
}, error) {
|
||||
return clickhouse.Open(&clickhouse.Options{
|
||||
Addr: []string{p.addr},
|
||||
Protocol: clickhouse.HTTP,
|
||||
Auth: clickhouse.Auth{Username: p.user, Password: p.pass},
|
||||
})
|
||||
}
|
||||
|
||||
func (p *datastoreProvisioner) Create(ctx context.Context, physical, user, pw string) (string, string, int, string, error) {
|
||||
conn, err := p.open()
|
||||
if err != nil {
|
||||
return "", "", 0, "", fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if err := conn.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s", chIdent(physical))); err != nil {
|
||||
if isCHDuplicate(err) {
|
||||
return "", "", 0, "", errAlreadyExists
|
||||
}
|
||||
return "", "", 0, "", fmt.Errorf("create database: %w", err)
|
||||
}
|
||||
if err := conn.Exec(ctx, fmt.Sprintf("CREATE USER %s IDENTIFIED BY '%s'", chIdent(user), sqlLit(pw))); err != nil {
|
||||
_ = conn.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", chIdent(physical)))
|
||||
return "", "", 0, "", fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
if err := conn.Exec(ctx, fmt.Sprintf("GRANT ALL ON %s.* TO %s", chIdent(physical), chIdent(user))); err != nil {
|
||||
_ = conn.Exec(ctx, fmt.Sprintf("DROP USER IF EXISTS %s", chIdent(user)))
|
||||
_ = conn.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", chIdent(physical)))
|
||||
return "", "", 0, "", fmt.Errorf("grant: %w", err)
|
||||
}
|
||||
cs := fmt.Sprintf("clickhouse://%s:%s@%s:%d/%s?protocol=http", user, pw, p.host, p.port, physical)
|
||||
return cs, p.host, p.port, physical, nil
|
||||
}
|
||||
|
||||
func (p *datastoreProvisioner) Drop(ctx context.Context, physical, user string) error {
|
||||
conn, err := p.open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if err := conn.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", chIdent(physical))); err != nil {
|
||||
return fmt.Errorf("drop database: %w", err)
|
||||
}
|
||||
if err := conn.Exec(ctx, fmt.Sprintf("DROP USER IF EXISTS %s", chIdent(user))); err != nil {
|
||||
return fmt.Errorf("drop user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCHDuplicate(err error) bool {
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "already exists") || strings.Contains(s, "code: 82")
|
||||
}
|
||||
|
||||
// ----- Redis / Valkey (kv) --------------------------------------------------
|
||||
// env: CLOUD_KV_ADMIN_ADDR (default kv.hanzo.svc:6379),
|
||||
// CLOUD_KV_ADMIN_USER (default), CLOUD_KV_ADMIN_PASSWORD
|
||||
//
|
||||
// The logical resource is an ACL user constrained to the key prefix
|
||||
// "<physical>:*" with full command access inside that keyspace.
|
||||
|
||||
type redisProvisioner struct {
|
||||
addr string
|
||||
user string
|
||||
pass string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func newRedis() *redisProvisioner {
|
||||
addr := env("CLOUD_KV_ADMIN_ADDR", "kv.hanzo.svc:6379")
|
||||
host, port := splitAddr(addr, 6379)
|
||||
return &redisProvisioner{
|
||||
addr: addr,
|
||||
user: env("CLOUD_KV_ADMIN_USER", "default"),
|
||||
pass: os.Getenv("CLOUD_KV_ADMIN_PASSWORD"),
|
||||
host: host,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *redisProvisioner) client() *redis.Client {
|
||||
return redis.NewClient(&redis.Options{Addr: p.addr, Username: p.user, Password: p.pass})
|
||||
}
|
||||
|
||||
func (p *redisProvisioner) Create(ctx context.Context, physical, user, pw string) (string, string, int, string, error) {
|
||||
rdb := p.client()
|
||||
defer func() { _ = rdb.Close() }()
|
||||
|
||||
prefix := physical + ":"
|
||||
// ACL SETUSER is idempotent (overwrites); the control-plane UNIQUE index is
|
||||
// the real duplicate guard. Restrict to the resource keyspace + all commands.
|
||||
if err := rdb.Do(ctx, "ACL", "SETUSER", user, "reset", "on", ">"+pw, "~"+prefix+"*", "+@all").Err(); err != nil {
|
||||
return "", "", 0, "", fmt.Errorf("acl setuser: %w", err)
|
||||
}
|
||||
cs := fmt.Sprintf("redis://%s:%s@%s:%d/0", user, pw, p.host, p.port)
|
||||
return cs, p.host, p.port, prefix, nil
|
||||
}
|
||||
|
||||
func (p *redisProvisioner) Drop(ctx context.Context, physical, user string) error {
|
||||
rdb := p.client()
|
||||
defer func() { _ = rdb.Close() }()
|
||||
if err := rdb.Do(ctx, "ACL", "DELUSER", user).Err(); err != nil {
|
||||
return fmt.Errorf("acl deluser: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- docdb (MongoDB wire protocol) ---------------------------------------
|
||||
// env: CLOUD_DOCDB_ADMIN_URI (default mongodb://docdb.hanzo.svc:27017/admin)
|
||||
|
||||
type docdbProvisioner struct {
|
||||
uri string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func newDocdb() *docdbProvisioner {
|
||||
uri := env("CLOUD_DOCDB_ADMIN_URI", "mongodb://docdb.hanzo.svc:27017/admin")
|
||||
host, port := hostPortFromURL(uri, 27017)
|
||||
return &docdbProvisioner{uri: uri, host: host, port: port}
|
||||
}
|
||||
|
||||
func (p *docdbProvisioner) connect(ctx context.Context) (*mongo.Client, error) {
|
||||
cli, err := mongo.Connect(options.Client().ApplyURI(p.uri))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cli.Ping(ctx, nil); err != nil {
|
||||
_ = cli.Disconnect(ctx)
|
||||
return nil, err
|
||||
}
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
func (p *docdbProvisioner) Create(ctx context.Context, physical, user, pw string) (string, string, int, string, error) {
|
||||
cli, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return "", "", 0, "", fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer func() { _ = cli.Disconnect(ctx) }()
|
||||
|
||||
db := cli.Database(physical)
|
||||
// A Mongo database materializes when its first collection appears.
|
||||
if err := db.CreateCollection(ctx, "_meta"); err != nil {
|
||||
return "", "", 0, "", fmt.Errorf("create collection: %w", err)
|
||||
}
|
||||
cmd := bson.D{
|
||||
{Key: "createUser", Value: user},
|
||||
{Key: "pwd", Value: pw},
|
||||
{Key: "roles", Value: bson.A{bson.M{"role": "readWrite", "db": physical}}},
|
||||
}
|
||||
if err := db.RunCommand(ctx, cmd).Err(); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
||||
return "", "", 0, "", errAlreadyExists
|
||||
}
|
||||
_ = db.Drop(ctx)
|
||||
return "", "", 0, "", fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
cs := fmt.Sprintf("mongodb://%s:%s@%s:%d/%s?authSource=%s", user, pw, p.host, p.port, physical, physical)
|
||||
return cs, p.host, p.port, physical, nil
|
||||
}
|
||||
|
||||
func (p *docdbProvisioner) Drop(ctx context.Context, physical, user string) error {
|
||||
cli, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer func() { _ = cli.Disconnect(ctx) }()
|
||||
db := cli.Database(physical)
|
||||
_ = db.RunCommand(ctx, bson.D{{Key: "dropUser", Value: user}}).Err()
|
||||
if err := db.Drop(ctx); err != nil {
|
||||
return fmt.Errorf("drop database: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- Qdrant (vector) ------------------------------------------------------
|
||||
// env: CLOUD_VECTOR_ADMIN_URL (default http://vector.hanzo.svc:6333),
|
||||
// CLOUD_VECTOR_ADMIN_KEY, CLOUD_VECTOR_DEFAULT_DIM (1536), CLOUD_VECTOR_DISTANCE (Cosine)
|
||||
//
|
||||
// Qdrant has no per-collection credential; auth is the cluster api-key. The
|
||||
// collection is created with a default unnamed vector config (size+distance).
|
||||
|
||||
type qdrantProvisioner struct {
|
||||
base string
|
||||
key string
|
||||
host string
|
||||
port int
|
||||
dim int
|
||||
distance string
|
||||
}
|
||||
|
||||
func newQdrant() *qdrantProvisioner {
|
||||
base := strings.TrimRight(env("CLOUD_VECTOR_ADMIN_URL", "http://vector.hanzo.svc:6333"), "/")
|
||||
host, port := hostPortFromURL(base, 6333)
|
||||
return &qdrantProvisioner{
|
||||
base: base,
|
||||
key: os.Getenv("CLOUD_VECTOR_ADMIN_KEY"),
|
||||
host: host,
|
||||
port: port,
|
||||
dim: atoiEnv("CLOUD_VECTOR_DEFAULT_DIM", 1536),
|
||||
distance: env("CLOUD_VECTOR_DISTANCE", "Cosine"),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *qdrantProvisioner) headers() map[string]string {
|
||||
return map[string]string{"api-key": p.key}
|
||||
}
|
||||
|
||||
func (p *qdrantProvisioner) Create(ctx context.Context, physical, _, _ string) (string, string, int, string, error) {
|
||||
body := map[string]any{"vectors": map[string]any{"size": p.dim, "distance": p.distance}}
|
||||
status, rb, err := httpRequest(ctx, http.MethodPut, p.base+"/collections/"+physical, p.headers(), body)
|
||||
if err != nil {
|
||||
return "", "", 0, "", fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
if status == http.StatusConflict {
|
||||
return "", "", 0, "", errAlreadyExists
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return "", "", 0, "", fmt.Errorf("qdrant status %d: %s", status, truncate(rb))
|
||||
}
|
||||
cs := p.base + "/collections/" + physical
|
||||
return cs, p.host, p.port, physical, nil
|
||||
}
|
||||
|
||||
func (p *qdrantProvisioner) Drop(ctx context.Context, physical, _ string) error {
|
||||
status, rb, err := httpRequest(ctx, http.MethodDelete, p.base+"/collections/"+physical, p.headers(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("qdrant status %d: %s", status, truncate(rb))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- Meilisearch (search) -------------------------------------------------
|
||||
// env: CLOUD_SEARCH_ADMIN_URL (default http://search.hanzo.svc:7700), CLOUD_SEARCH_ADMIN_KEY
|
||||
//
|
||||
// Meilisearch authenticates with API keys (Bearer), not per-index passwords;
|
||||
// the logical resource is the index.
|
||||
|
||||
type meiliProvisioner struct {
|
||||
base string
|
||||
key string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func newMeili() *meiliProvisioner {
|
||||
base := strings.TrimRight(env("CLOUD_SEARCH_ADMIN_URL", "http://search.hanzo.svc:7700"), "/")
|
||||
host, port := hostPortFromURL(base, 7700)
|
||||
return &meiliProvisioner{base: base, key: os.Getenv("CLOUD_SEARCH_ADMIN_KEY"), host: host, port: port}
|
||||
}
|
||||
|
||||
func (p *meiliProvisioner) headers() map[string]string {
|
||||
h := map[string]string{}
|
||||
if p.key != "" {
|
||||
h["Authorization"] = "Bearer " + p.key
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (p *meiliProvisioner) Create(ctx context.Context, physical, _, _ string) (string, string, int, string, error) {
|
||||
body := map[string]any{"uid": physical, "primaryKey": "id"}
|
||||
status, rb, err := httpRequest(ctx, http.MethodPost, p.base+"/indexes", p.headers(), body)
|
||||
if err != nil {
|
||||
return "", "", 0, "", fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return "", "", 0, "", fmt.Errorf("meilisearch status %d: %s", status, truncate(rb))
|
||||
}
|
||||
cs := p.base + "/indexes/" + physical
|
||||
return cs, p.host, p.port, physical, nil
|
||||
}
|
||||
|
||||
func (p *meiliProvisioner) Drop(ctx context.Context, physical, _ string) error {
|
||||
status, rb, err := httpRequest(ctx, http.MethodDelete, p.base+"/indexes/"+physical, p.headers(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("meilisearch status %d: %s", status, truncate(rb))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- S3 / MinIO (s3) ------------------------------------------------------
|
||||
// env: CLOUD_S3_ADMIN_ENDPOINT (default s3.hanzo.svc:9000),
|
||||
// CLOUD_S3_ADMIN_ACCESS_KEY, CLOUD_S3_ADMIN_SECRET_KEY,
|
||||
// CLOUD_S3_SECURE (false), CLOUD_S3_REGION (us-east-1)
|
||||
//
|
||||
// S3 access uses the shared admin credentials scoped by bucket policy out of
|
||||
// band; there is no per-bucket password. The logical resource is the bucket.
|
||||
|
||||
type s3Provisioner struct {
|
||||
endpoint string
|
||||
ak string
|
||||
sk string
|
||||
secure bool
|
||||
region string
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func newS3() *s3Provisioner {
|
||||
endpoint := env("CLOUD_S3_ADMIN_ENDPOINT", "s3.hanzo.svc:9000")
|
||||
host, port := splitAddr(endpoint, 9000)
|
||||
return &s3Provisioner{
|
||||
endpoint: endpoint,
|
||||
ak: os.Getenv("CLOUD_S3_ADMIN_ACCESS_KEY"),
|
||||
sk: os.Getenv("CLOUD_S3_ADMIN_SECRET_KEY"),
|
||||
secure: boolEnv("CLOUD_S3_SECURE", false),
|
||||
region: env("CLOUD_S3_REGION", "us-east-1"),
|
||||
host: host,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *s3Provisioner) client() (*minio.Client, error) {
|
||||
return minio.New(p.endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(p.ak, p.sk, ""),
|
||||
Secure: p.secure,
|
||||
Region: p.region,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *s3Provisioner) Create(ctx context.Context, physical, _, _ string) (string, string, int, string, error) {
|
||||
bucket := bucketName(physical)
|
||||
cli, err := p.client()
|
||||
if err != nil {
|
||||
return "", "", 0, "", fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
if err := cli.MakeBucket(ctx, bucket, minio.MakeBucketOptions{Region: p.region}); err != nil {
|
||||
if exists, _ := cli.BucketExists(ctx, bucket); exists {
|
||||
return "", "", 0, "", errAlreadyExists
|
||||
}
|
||||
return "", "", 0, "", fmt.Errorf("make bucket: %w", err)
|
||||
}
|
||||
scheme := "http"
|
||||
if p.secure {
|
||||
scheme = "https"
|
||||
}
|
||||
cs := fmt.Sprintf("%s://%s/%s", scheme, p.endpoint, bucket)
|
||||
return cs, p.host, p.port, bucket, nil
|
||||
}
|
||||
|
||||
func (p *s3Provisioner) Drop(ctx context.Context, physical, _ string) error {
|
||||
bucket := bucketName(physical)
|
||||
cli, err := p.client()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
if err := cli.RemoveBucket(ctx, bucket); err != nil {
|
||||
return fmt.Errorf("remove bucket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketName converts a physical identifier ("o"<orgHash>_<ident>) into a
|
||||
// DNS-safe S3 bucket name: lowercase [a-z0-9-], 3–63 chars, no leading or
|
||||
// trailing hyphen. Folding '_'→'-' is a bijection on physical names (which
|
||||
// contain no '-'), so the fixed-width org-hash prefix that makes physicalName
|
||||
// injective makes the bucket injective too — distinct tenants get distinct
|
||||
// buckets, and the single UNIQUE(physical_name) control-plane guard therefore
|
||||
// also guarantees bucket uniqueness. Deterministic, so Drop recomputes it.
|
||||
func bucketName(physical string) string {
|
||||
b := strings.Trim(strings.ToLower(strings.ReplaceAll(physical, "_", "-")), "-")
|
||||
if len(b) > 63 { // unreachable for nameRE-bounded input (physical ≤ 58); defensive.
|
||||
b = strings.Trim(b[:63], "-")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ----- shared helpers -------------------------------------------------------
|
||||
|
||||
func httpRequest(ctx context.Context, method, rawURL string, headers map[string]string, body any) (int, []byte, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, rdr)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for k, v := range headers {
|
||||
if v != "" {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return resp.StatusCode, rb, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > 200 {
|
||||
return s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// genToken returns n bytes of crypto-random data as URL-safe base64 (no
|
||||
// padding). The alphabet [A-Za-z0-9_-] contains no quote characters, so the
|
||||
// password is safe to interpolate into single-quoted SQL string literals.
|
||||
func genToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func pgIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
|
||||
func chIdent(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" }
|
||||
func sqlLit(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func atoiEnv(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func boolEnv(key string, def bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func hostPortFromURL(raw string, def int) (string, int) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return splitAddr(raw, def)
|
||||
}
|
||||
host := u.Hostname()
|
||||
port := def
|
||||
if ps := u.Port(); ps != "" {
|
||||
if n, err := strconv.Atoi(ps); err == nil {
|
||||
port = n
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
host = raw
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
|
||||
func splitAddr(addr string, def int) (string, int) {
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr, def
|
||||
}
|
||||
port := def
|
||||
if n, err := strconv.Atoi(portStr); err == nil {
|
||||
port = n
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// Package provisioningsvc is the Hanzo Cloud provisioning control plane. It
|
||||
// turns "create a database" into a real logical resource inside an
|
||||
// already-live, shared product backend, per the unified /v1 binary (HIP-0106).
|
||||
//
|
||||
// One HTTP surface, seven kinds, one Provisioner each:
|
||||
//
|
||||
// sql -> Postgres sql.hanzo.svc:5432 CREATE DATABASE + ROLE
|
||||
// vector -> Qdrant vector.hanzo.svc:6333 PUT /collections/{name}
|
||||
// datastore -> ClickHouse datastore.hanzo.svc:8123 CREATE DATABASE + USER
|
||||
// kv -> Redis kv.hanzo.svc:6379 ACL SETUSER (keyspace scope)
|
||||
// search -> Meilisearch search.hanzo.svc:7700 POST /indexes
|
||||
// s3 -> S3/MinIO s3.hanzo.svc:9000 MakeBucket
|
||||
// docdb -> MongoDB docdb.hanzo.svc:27017 createCollection + createUser
|
||||
//
|
||||
// Tenancy: every request is scoped to the gateway-minted org (X-Org-Id /
|
||||
// c.Org()). Empty org is rejected 403 unless the caller is an admin. The
|
||||
// physical resource on the shared backend is namespaced "o"<hash(org)>_<name>
|
||||
// with a FIXED-WIDTH org hash, so the org→name boundary is unambiguous and two
|
||||
// distinct tenants can never fold onto one backend resource. A global
|
||||
// UNIQUE(physical_name) guard makes any residual fold fail closed with 409.
|
||||
//
|
||||
// Secrets: generated per-resource passwords are sealed in Hanzo KMS
|
||||
// (client-side encrypted) and only a secret_ref is persisted. When KMS is not
|
||||
// configured the service degrades safely — it returns the password once in the
|
||||
// create response and stores NOTHING in plaintext. See kms.go.
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// kinds is the closed set of resource kinds this control plane provisions.
|
||||
// These strings are the Hanzo product names — never the upstream OSS name of
|
||||
// the backend (so "sql"/"s3", not "postgres"/"minio").
|
||||
var kinds = []string{"sql", "vector", "datastore", "kv", "search", "s3", "docdb"}
|
||||
|
||||
// secretfulKinds are the kinds whose backend wires a real per-resource
|
||||
// credential (so the generated password is meaningful and gets sealed in KMS /
|
||||
// returned once). The others (vector, search, storage) authenticate with a
|
||||
// shared, out-of-band key, so no per-resource password is produced.
|
||||
var secretfulKinds = map[string]bool{
|
||||
"sql": true,
|
||||
"kv": true,
|
||||
"datastore": true,
|
||||
"docdb": true,
|
||||
}
|
||||
|
||||
// nameRE constrains the user-supplied resource name to a DNS/identifier-safe
|
||||
// slug. Validated at the boundary; the physical name and every SQL identifier
|
||||
// derive from it, so this is the injection guard.
|
||||
var nameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`)
|
||||
|
||||
// provisionFeeEnvPrefix is the operator knob for the per-provision fee. The
|
||||
// effective fee is cloud.ResourceFeeCents(provisionFeeEnvPrefix, kind): a
|
||||
// per-kind override (CLOUD_PROVISION_FEE_CENTS_SQL=…) wins over the global
|
||||
// CLOUD_PROVISION_FEE_CENTS, else the $1.00 default. Set a kind to 0 to make it
|
||||
// free (and therefore un-gated).
|
||||
//
|
||||
// Ongoing storage footprint (GB-month) is billed by REUSING s.bill.Meter with a
|
||||
// usage-derived amount; its unit price lives in hanzoai/pricing
|
||||
// (infrastructure.blockStorage.pricePerGBMonthly = $0.08/GB-month) and is
|
||||
// applied by the recurring caller, not at provision time — there is no live-size
|
||||
// source here and a size is never fabricated.
|
||||
const provisionFeeEnvPrefix = "CLOUD_PROVISION_FEE_CENTS"
|
||||
|
||||
type svc struct {
|
||||
store *Store
|
||||
sec *secrets
|
||||
reg map[string]Provisioner
|
||||
log luxlog.Logger
|
||||
// bill is the shared per-org resource gate+meter (reuses deps.Metering, the
|
||||
// one commerce client). Nil/!Enabled() makes Gate allow and Meter a no-op.
|
||||
bill *cloud.ResourceMeter
|
||||
}
|
||||
|
||||
type createResp struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Database string `json:"database"`
|
||||
ConnectionString string `json:"connectionString"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
type getResp struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Database string `json:"database"`
|
||||
}
|
||||
|
||||
type listItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Mount wires the provisioning surface onto app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("provisioning.Mount: nil zip.App")
|
||||
}
|
||||
log := deps.Logger
|
||||
if log == nil {
|
||||
return fmt.Errorf("provisioning.Mount: nil deps.Logger")
|
||||
}
|
||||
log = log.New("subsystem", "provisioning")
|
||||
|
||||
if deps.DataDir == "" {
|
||||
return fmt.Errorf("provisioning.Mount: empty DataDir")
|
||||
}
|
||||
if err := os.MkdirAll(deps.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("provisioning.Mount: data dir: %w", err)
|
||||
}
|
||||
store, err := openStore(filepath.Join(deps.DataDir, "provisioning.db"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("provisioning.Mount: open store: %w", err)
|
||||
}
|
||||
|
||||
s := &svc{
|
||||
store: store,
|
||||
sec: openSecrets(deps.Brand, log),
|
||||
reg: newRegistry(),
|
||||
log: log,
|
||||
bill: cloud.NewResourceMeter(deps, "provisioning"),
|
||||
}
|
||||
mounted = s
|
||||
|
||||
for _, kind := range kinds {
|
||||
k := kind
|
||||
app.Post("/v1/"+k, s.create(k))
|
||||
app.Get("/v1/"+k, s.list(k))
|
||||
app.Get("/v1/"+k+"/:name", s.get(k))
|
||||
app.Delete("/v1/"+k+"/:name", s.drop(k))
|
||||
}
|
||||
|
||||
log.Info("provisioning mounted",
|
||||
"kinds", len(kinds),
|
||||
"kms", s.sec.Enabled(),
|
||||
"brand", deps.Brand,
|
||||
"env", deps.Env,
|
||||
"billing", s.bill.Enabled(),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
cloud.Register("provisioning", 120, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("provisioning.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
|
||||
// create provisions a new logical resource of kind for the caller's org.
|
||||
func (s *svc) create(kind string) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
prov := s.reg[kind]
|
||||
if prov == nil {
|
||||
return zip.Errorf(http.StatusNotImplemented, "kind %q not supported", kind)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(body.Name))
|
||||
if !nameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must match ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$")
|
||||
}
|
||||
|
||||
ctx := c.Context()
|
||||
|
||||
// Pre-provision balance gate (fail-closed, per-org). Refuse BEFORE any
|
||||
// backend resource is created: an unfunded org — or, in the default
|
||||
// fail-closed posture, an unreachable commerce — gets 402/503 and nothing
|
||||
// is provisioned (no free provisioning). Scoped to THIS caller's org (the
|
||||
// same slug that namespaces the resource below and that #66's identity
|
||||
// sanitizer derives from a validated JWT, not a spoofable header), so the
|
||||
// charge can never target another tenant. fee is computed once and reused
|
||||
// by the post-success debit; fee==0 (a free kind) or unconfigured billing
|
||||
// makes this a no-op.
|
||||
fee := cloud.ResourceFeeCents(provisionFeeEnvPrefix, kind)
|
||||
if err := s.bill.Gate(ctx, org, kind, fee); err != nil {
|
||||
return cloud.DenyResource(c, err)
|
||||
}
|
||||
|
||||
// Fast duplicate check (the UNIQUE index is the authoritative guard).
|
||||
if _, err := s.store.Get(ctx, org, kind, name); err == nil {
|
||||
return zip.ErrConflict("resource already exists")
|
||||
} else if !errors.Is(err, errNotFound) {
|
||||
return zip.Errorf(http.StatusInternalServerError, "lookup: %v", err)
|
||||
}
|
||||
|
||||
physical := physicalName(org, name)
|
||||
|
||||
// Global uniqueness guard (across ALL orgs/kinds). The fixed-width org
|
||||
// hash already makes a cross-tenant fold cryptographically negligible;
|
||||
// this check plus the UNIQUE(physical_name) index make any residual fold
|
||||
// (or hash collision) FAIL CLOSED with 409 BEFORE the backend is touched
|
||||
// — never a silent shared resource, which on KV would be a cross-tenant
|
||||
// credential takeover (idempotent ACL SETUSER overwriting another
|
||||
// tenant's user) and elsewhere a cross-tenant DoS / existence oracle.
|
||||
if exists, err := s.store.PhysicalExists(ctx, physical); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "lookup: %v", err)
|
||||
} else if exists {
|
||||
return zip.ErrConflict("resource already exists")
|
||||
}
|
||||
|
||||
user := physical
|
||||
pw, err := genToken(24)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
|
||||
cs, host, port, db, err := prov.Create(ctx, physical, user, pw)
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyExists) {
|
||||
return zip.ErrConflict("resource already exists")
|
||||
}
|
||||
s.log.Error("provision failed", "kind", kind, "org", org, "name", name, "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "provision %s failed: %v", kind, err)
|
||||
}
|
||||
|
||||
// Secret handling. Only secretful kinds carry a real per-resource
|
||||
// password. Seal it in KMS when configured; otherwise return once and
|
||||
// store nothing (never plaintext).
|
||||
secretRef := fmt.Sprintf("org/%s/%s/%s", org, kind, name)
|
||||
storedRef, returnPw, username := "", "", ""
|
||||
if secretfulKinds[kind] {
|
||||
returnPw, username = pw, user
|
||||
if s.sec.Enabled() {
|
||||
if err := s.sec.Put(secretRef, []byte(pw)); err != nil {
|
||||
_ = prov.Drop(ctx, physical, user)
|
||||
s.log.Error("kms put failed; rolled back backend", "kind", kind, "err", err)
|
||||
return zip.Errorf(http.StatusInternalServerError, "store secret failed")
|
||||
}
|
||||
storedRef = secretRef
|
||||
} else {
|
||||
s.log.Warn("KMS degraded: password returned once, not persisted", "kind", kind, "org", org, "name", name)
|
||||
}
|
||||
}
|
||||
|
||||
id, err := genID()
|
||||
if err != nil {
|
||||
_ = prov.Drop(ctx, physical, user)
|
||||
if storedRef != "" {
|
||||
_ = s.sec.Delete(storedRef)
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
|
||||
r := Resource{
|
||||
ID: id, Org: org, Kind: kind, Name: name,
|
||||
PhysicalName: physical, SecretRef: storedRef,
|
||||
Host: host, Port: port, Username: username, DBName: db,
|
||||
Status: "ready", CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
if err := s.store.Insert(ctx, r); err != nil {
|
||||
// Lost a concurrent race or DB error — undo the backend + secret.
|
||||
_ = prov.Drop(ctx, physical, user)
|
||||
if storedRef != "" {
|
||||
_ = s.sec.Delete(storedRef)
|
||||
}
|
||||
if errors.Is(err, errConflict) {
|
||||
return zip.ErrConflict("resource already exists")
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
|
||||
}
|
||||
|
||||
// Resource is live + persisted — debit the caller's org ledger for the
|
||||
// provision (per-org, env-attributed, async best-effort so the debit never
|
||||
// blocks or corrupts this 201; a debit failure is logged for
|
||||
// reconciliation). Recurring storage footprint reuses s.bill.Meter with a
|
||||
// GB-month amount once a live-size source exists.
|
||||
s.bill.Meter(org, kind, fee, c.RequestID(), cloud.ClientIP(c))
|
||||
|
||||
return c.JSON(http.StatusCreated, createResp{
|
||||
ID: id, Kind: kind, Name: name, Status: "ready",
|
||||
Host: host, Port: port, Username: username, Database: db,
|
||||
ConnectionString: cs, Password: returnPw,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// list returns every resource of kind for the caller's org. Never a password.
|
||||
func (s *svc) list(kind string) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
rows, err := s.store.List(c.Context(), org, kind)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list: %v", err)
|
||||
}
|
||||
out := make([]listItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, listItem{
|
||||
ID: r.ID, Name: r.Name, Kind: r.Kind, Status: r.Status,
|
||||
Host: r.Host, Port: r.Port, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// get returns one resource's metadata. Never a password.
|
||||
func (s *svc) get(kind string) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(c.Param("name")))
|
||||
r, err := s.store.Get(c.Context(), org, kind, name)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("resource not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, getResp{
|
||||
ID: r.ID, Name: r.Name, Kind: r.Kind, Status: r.Status,
|
||||
Host: r.Host, Port: r.Port, Username: r.Username, Database: r.DBName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// drop deprovisions the backend resource, deletes the sealed secret, and
|
||||
// removes the metadata row.
|
||||
func (s *svc) drop(kind string) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(c.Param("name")))
|
||||
ctx := c.Context()
|
||||
|
||||
r, err := s.store.Get(ctx, org, kind, name)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("resource not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
}
|
||||
|
||||
if prov := s.reg[kind]; prov != nil {
|
||||
if err := prov.Drop(ctx, r.PhysicalName, r.Username); err != nil {
|
||||
s.log.Error("deprovision failed", "kind", kind, "org", org, "name", name, "err", err)
|
||||
return zip.Errorf(http.StatusBadGateway, "deprovision %s failed: %v", kind, err)
|
||||
}
|
||||
}
|
||||
if r.SecretRef != "" {
|
||||
if err := s.sec.Delete(r.SecretRef); err != nil {
|
||||
s.log.Warn("kms delete failed (continuing)", "ref", r.SecretRef, "err", err)
|
||||
}
|
||||
}
|
||||
if _, err := s.store.Delete(ctx, org, kind, name); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- tenancy + naming -----------------------------------------------------
|
||||
|
||||
// tenant resolves the org for a request. Empty org is allowed only for admins,
|
||||
// who are bucketed under the literal "admin" org.
|
||||
//
|
||||
// Trusting the gateway-minted X-User-IsAdmin claim (c.IsAdmin()) is acceptable
|
||||
// here: the blast radius of a forged claim is bounded to the single literal
|
||||
// "admin" org bucket. An admin still gets a distinct physical namespace
|
||||
// ("o"<hash("admin")>_…) and cannot name into any real tenant's resources. The
|
||||
// gateway strips client-supplied identity headers and only sets this claim on
|
||||
// the JWT-validated path (HIP-0026), so it cannot be spoofed from the edge.
|
||||
func tenant(c *zip.Ctx) (string, bool) {
|
||||
org := sanitizeOrg(c.Org())
|
||||
if org != "" {
|
||||
return org, true
|
||||
}
|
||||
if c.IsAdmin() {
|
||||
return "admin", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// sanitizeOrg reduces a gateway org id to a lowercase [a-z0-9-] slug, capped at
|
||||
// 32 chars. Defense in depth: org comes from the JWT via the gateway, but it
|
||||
// still flows into physical identifiers.
|
||||
func sanitizeOrg(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if len(out) > 32 {
|
||||
out = strings.Trim(out[:32], "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// orgHash returns a fixed-width, collision-resistant tag for an org slug: the
|
||||
// first 16 hex chars (64 bits) of SHA-256(org). The FIXED WIDTH is the whole
|
||||
// point — it makes the org→name boundary in physicalName / bucketName
|
||||
// unambiguous, so two distinct orgs can never fold onto one backend resource.
|
||||
// The prior "org_<org>_<name>" join folded that boundary: physicalName(
|
||||
// "acme","my-db") == physicalName("acme-my","db") == "org_acme_my_db", a
|
||||
// cross-tenant collision (credential takeover on KV; DoS/existence oracle
|
||||
// elsewhere). 64 bits makes a cross-org collision cryptographically negligible.
|
||||
func orgHash(org string) string {
|
||||
sum := sha256.Sum256([]byte(org))
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// sanitizeIdent reduces a validated resource name to a [a-z0-9_] identifier by
|
||||
// folding '-' (the only non-alphanumeric a valid name may contain) to '_'.
|
||||
// Names are constrained by nameRE at the boundary and never contain '_', so the
|
||||
// fold round-trips and is injective on the valid set.
|
||||
func sanitizeIdent(name string) string { return strings.ReplaceAll(name, "-", "_") }
|
||||
|
||||
// physicalName namespaces a resource on a shared backend as
|
||||
// "o"<orgHash>_<sanitizedName>. The leading 'o' keeps it alpha-initial (a valid
|
||||
// identifier for every backend); the fixed-width org hash disambiguates org
|
||||
// from name; sanitizeIdent makes the name a safe SQL/Mongo/ClickHouse
|
||||
// identifier. Injective in (org,name) up to a 64-bit SHA-256 collision. With
|
||||
// name ≤ 40 chars (nameRE) the identifier is ≤ 58 chars — inside Postgres's
|
||||
// 63-char identifier limit.
|
||||
func physicalName(org, name string) string {
|
||||
return "o" + orgHash(org) + "_" + sanitizeIdent(name)
|
||||
}
|
||||
|
||||
func genID() (string, error) {
|
||||
tok, err := genToken(12)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "rs_" + tok, nil
|
||||
}
|
||||
|
||||
// mounted is the active service, set by Mount so Shutdown can release the
|
||||
// metadata store. The unified binary mounts one provisioning surface.
|
||||
var mounted *svc
|
||||
|
||||
// Shutdown closes the provisioning metadata store. Idempotent. Mirrors the
|
||||
// plansvc Shutdown contract so the serve layer can release subsystem resources
|
||||
// uniformly.
|
||||
func Shutdown(context.Context) error {
|
||||
if mounted == nil || mounted.store == nil {
|
||||
return nil
|
||||
}
|
||||
err := mounted.store.Close()
|
||||
mounted = nil
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := openStore(filepath.Join(t.TempDir(), "provisioning.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("openStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func sampleResource(name string) Resource {
|
||||
return Resource{
|
||||
ID: "rs_" + name, Org: "acme", Kind: "sql", Name: name,
|
||||
PhysicalName: physicalName("acme", name), SecretRef: "org/acme/sql/" + name,
|
||||
Host: "sql.hanzo.svc", Port: 5432, Username: physicalName("acme", name),
|
||||
DBName: physicalName("acme", name), Status: "ready", CreatedAt: 1700000000,
|
||||
}
|
||||
}
|
||||
|
||||
// mockProv is an in-memory Provisioner: it touches no backend, records the
|
||||
// password it was handed, and returns canned connection metadata.
|
||||
type mockProv struct {
|
||||
cs, host, db string
|
||||
port int
|
||||
gotPw string
|
||||
created int
|
||||
dropped int
|
||||
}
|
||||
|
||||
func (m *mockProv) Create(_ context.Context, _, _, pw string) (string, string, int, string, error) {
|
||||
m.created++
|
||||
m.gotPw = pw
|
||||
return m.cs, m.host, m.port, m.db, nil
|
||||
}
|
||||
|
||||
func (m *mockProv) Drop(_ context.Context, _, _ string) error { m.dropped++; return nil }
|
||||
|
||||
// newTestSvc builds a svc with a temp store, KMS-degraded secrets (no env),
|
||||
// and a mock provisioner under each given kind.
|
||||
func newTestSvc(t *testing.T, kinds ...string) (*svc, *mockProv) {
|
||||
t.Helper()
|
||||
// Force KMS degrade so secret persistence is hermetic and never dials.
|
||||
t.Setenv("CLOUD_KMS_NODES", "")
|
||||
t.Setenv("CLOUD_KMS_PASSPHRASE", "")
|
||||
log := luxlog.New("module", "provtest")
|
||||
mp := &mockProv{cs: "redis://u:pw@kv.hanzo.svc:6379/0", host: "kv.hanzo.svc", port: 6379, db: "prefix:"}
|
||||
reg := map[string]Provisioner{}
|
||||
for _, k := range kinds {
|
||||
reg[k] = mp
|
||||
}
|
||||
return &svc{store: newTestStore(t), sec: openSecrets("hanzo", log), reg: reg, log: log}, mp
|
||||
}
|
||||
|
||||
func TestStore_InsertGetListDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.Get(ctx, "acme", "sql", "orders"); !errors.Is(err, errNotFound) {
|
||||
t.Fatalf("Get(missing) = %v, want errNotFound", err)
|
||||
}
|
||||
|
||||
if err := s.Insert(ctx, sampleResource("orders")); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
if err := s.Insert(ctx, sampleResource("events")); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.Get(ctx, "acme", "sql", "orders")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.PhysicalName != physicalName("acme", "orders") || got.Port != 5432 || got.Status != "ready" {
|
||||
t.Fatalf("Get returned wrong row: %+v", got)
|
||||
}
|
||||
|
||||
rows, err := s.List(ctx, "acme", "sql")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("List len = %d, want 2", len(rows))
|
||||
}
|
||||
|
||||
// Org isolation: another org sees nothing.
|
||||
other, err := s.List(ctx, "globex", "sql")
|
||||
if err != nil {
|
||||
t.Fatalf("List(globex): %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("cross-org leak: globex saw %d rows", len(other))
|
||||
}
|
||||
|
||||
deleted, err := s.Delete(ctx, "acme", "sql", "orders")
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("Delete = (%v,%v), want (true,nil)", deleted, err)
|
||||
}
|
||||
if _, err := s.Get(ctx, "acme", "sql", "orders"); !errors.Is(err, errNotFound) {
|
||||
t.Fatalf("Get after delete = %v, want errNotFound", err)
|
||||
}
|
||||
deletedAgain, err := s.Delete(ctx, "acme", "sql", "orders")
|
||||
if err != nil || deletedAgain {
|
||||
t.Fatalf("Delete(missing) = (%v,%v), want (false,nil)", deletedAgain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DuplicateConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.Insert(ctx, sampleResource("orders")); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
dup := sampleResource("orders")
|
||||
dup.ID = "rs_different"
|
||||
if err := s.Insert(ctx, dup); !errors.Is(err, errConflict) {
|
||||
t.Fatalf("Insert(dup) = %v, want errConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_PhysicalNameConflict proves the global guard: two DIFFERENT
|
||||
// (org,kind,name) rows that somehow resolve to the SAME physical_name must
|
||||
// fail closed — a backend resource is never shared.
|
||||
func TestStore_PhysicalNameConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
a := sampleResource("orders")
|
||||
if err := s.Insert(ctx, a); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
// Distinct identity (different name + id), but force a physical collision.
|
||||
b := sampleResource("orders")
|
||||
b.ID, b.Name = "rs_other", "events" // satisfies UNIQUE(org,kind,name)
|
||||
b.PhysicalName = a.PhysicalName // but collides on physical_name
|
||||
if exists, err := s.PhysicalExists(ctx, b.PhysicalName); err != nil || !exists {
|
||||
t.Fatalf("PhysicalExists = (%v,%v), want (true,nil)", exists, err)
|
||||
}
|
||||
if err := s.Insert(ctx, b); !errors.Is(err, errConflict) {
|
||||
t.Fatalf("Insert(physical collision) = %v, want errConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameValidation(t *testing.T) {
|
||||
valid := []string{"orders", "my-db", "a", "db1", "x0-1-2"}
|
||||
invalid := []string{"", "-bad", "bad-", "Bad", "has_underscore", "white space", "way-too-long-" + strings.Repeat("x", 60)}
|
||||
for _, n := range valid {
|
||||
if !nameRE.MatchString(n) {
|
||||
t.Errorf("expected %q valid", n)
|
||||
}
|
||||
}
|
||||
for _, n := range invalid {
|
||||
if nameRE.MatchString(n) {
|
||||
t.Errorf("expected %q invalid", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOrg(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"acme": "acme",
|
||||
"Acme Corp": "acme-corp",
|
||||
" hanzo ": "hanzo",
|
||||
"a@b.c": "a-b-c",
|
||||
"--weird--": "weird",
|
||||
strings.Repeat("z", 50): strings.Repeat("z", 32),
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeOrg(in); got != want {
|
||||
t.Errorf("sanitizeOrg(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhysicalNameInjective is the regression test for the cross-tenant
|
||||
// collision: a literal "org_<org>_<name>" join folded the org→name boundary so
|
||||
// physicalName("acme","my-db") == physicalName("acme-my","db"). The fixed-width
|
||||
// org hash must keep the two tenants distinct — for the SQL identifier AND the
|
||||
// derived S3 bucket — and both projections must stay backend-valid.
|
||||
func TestPhysicalNameInjective(t *testing.T) {
|
||||
a := physicalName("acme", "my-db")
|
||||
b := physicalName("acme-my", "db")
|
||||
if a == b {
|
||||
t.Fatalf("physicalName collision: both = %q", a)
|
||||
}
|
||||
if bucketName(a) == bucketName(b) {
|
||||
t.Fatalf("bucketName collision: %q == %q", bucketName(a), bucketName(b))
|
||||
}
|
||||
|
||||
// physical carries only [a-z0-9_] (safe quoted SQL/Mongo/CH identifier).
|
||||
for _, r := range a {
|
||||
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_') {
|
||||
t.Fatalf("physicalName produced unsafe char %q in %q", r, a)
|
||||
}
|
||||
}
|
||||
// bucket is a valid S3 name: [a-z0-9-], 3..63, no leading/trailing hyphen.
|
||||
bk := bucketName(a)
|
||||
if len(bk) < 3 || len(bk) > 63 {
|
||||
t.Fatalf("bucket %q length %d outside S3 range 3..63", bk, len(bk))
|
||||
}
|
||||
if strings.HasPrefix(bk, "-") || strings.HasSuffix(bk, "-") {
|
||||
t.Fatalf("bucket %q has a leading/trailing hyphen", bk)
|
||||
}
|
||||
for _, r := range bk {
|
||||
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
|
||||
t.Fatalf("bucketName produced unsafe char %q in %q", r, bk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenToken(t *testing.T) {
|
||||
tok, err := genToken(24)
|
||||
if err != nil {
|
||||
t.Fatalf("genToken: %v", err)
|
||||
}
|
||||
// base64.RawURLEncoding of 24 bytes = 32 chars, alphabet has no quote chars.
|
||||
if len(tok) != 32 {
|
||||
t.Fatalf("token len = %d, want 32", len(tok))
|
||||
}
|
||||
if strings.ContainsAny(tok, "'\"`") {
|
||||
t.Fatalf("token %q contains a quote char (unsafe for SQL literals)", tok)
|
||||
}
|
||||
other, _ := genToken(24)
|
||||
if tok == other {
|
||||
t.Fatalf("genToken returned identical tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateOrgGate: a non-admin POST with no X-Org-Id is refused 403 before
|
||||
// anything is provisioned.
|
||||
func TestCreateOrgGate(t *testing.T) {
|
||||
s, mp := newTestSvc(t, "sql")
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Post("/v1/sql", s.create("sql"))
|
||||
|
||||
req, _ := http.NewRequest("POST", "/v1/sql", strings.NewReader(`{"name":"orders"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No X-Org-Id, no X-User-IsAdmin.
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d body=%s, want 403", resp.StatusCode, body)
|
||||
}
|
||||
if mp.created != 0 {
|
||||
t.Fatalf("provisioner ran %d times for a gated request, want 0", mp.created)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateKMSDegradePersistsNoPlaintext: with KMS unconfigured, create returns
|
||||
// the generated password ONCE and persists NO plaintext (stored row carries an
|
||||
// empty secret_ref and the password appears in no stored column).
|
||||
func TestCreateKMSDegradePersistsNoPlaintext(t *testing.T) {
|
||||
s, mp := newTestSvc(t, "kv")
|
||||
if s.sec.Enabled() {
|
||||
t.Fatal("precondition: KMS must be degraded for this test")
|
||||
}
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Post("/v1/kv", s.create("kv"))
|
||||
|
||||
req, _ := http.NewRequest("POST", "/v1/kv", strings.NewReader(`{"name":"cache"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Org-Id", "acme")
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d body=%s, want 201", resp.StatusCode, body)
|
||||
}
|
||||
var cr createResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
// Password is returned exactly once on create under KMS-degrade...
|
||||
if cr.Password == "" {
|
||||
t.Fatal("expected password returned once on create under KMS-degrade")
|
||||
}
|
||||
if cr.Password != mp.gotPw {
|
||||
t.Fatalf("returned password %q != password handed to backend %q", cr.Password, mp.gotPw)
|
||||
}
|
||||
|
||||
// ...but NOTHING is persisted in plaintext.
|
||||
row, err := s.store.Get(context.Background(), "acme", "kv", "cache")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if row.SecretRef != "" {
|
||||
t.Fatalf("secret_ref = %q, want empty under KMS-degrade", row.SecretRef)
|
||||
}
|
||||
for field, val := range map[string]string{
|
||||
"physical_name": row.PhysicalName, "secret_ref": row.SecretRef,
|
||||
"host": row.Host, "username": row.Username, "dbname": row.DBName, "status": row.Status,
|
||||
} {
|
||||
if val == cr.Password {
|
||||
t.Fatalf("plaintext password leaked into stored column %q", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
// modernc.org/sqlite is the pure-Go SQLite driver (already in the cloud
|
||||
// dep graph via graph). Blank import registers the "sqlite" driver name.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// errConflict is returned by Insert when the (org,kind,name) tuple already
|
||||
// exists. errNotFound is returned by Get when no row matches. Callers map
|
||||
// these to HTTP 409 / 404.
|
||||
var (
|
||||
errConflict = errors.New("provisioning: resource already exists")
|
||||
errNotFound = errors.New("provisioning: resource not found")
|
||||
)
|
||||
|
||||
// Resource is one row of provisioned_resources: the control-plane record for a
|
||||
// logical resource (database, bucket, collection, …) created inside a shared
|
||||
// backend. It never carries the plaintext password — only secret_ref, the KMS
|
||||
// key under which the password is sealed.
|
||||
type Resource struct {
|
||||
ID string
|
||||
Org string
|
||||
Kind string
|
||||
Name string
|
||||
PhysicalName string
|
||||
SecretRef string
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
DBName string
|
||||
Status string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// Store is the provisioning metadata database. ONE SQLite file
|
||||
// ({DataDir}/provisioning.db) holds every org's records; tenant isolation is
|
||||
// by the org column, enforced at the query layer. MaxOpenConns(1) serializes
|
||||
// access so multi-step writes never race the SQLite file lock.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// openStore opens (creating if needed) the SQLite metadata DB at path and runs
|
||||
// the migration. The "sqlite" driver is modernc's pure-Go build.
|
||||
func openStore(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
|
||||
}
|
||||
// Single connection: the control-plane table is low-volume and this makes
|
||||
// every write atomic against the file lock without busy-loop retries.
|
||||
db.SetMaxOpenConns(1)
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS provisioned_resources (
|
||||
id TEXT PRIMARY KEY,
|
||||
org TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
physical_name TEXT NOT NULL,
|
||||
secret_ref TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
dbname TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioned_org_kind_name
|
||||
ON provisioned_resources(org, kind, name);
|
||||
-- Global (cross-org) physical-name uniqueness: the authoritative guard that
|
||||
-- two distinct logical resources can NEVER map onto one physical backend
|
||||
-- resource. physical_name embeds a fixed-width org hash, so this also pins
|
||||
-- cross-tenant isolation at the physical layer (not just UNIQUE(org,kind,name),
|
||||
-- which two distinct rows could satisfy while colliding physically). The row
|
||||
-- itself maps physical_name -> (org,kind,name) so names stay traceable.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioned_physical
|
||||
ON provisioned_resources(physical_name);
|
||||
CREATE INDEX IF NOT EXISTS ix_provisioned_org_kind_rank
|
||||
ON provisioned_resources(org, kind, created_at);
|
||||
`
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
const resourceCols = `id,org,kind,name,physical_name,secret_ref,host,port,username,dbname,status,created_at`
|
||||
|
||||
func scanResource(sc interface{ Scan(...any) error }) (Resource, error) {
|
||||
var r Resource
|
||||
err := sc.Scan(&r.ID, &r.Org, &r.Kind, &r.Name, &r.PhysicalName, &r.SecretRef,
|
||||
&r.Host, &r.Port, &r.Username, &r.DBName, &r.Status, &r.CreatedAt)
|
||||
return r, err
|
||||
}
|
||||
|
||||
// Insert writes one resource row inside a transaction. A UNIQUE(org,kind,name)
|
||||
// OR UNIQUE(physical_name) violation surfaces as errConflict so the caller can
|
||||
// roll back the backend side-effects it already performed.
|
||||
func (s *Store) Insert(ctx context.Context, r Resource) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO provisioned_resources (`+resourceCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.ID, r.Org, r.Kind, r.Name, r.PhysicalName, r.SecretRef,
|
||||
r.Host, r.Port, r.Username, r.DBName, r.Status, r.CreatedAt)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return errConflict
|
||||
}
|
||||
return fmt.Errorf("insert: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Get returns the resource for (org,kind,name) or errNotFound.
|
||||
func (s *Store) Get(ctx context.Context, org, kind, name string) (Resource, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+resourceCols+` FROM provisioned_resources WHERE org=? AND kind=? AND name=?`,
|
||||
org, kind, name)
|
||||
r, err := scanResource(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Resource{}, errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Resource{}, fmt.Errorf("get: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// PhysicalExists reports whether ANY org already owns the given physical
|
||||
// backend name. This is the global (cross-org) uniqueness pre-check: paired
|
||||
// with the UNIQUE(physical_name) index it lets the handler fail closed with 409
|
||||
// BEFORE it touches a backend, so a residual name-fold (or hash collision) can
|
||||
// never silently provision over another tenant's physical resource.
|
||||
func (s *Store) PhysicalExists(ctx context.Context, physical string) (bool, error) {
|
||||
var one int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM provisioned_resources WHERE physical_name=? LIMIT 1`, physical).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("physical exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// List returns every resource of kind for org, oldest first.
|
||||
func (s *Store) List(ctx context.Context, org, kind string) ([]Resource, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+resourceCols+` FROM provisioned_resources WHERE org=? AND kind=? ORDER BY created_at ASC, id ASC`,
|
||||
org, kind)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []Resource
|
||||
for rows.Next() {
|
||||
r, err := scanResource(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes the resource row inside a transaction. Reports whether a row
|
||||
// was actually deleted.
|
||||
func (s *Store) Delete(ctx context.Context, org, kind, name string) (bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM provisioned_resources WHERE org=? AND kind=? AND name=?`,
|
||||
org, kind, name)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// rpcEndpoint identifies a remote subsystem reachable over ZAP RPC.
|
||||
// The transport layer is hanzoai/zap (binary, length-prefixed,
|
||||
// per-stream). Per HIP-0106 this is the only inter-subsystem wire
|
||||
// format when subsystems are split-deployed. JSON never appears
|
||||
// between subsystems.
|
||||
type rpcEndpoint struct {
|
||||
subsystem string
|
||||
addr string // e.g. "payments.hanzo.svc:9653"
|
||||
}
|
||||
|
||||
func (e *rpcEndpoint) errf(verb string) error {
|
||||
// TODO(zapc-gen): replace with the real zapc-generated client
|
||||
// once `zapc generate <subsystem>/schema/*.zap --lang go --out
|
||||
// ./zap/gen/` has produced typed stubs for every subsystem.
|
||||
// Until then, the contract is enforced (the call goes through a
|
||||
// typed Go interface), and the wire transport returns a clear
|
||||
// "not wired" error so operators see what's missing.
|
||||
return fmt.Errorf("cloud: ZAP RPC client for %s@%s not yet wired (zapc-gen pending) — %s", e.subsystem, e.addr, verb)
|
||||
}
|
||||
|
||||
// --- per-subsystem RPC stubs --------------------------------------------
|
||||
|
||||
type rpcIAM struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcIAM) VerifyJWT(_ context.Context, _ string) (types.Claims, error) {
|
||||
return types.Claims{}, c.errf("VerifyJWT")
|
||||
}
|
||||
func (c *rpcIAM) GetUser(_ context.Context, _ string) (*types.User, error) {
|
||||
return nil, c.errf("GetUser")
|
||||
}
|
||||
func (c *rpcIAM) GetOrg(_ context.Context, _ string) (*types.Org, error) {
|
||||
return nil, c.errf("GetOrg")
|
||||
}
|
||||
|
||||
type rpcKMS struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcKMS) GetSecret(_ context.Context, _ string) ([]byte, error) {
|
||||
return nil, c.errf("GetSecret")
|
||||
}
|
||||
func (c *rpcKMS) PutSecret(_ context.Context, _ string, _ []byte) error {
|
||||
return c.errf("PutSecret")
|
||||
}
|
||||
func (c *rpcKMS) Sign(_ context.Context, _ string, _ []byte) ([]byte, error) {
|
||||
return nil, c.errf("Sign")
|
||||
}
|
||||
|
||||
type rpcBase struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcBase) Open(_ context.Context, _, _ string) (types.DBHandle, error) {
|
||||
return nil, c.errf("Open")
|
||||
}
|
||||
|
||||
type rpcCommerce struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcCommerce) GetTenantConfig(_ context.Context, _ string) (*types.TenantConfig, error) {
|
||||
return nil, c.errf("GetTenantConfig")
|
||||
}
|
||||
func (c *rpcCommerce) CheckEntitlement(_ context.Context, _, _ string) (*types.LicenseEntitlement, error) {
|
||||
return nil, c.errf("CheckEntitlement")
|
||||
}
|
||||
|
||||
type rpcAI struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcAI) ChatCompletion(_ context.Context, _ *types.ChatRequest) (*types.ChatResponse, error) {
|
||||
return nil, c.errf("ChatCompletion")
|
||||
}
|
||||
|
||||
type rpcO11y struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcO11y) Counter(_ string, _ ...string) types.Counter { return noopCounter{} }
|
||||
func (c *rpcO11y) Timing(_ string, _ ...string) types.Timing { return noopTiming{} }
|
||||
func (c *rpcO11y) Span(ctx context.Context, _ string) (context.Context, types.Span) {
|
||||
return ctx, noopSpan{}
|
||||
}
|
||||
|
||||
type rpcVFS struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcVFS) Put(_ context.Context, _ string, _ []byte) error {
|
||||
return c.errf("Put")
|
||||
}
|
||||
func (c *rpcVFS) Get(_ context.Context, _ string) ([]byte, error) {
|
||||
return nil, c.errf("Get")
|
||||
}
|
||||
|
||||
type rpcMQ struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcMQ) Publish(_ context.Context, _ string, _ []byte) error {
|
||||
return c.errf("Publish")
|
||||
}
|
||||
func (c *rpcMQ) Subscribe(_ context.Context, _ string, _ func([]byte) error) error {
|
||||
return c.errf("Subscribe")
|
||||
}
|
||||
|
||||
type rpcPayments struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcPayments) CreateIntent(_ context.Context, _ *types.IntentRequest) (*types.IntentResponse, error) {
|
||||
return nil, c.errf("CreateIntent")
|
||||
}
|
||||
func (c *rpcPayments) ConfirmIntent(_ context.Context, _ string) (*types.IntentResponse, error) {
|
||||
return nil, c.errf("ConfirmIntent")
|
||||
}
|
||||
func (c *rpcPayments) GetIntentStatus(_ context.Context, _ string) (*types.IntentStatus, error) {
|
||||
return nil, c.errf("GetIntentStatus")
|
||||
}
|
||||
|
||||
type rpcVault struct{ rpcEndpoint }
|
||||
|
||||
func (c *rpcVault) Charge(_ context.Context, _ *types.VaultChargeRequest) (*types.VaultChargeResponse, error) {
|
||||
return nil, c.errf("Charge")
|
||||
}
|
||||
|
||||
// --- constructors --------------------------------------------------------
|
||||
|
||||
// IAMRPCAt returns a ZAP-RPC IAM client targeting addr.
|
||||
func IAMRPCAt(addr string) types.IAMClient {
|
||||
return &rpcIAM{rpcEndpoint{subsystem: "iam", addr: addr}}
|
||||
}
|
||||
|
||||
// KMSRPCAt returns a ZAP-RPC KMS client targeting addr.
|
||||
func KMSRPCAt(addr string) types.KMSClient {
|
||||
return &rpcKMS{rpcEndpoint{subsystem: "kms", addr: addr}}
|
||||
}
|
||||
|
||||
// BaseRPCAt returns a ZAP-RPC Base client targeting addr.
|
||||
func BaseRPCAt(addr string) types.BaseClient {
|
||||
return &rpcBase{rpcEndpoint{subsystem: "base", addr: addr}}
|
||||
}
|
||||
|
||||
// CommerceRPCAt returns a ZAP-RPC Commerce client targeting addr.
|
||||
func CommerceRPCAt(addr string) types.CommerceClient {
|
||||
return &rpcCommerce{rpcEndpoint{subsystem: "commerce", addr: addr}}
|
||||
}
|
||||
|
||||
// AIRPCAt returns a ZAP-RPC AI client targeting addr.
|
||||
func AIRPCAt(addr string) types.AIClient {
|
||||
return &rpcAI{rpcEndpoint{subsystem: "ai", addr: addr}}
|
||||
}
|
||||
|
||||
// O11yRPCAt returns a ZAP-RPC O11y client targeting addr.
|
||||
func O11yRPCAt(addr string) types.O11yClient {
|
||||
return &rpcO11y{rpcEndpoint{subsystem: "o11y", addr: addr}}
|
||||
}
|
||||
|
||||
// VFSRPCAt returns a ZAP-RPC VFS client targeting addr.
|
||||
func VFSRPCAt(addr string) types.VFSClient {
|
||||
return &rpcVFS{rpcEndpoint{subsystem: "vfs", addr: addr}}
|
||||
}
|
||||
|
||||
// MQRPCAt returns a ZAP-RPC MQ client targeting addr.
|
||||
func MQRPCAt(addr string) types.MQClient {
|
||||
return &rpcMQ{rpcEndpoint{subsystem: "mq", addr: addr}}
|
||||
}
|
||||
|
||||
// PaymentsRPCAt returns a ZAP-RPC Payments client targeting addr.
|
||||
// Payments is ALWAYS split-deployed (PCI scope isolation per HIP-0106
|
||||
// solo-vault CDE), so there is no in-process variant.
|
||||
func PaymentsRPCAt(addr string) types.PaymentsClient {
|
||||
return &rpcPayments{rpcEndpoint{subsystem: "payments", addr: addr}}
|
||||
}
|
||||
|
||||
// VaultRPCAt returns a ZAP-RPC Vault client targeting addr. Vault is
|
||||
// ALWAYS split-deployed (PCI-CDE, the only system that touches PAN),
|
||||
// so there is no in-process variant.
|
||||
func VaultRPCAt(addr string) types.VaultClient {
|
||||
return &rpcVault{rpcEndpoint{subsystem: "vault", addr: addr}}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// Package websearch exposes Hanzo-native Web Search + Scrape on the unified
|
||||
// cloud-api /v1 plane, so hanzo.chat's web_search agent tool runs entirely on
|
||||
// Hanzo infrastructure with NO external SaaS provider, per HIP-0106.
|
||||
//
|
||||
// hanzo.chat (LibreChat fork) implements web_search as a fixed 3-stage pipeline
|
||||
// whose provider contracts are frozen by the upstream client
|
||||
// (@librechat/agents tools/search). The only self-hostable, key-less-to-a-SaaS
|
||||
// providers it accepts are:
|
||||
// - search provider "searxng" → GET {searxngInstanceUrl}/search?q=&format=json
|
||||
// ← {results:[{url,title,content,img_src?}]}
|
||||
// - scraper provider "firecrawl" → POST {firecrawlApiUrl}/{version}/scrape
|
||||
// body {url,formats} ← {success,data:{markdown,metadata}}
|
||||
// (reranker is optional; we omit it — provider+scraper is sufficient.)
|
||||
//
|
||||
// This subsystem serves BOTH contracts under /v1/websearch, backed by Hanzo's
|
||||
// own services — never a third-party search API:
|
||||
// - GET /v1/websearch/search SearXNG-shaped. Proxied to a Hanzo-operated
|
||||
// metasearch instance (WEBSEARCH_UPSTREAM).
|
||||
// - POST /v1/websearch/v1/scrape Firecrawl-shaped. Backed by Hanzo Crawl
|
||||
// (also /v1/websearch/scrape) (crawl.hanzo.svc, Crawl4AI): fetch the URL,
|
||||
// return {success,data:{markdown,metadata}}.
|
||||
//
|
||||
// The chat server calls these SERVER-SIDE in-cluster, so point
|
||||
// searxngInstanceUrl / firecrawlApiUrl at this surface (public api.hanzo.ai/v1
|
||||
// or the internal cloud-api svc DNS — same binary either way).
|
||||
//
|
||||
// AUTH: firecrawl requires a Bearer key; searxng accepts an optional X-API-Key.
|
||||
// Both are the same shared service key WEBSEARCH_API_KEY (KMS-sourced, synced
|
||||
// into the pod env). An unset key fails closed on the authed scrape path.
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
)
|
||||
|
||||
// defaults are in-cluster service DNS; overridable via env.
|
||||
const (
|
||||
// A Hanzo-operated SearXNG metasearch instance. Self-hosted: Hanzo runs it,
|
||||
// no third-party search-API key. Serves the SearXNG JSON contract directly,
|
||||
// so search is a transparent proxy (no reshape).
|
||||
defaultSearchUpstream = "http://searxng.hanzo.svc.cluster.local:8080"
|
||||
// Hanzo Crawl (Crawl4AI) — the scrape backend.
|
||||
defaultCrawlEndpoint = "http://crawl.hanzo.svc.cluster.local:11235"
|
||||
)
|
||||
|
||||
func searchUpstream() string {
|
||||
if v := strings.TrimSpace(os.Getenv("WEBSEARCH_UPSTREAM")); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultSearchUpstream
|
||||
}
|
||||
|
||||
func crawlEndpoint() string {
|
||||
if v := strings.TrimSpace(os.Getenv("WEBSEARCH_CRAWL_ENDPOINT")); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultCrawlEndpoint
|
||||
}
|
||||
|
||||
// crawlToken is the optional bearer the Hanzo Crawl service expects
|
||||
// (CRAWL4AI_API_TOKEN on the crawl deployment). Empty ⇒ no header.
|
||||
func crawlToken() string { return strings.TrimSpace(os.Getenv("WEBSEARCH_CRAWL_TOKEN")) }
|
||||
|
||||
// apiKey is the shared service key the chat server presents (firecrawl Bearer /
|
||||
// searxng X-API-Key). KMS-sourced, synced as WEBSEARCH_API_KEY.
|
||||
func apiKey() string { return strings.TrimSpace(os.Getenv("WEBSEARCH_API_KEY")) }
|
||||
|
||||
var httpClient = &http.Client{Timeout: 45 * time.Second}
|
||||
|
||||
// ── SearXNG search: transparent proxy to the Hanzo metasearch instance ──────
|
||||
// The upstream already speaks the SearXNG JSON contract, so we forward verbatim
|
||||
// (append nothing, reshape nothing). Only scheme/host/path-prefix change.
|
||||
|
||||
func newSearchProxy(rawURL string) (http.Handler, error) {
|
||||
target, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target.Scheme == "" || target.Host == "" {
|
||||
return nil, fmt.Errorf("websearch: WEBSEARCH_UPSTREAM must be an absolute URL, got %q", rawURL)
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
base := proxy.Director
|
||||
proxy.Director = func(r *http.Request) {
|
||||
base(r)
|
||||
// /v1/websearch/search → {upstream}/search (SearXNG's own path).
|
||||
r.URL.Path = "/search" + strings.TrimPrefix(r.URL.Path, "/v1/websearch/search")
|
||||
r.URL.RawPath = ""
|
||||
r.Host = target.Host
|
||||
}
|
||||
proxy.Transport = &http.Transport{ResponseHeaderTimeout: 30 * time.Second}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// searchGuard applies the OPTIONAL searxng key check: if WEBSEARCH_API_KEY is
|
||||
// set and the caller sent an X-API-Key, it must match; a missing X-API-Key is
|
||||
// allowed (searxng treats the key as optional), matching the client which only
|
||||
// sends it when configured.
|
||||
func searchGuard(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if want := apiKey(); want != "" {
|
||||
if got := strings.TrimSpace(r.Header.Get("X-API-Key")); got != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Firecrawl scrape: adapt Hanzo Crawl → the firecrawl response shape ──────
|
||||
|
||||
// firecrawlRequest is the subset of the firecrawl /scrape body we honor.
|
||||
type firecrawlRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// firecrawlResponse is the exact shape the LibreChat firecrawl client decodes:
|
||||
// {success, data:{markdown, metadata}}.
|
||||
type firecrawlResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *firecrawlData `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type firecrawlData struct {
|
||||
Markdown string `json:"markdown"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// crawlRequest / crawlResult mirror Hanzo Crawl's /crawl contract
|
||||
// (ai/object/crawl4ai.go).
|
||||
type crawlRequest struct {
|
||||
Urls []string `json:"urls"`
|
||||
}
|
||||
|
||||
type crawlResult struct {
|
||||
URL string `json:"url"`
|
||||
Markdown string `json:"markdown"`
|
||||
Success bool `json:"success"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type crawlResponse struct {
|
||||
Status string `json:"status"`
|
||||
Results []crawlResult `json:"results"`
|
||||
}
|
||||
|
||||
func scrapeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Bearer auth (firecrawl always sends Authorization: Bearer <key>); fail
|
||||
// closed if unconfigured.
|
||||
want := apiKey()
|
||||
if want == "" {
|
||||
writeErr(w, http.StatusServiceUnavailable, "web search not configured")
|
||||
return
|
||||
}
|
||||
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
|
||||
var req firecrawlRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil || req.URL == "" {
|
||||
writeJSON(w, http.StatusOK, firecrawlResponse{Success: false, Error: "missing url"})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := crawl(req.URL)
|
||||
if err != nil || res == nil || !res.Success {
|
||||
msg := "crawl failed"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, firecrawlResponse{Success: false, Error: msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, firecrawlResponse{
|
||||
Success: true,
|
||||
Data: &firecrawlData{Markdown: res.Markdown, Metadata: res.Metadata},
|
||||
})
|
||||
}
|
||||
|
||||
// crawl fetches one URL via Hanzo Crawl and returns its markdown result.
|
||||
func crawl(target string) (*crawlResult, error) {
|
||||
body, _ := json.Marshal(crawlRequest{Urls: []string{target}})
|
||||
req, err := http.NewRequest(http.MethodPost, crawlEndpoint()+"/crawl", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if tok := crawlToken(); tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("hanzo crawl returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
var cr crawlResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cr.Results) == 0 {
|
||||
return nil, fmt.Errorf("hanzo crawl returned no results for %s", target)
|
||||
}
|
||||
return &cr.Results[0], nil
|
||||
}
|
||||
|
||||
// ── shared JSON writers ─────────────────────────────────────────────────────
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeRaw(w, status, fmt.Sprintf(`{"status":%d,"error":%q}`, status, msg))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "encode error")
|
||||
return
|
||||
}
|
||||
writeRaw(w, status, string(b))
|
||||
}
|
||||
|
||||
func writeRaw(w http.ResponseWriter, status int, body string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}
|
||||
|
||||
// Mount registers the web-search surface on app.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("websearch.Mount: nil zip.App")
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
return fmt.Errorf("websearch.Mount: nil deps.Logger")
|
||||
}
|
||||
logger = logger.New("subsystem", "websearch")
|
||||
|
||||
searchProxy, err := newSearchProxy(searchUpstream())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.All("/v1/websearch/search", zip.AdaptNetHTTP(searchGuard(searchProxy)))
|
||||
|
||||
scrape := zip.AdaptNetHTTPFunc(scrapeHandler)
|
||||
// Firecrawl builds {apiUrl}/{version}/scrape; pin firecrawlVersion:v1 so the
|
||||
// client POSTs /v1/websearch/v1/scrape. Also accept the bare /scrape.
|
||||
app.Post("/v1/websearch/v1/scrape", scrape)
|
||||
app.Post("/v1/websearch/scrape", scrape)
|
||||
|
||||
logger.Info("web search surface mounted (searxng-compat proxy + firecrawl-compat over Hanzo Crawl)",
|
||||
"searchUpstream", searchUpstream(), "crawl", crawlEndpoint())
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Order 141: before hanzoai/ai (150) so /v1/websearch/* wins over ai's
|
||||
// /v1/* catch-all; sits next to exec (140).
|
||||
cloud.Register("websearch", 141, func(app any, deps cloud.Deps) error {
|
||||
a, ok := app.(*zip.App)
|
||||
if !ok {
|
||||
return fmt.Errorf("websearch.Mount: app is %T, want *zip.App", app)
|
||||
}
|
||||
return Mount(a, deps)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v3"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// Mount() must register /v1/websearch/search + the two scrape POST paths on a
|
||||
// real Fiber router without panicking, and requests routed through the whole
|
||||
// app must reach the handlers (search proxy + firecrawl-shaped scrape).
|
||||
func TestMountRoutesThroughRouter(t *testing.T) {
|
||||
searchUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"results":[]}`)
|
||||
}))
|
||||
defer searchUp.Close()
|
||||
crawlUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"status":"completed","results":[{"url":"https://ex","markdown":"# M","success":true}]}`)
|
||||
}))
|
||||
defer crawlUp.Close()
|
||||
t.Setenv("WEBSEARCH_UPSTREAM", searchUp.URL)
|
||||
t.Setenv("WEBSEARCH_CRAWL_ENDPOINT", crawlUp.URL)
|
||||
t.Setenv("WEBSEARCH_API_KEY", "k")
|
||||
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
fa := app.Fiber()
|
||||
|
||||
// Search routes through to the searxng proxy.
|
||||
req := httptest.NewRequest(http.MethodGet, "http://api.hanzo.ai/v1/websearch/search?q=x&format=json", nil)
|
||||
resp, err := fa.Test(req, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("search route: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("search route status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Firecrawl scrape (the /v1/scrape path the client builds) routes to the
|
||||
// crawl-backed handler and returns the firecrawl shape.
|
||||
sreq := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai/v1/websearch/v1/scrape",
|
||||
strings.NewReader(`{"url":"https://ex"}`))
|
||||
sreq.Header.Set("Authorization", "Bearer k")
|
||||
sreq.Header.Set("Content-Type", "application/json")
|
||||
sresp, err := fa.Test(sreq, fiber.TestConfig{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("scrape route: %v", err)
|
||||
}
|
||||
b, _ := io.ReadAll(sresp.Body)
|
||||
if sresp.StatusCode != http.StatusOK || !strings.Contains(string(b), `"success":true`) {
|
||||
t.Fatalf("scrape route status %d body %s", sresp.StatusCode, string(b))
|
||||
}
|
||||
_ = sresp.Body.Close()
|
||||
}
|
||||
|
||||
func TestMountRejectsBadInputs(t *testing.T) {
|
||||
if err := Mount(nil, cloud.Deps{Logger: luxlog.New("test")}); err == nil {
|
||||
t.Fatal("Mount(nil app) should error")
|
||||
}
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{}); err == nil {
|
||||
t.Fatal("Mount(nil logger) should error")
|
||||
}
|
||||
}
|
||||
|
||||
// Search must proxy /v1/websearch/search → {upstream}/search verbatim (query
|
||||
// preserved), so the LibreChat searxng client gets a real SearXNG response.
|
||||
func TestSearchProxyRewritesToSearchPath(t *testing.T) {
|
||||
var gotPath, gotQuery string
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"results":[{"url":"https://x","title":"T","content":"C"}]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
proxy, err := newSearchProxy(up.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("newSearchProxy: %v", err)
|
||||
}
|
||||
t.Setenv("WEBSEARCH_API_KEY", "") // key optional for search
|
||||
h := searchGuard(proxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"http://api.hanzo.ai/v1/websearch/search?q=hanzo+ai&format=json&engines=google,bing", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if gotPath != "/search" {
|
||||
t.Fatalf("upstream path = %q, want /search", gotPath)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "q=hanzo+ai") || !strings.Contains(gotQuery, "format=json") {
|
||||
t.Fatalf("upstream query = %q, want the searxng params preserved", gotQuery)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"results"`) {
|
||||
t.Fatalf("searxng body not passed through: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// When a key IS configured and the caller sends a WRONG X-API-Key, reject.
|
||||
func TestSearchWrongKeyRejected(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("upstream must not be reached with a wrong key")
|
||||
}))
|
||||
defer up.Close()
|
||||
proxy, _ := newSearchProxy(up.URL)
|
||||
t.Setenv("WEBSEARCH_API_KEY", "right")
|
||||
h := searchGuard(proxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://api.hanzo.ai/v1/websearch/search?q=x", nil)
|
||||
req.Header.Set("X-API-Key", "wrong")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing X-API-Key is allowed for search (searxng key is optional).
|
||||
func TestSearchMissingKeyAllowed(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"results":[]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
proxy, _ := newSearchProxy(up.URL)
|
||||
t.Setenv("WEBSEARCH_API_KEY", "configured")
|
||||
h := searchGuard(proxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://api.hanzo.ai/v1/websearch/search?q=x", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (missing key allowed for searxng)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Scrape must Bearer-auth, call Hanzo Crawl, and adapt its {url,markdown,...}
|
||||
// result into the firecrawl {success,data:{markdown,metadata}} shape.
|
||||
func TestScrapeAdaptsCrawlToFirecrawlShape(t *testing.T) {
|
||||
var gotCrawlBody string
|
||||
crawlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/crawl" {
|
||||
t.Fatalf("crawl path = %q, want /crawl", r.URL.Path)
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotCrawlBody = string(b)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"status":"completed","results":[{"url":"https://ex.com","markdown":"# Hello","success":true,"metadata":{"title":"Ex"}}]}`)
|
||||
}))
|
||||
defer crawlSrv.Close()
|
||||
|
||||
t.Setenv("WEBSEARCH_API_KEY", "svc-key")
|
||||
t.Setenv("WEBSEARCH_CRAWL_ENDPOINT", crawlSrv.URL)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai/v1/websearch/v1/scrape",
|
||||
strings.NewReader(`{"url":"https://ex.com","formats":["markdown"]}`))
|
||||
req.Header.Set("Authorization", "Bearer svc-key")
|
||||
rec := httptest.NewRecorder()
|
||||
scrapeHandler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s, want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(gotCrawlBody, `"urls":["https://ex.com"]`) {
|
||||
t.Fatalf("crawl body = %q, want urls array with the target", gotCrawlBody)
|
||||
}
|
||||
var out firecrawlResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode response: %v (%s)", err, rec.Body.String())
|
||||
}
|
||||
if !out.Success || out.Data == nil || out.Data.Markdown != "# Hello" {
|
||||
t.Fatalf("response = %+v, want success with markdown '# Hello'", out)
|
||||
}
|
||||
if out.Data.Metadata["title"] != "Ex" {
|
||||
t.Fatalf("metadata not passed through: %+v", out.Data.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Scrape fails closed with no configured key.
|
||||
func TestScrapeUnsetKeyFailsClosed(t *testing.T) {
|
||||
t.Setenv("WEBSEARCH_API_KEY", "")
|
||||
req := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai/v1/websearch/v1/scrape",
|
||||
strings.NewReader(`{"url":"https://ex.com"}`))
|
||||
req.Header.Set("Authorization", "Bearer anything")
|
||||
rec := httptest.NewRecorder()
|
||||
scrapeHandler(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 (fail closed)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Scrape rejects a wrong Bearer key.
|
||||
func TestScrapeWrongKeyRejected(t *testing.T) {
|
||||
t.Setenv("WEBSEARCH_API_KEY", "right")
|
||||
req := httptest.NewRequest(http.MethodPost, "http://api.hanzo.ai/v1/websearch/v1/scrape",
|
||||
strings.NewReader(`{"url":"https://ex.com"}`))
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
rec := httptest.NewRecorder()
|
||||
scrapeHandler(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSearchProxyRejectsBadURL(t *testing.T) {
|
||||
if _, err := newSearchProxy("://nope"); err == nil {
|
||||
t.Fatal("expected error for malformed upstream URL")
|
||||
}
|
||||
if _, err := newSearchProxy("relative"); err == nil {
|
||||
t.Fatal("expected error for non-absolute upstream URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamDefaultsAndOverrides(t *testing.T) {
|
||||
t.Setenv("WEBSEARCH_UPSTREAM", "")
|
||||
if got := searchUpstream(); got != defaultSearchUpstream {
|
||||
t.Fatalf("searchUpstream() = %q, want default", got)
|
||||
}
|
||||
t.Setenv("WEBSEARCH_UPSTREAM", "http://searxng:8080")
|
||||
if got := searchUpstream(); got != "http://searxng:8080" {
|
||||
t.Fatalf("searchUpstream() override = %q", got)
|
||||
}
|
||||
t.Setenv("WEBSEARCH_CRAWL_ENDPOINT", "")
|
||||
if got := crawlEndpoint(); got != defaultCrawlEndpoint {
|
||||
t.Fatalf("crawlEndpoint() = %q, want default", got)
|
||||
}
|
||||
}
|
||||
+11
-54
@@ -4,6 +4,10 @@
|
||||
// which subsystems mount at startup. Same artifact powers
|
||||
// api.hanzo.ai, api.osage.cloud, api.lux.cloud, api.zoo.cloud, and
|
||||
// every other white-label resold cloud surface.
|
||||
//
|
||||
// The serve body lives in cloud.Serve (one place, shared with the `hanzo`
|
||||
// subcommand dispatcher); main() is just its full-surface entrypoint. The
|
||||
// subsystem set is defined once in the subsystems bundle.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,64 +15,17 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
"github.com/hanzoai/zip/middleware"
|
||||
|
||||
// Subsystems — each ships func Mount(*zip.App, cloud.Deps) error.
|
||||
// Imports register themselves in cloud.Registry via init().
|
||||
//
|
||||
// Uncomment + add each as the per-subsystem port lands:
|
||||
//
|
||||
// _ "github.com/hanzoai/kms/pkg/kms" // PR pending
|
||||
// _ "github.com/hanzoai/amqp/pkg/amqp" // PR pending
|
||||
// _ "github.com/hanzoai/vfs/pkg/vfs" // already has Mount
|
||||
// _ "github.com/hanzoai/mq/pkg/mq" // PR pending
|
||||
// _ "github.com/hanzoai/iam/pkg/iam" // PR pending
|
||||
// _ "github.com/hanzoai/base/pkg/base" // PR pending
|
||||
// _ "github.com/hanzoai/commerce/pkg/commerce" // already has Mount (gin) — adapt to zip
|
||||
// _ "github.com/hanzoai/gateway/pkg/gateway" // already has Mount
|
||||
// _ "github.com/hanzoai/o11y/pkg/o11y" // PR pending
|
||||
// _ "github.com/hanzoai/ai/pkg/ai" // PR pending (was hanzoai/cloud LLM subsystem)
|
||||
// _ "github.com/hanzoai/mcp/pkg/mcp" // PR pending
|
||||
// Every subsystem registers into cloud.Registry via init(); the set is
|
||||
// defined ONCE in the subsystems bundle (one source of truth, shared with
|
||||
// cmd/hanzo). Blank-importing it populates the registry cloud.Serve mounts.
|
||||
_ "github.com/hanzoai/cloud/subsystems"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := cloud.LoadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
|
||||
app := zip.New(zip.Config{Logger: deps.Logger})
|
||||
|
||||
// Canonical middleware pipeline. Order matters:
|
||||
// 1. Recover — panic → JSON 500
|
||||
// 2. RequestID — generate / propagate X-Request-Id
|
||||
// 3. Logger — request-line log via luxfi/log
|
||||
// 4. Telemetry — OTel span; depends on deps.O11y if enabled
|
||||
// 5. Auth — JWT validation; strips client identity, mints from JWT
|
||||
app.Use(middleware.Recover())
|
||||
app.Use(middleware.RequestID())
|
||||
app.Use(middleware.Logger(deps.Logger))
|
||||
// app.Use(middleware.Telemetry(deps.O11y)) // enable once o11y mounted
|
||||
// app.Use(middleware.Auth(deps.IAM)) // enable once iam mounted
|
||||
|
||||
// Per-deployment subsystem mount.
|
||||
if err := cloud.MountAll(app, cfg, deps); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mount: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
deps.Logger.Info("listening",
|
||||
"http", cfg.ListenAddr,
|
||||
"zap", cfg.ZAPListenAddr,
|
||||
"brand", cfg.Brand,
|
||||
"domain", cfg.Domain,
|
||||
)
|
||||
if err := app.Listen(cfg.ListenAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "listen: %v\n", err)
|
||||
// nil ⇒ honor cfg.Enable from flags/env (empty = all subsystems).
|
||||
if err := cloud.Serve(nil); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cloud: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
// Real integration tests for the unified Hanzo Cloud binary (HIP-0106).
|
||||
// These exercise the actual orchestrator path — BuildDeps -> MountAll over the
|
||||
// init()-populated Registry -> serve via the real zip/fiber + jsonenc stack —
|
||||
// not a hand-rolled smoke harness. app.Fiber().Test drives requests in-process,
|
||||
// no listener or external services.
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/zip"
|
||||
"github.com/hanzoai/zip/middleware"
|
||||
)
|
||||
|
||||
// every subsystem main.go imports must self-register via init() — this is the
|
||||
// proof the unified binary actually wires the whole matrix.
|
||||
var wantSubsystems = []string{
|
||||
"metrics", "base", "authz", "o11y",
|
||||
"licensing", "plans", "pricing", "ai",
|
||||
}
|
||||
|
||||
func TestRegistryAssemblesSubsystems(t *testing.T) {
|
||||
got := map[string]bool{}
|
||||
for _, s := range cloud.Registry {
|
||||
got[s.Name] = true
|
||||
}
|
||||
for _, name := range wantSubsystems {
|
||||
if !got[name] {
|
||||
t.Errorf("subsystem %q not registered — main.go import or its init() missing", name)
|
||||
}
|
||||
}
|
||||
t.Logf("registry assembled %d subsystems", len(cloud.Registry))
|
||||
}
|
||||
|
||||
// newTestApp mirrors main()'s wiring: BuildDeps + the canonical middleware
|
||||
// pipeline + MountAll for the requested subsystems.
|
||||
func newTestApp(t *testing.T, enable ...string) *zip.App {
|
||||
t.Helper()
|
||||
cfg := &cloud.Config{
|
||||
Brand: "hanzo",
|
||||
Domain: "api.hanzo.ai",
|
||||
DataDir: t.TempDir(),
|
||||
Enable: enable,
|
||||
}
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
app := zip.New(zip.Config{Logger: deps.Logger})
|
||||
app.Use(middleware.Recover())
|
||||
app.Use(middleware.RequestID())
|
||||
app.Use(middleware.Logger(deps.Logger))
|
||||
if err := cloud.MountAll(app, cfg, deps); err != nil {
|
||||
t.Fatalf("MountAll(%v): %v", enable, err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// The self-contained subsystems mount in-process (per-tenant SQLite / in-mem,
|
||||
// HIP-0302) and serve a healthy /v1/<name>/health with no external deps.
|
||||
func TestMountAllAndServeHealth(t *testing.T) {
|
||||
healthy := []string{"base", "authz", "metrics", "plans", "pricing"}
|
||||
app := newTestApp(t, healthy...)
|
||||
for _, name := range healthy {
|
||||
path := "/v1/" + name + "/health"
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subsystems whose deps are disabled (no in-process peer, no ZAP endpoint) must
|
||||
// mount and fail CLOSED — a 5xx from the disabled stub, never a panic or a
|
||||
// silent 200. This proves the BuildDeps three-mode contract end-to-end.
|
||||
func TestDepGatedSubsystemsFailClosed(t *testing.T) {
|
||||
for _, name := range []string{"ai", "o11y"} {
|
||||
app := newTestApp(t, name)
|
||||
path := "/v1/" + name + "/health"
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
if resp.StatusCode < 500 {
|
||||
t.Errorf("GET %s = %d, want >=500 (fail-closed; deps disabled)", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Command hanzo is the unified Hanzo Go binary, dispatched by subcommand.
|
||||
//
|
||||
// hanzo list subcommands
|
||||
// hanzo --help list subcommands
|
||||
// hanzo <svc> [flags] serve exactly one subsystem (iam, kms, commerce,
|
||||
// gateway, ai, base, vfs, o11y, …)
|
||||
// hanzo cloud [flags] serve the full unified surface (all enabled
|
||||
// subsystems mounted into one zip.App / one listener)
|
||||
//
|
||||
// One binary. Many subsystems. The subcommand selects WHICH subsystem(s)
|
||||
// serve this process; the same artifact is every standalone service AND
|
||||
// the fused cloud control plane.
|
||||
//
|
||||
// Design — one mechanism, not many. Every Hanzo subsystem registers a
|
||||
// cloud.MountSpec{Name, Order, Mount} into cloud.Registry via init() at
|
||||
// package load (kms order 10, iam 50, gateway 80, commerce 100, …). A
|
||||
// subcommand is therefore just a *selection* over that registry:
|
||||
//
|
||||
// - `hanzo <svc>` ⇒ cloud.Serve([]string{svc}); MountAll mounts only it.
|
||||
// - `hanzo cloud` ⇒ cloud.Serve(nil); cfg.Enable per --enable (empty = all).
|
||||
//
|
||||
// Both paths run the identical compose root (BuildDeps → zip.App → health
|
||||
// contract → MountAll → graceful Listen) — that body lives once in cloud.Serve
|
||||
// and is shared with cmd/cloud. No subcommand duplicates boot logic.
|
||||
//
|
||||
// The single exception is `hanzo iam`. The registry's iam Mount (pkg/iam,
|
||||
// order 50) wraps the Beego handler under /v1/iam/* for the fused surface;
|
||||
// the FULL standalone IAM — login UI at /, all ~150 routes at root, LDAP +
|
||||
// RADIUS listeners — is iamserver.Run(), the body of the legacy iamd
|
||||
// main(). `hanzo iam` runs that, so the standalone identity provider is
|
||||
// byte-for-byte what iamd shipped. See the iam case in dispatch().
|
||||
//
|
||||
// THE BEEGO CRUX (and why this binary does not init-panic). iam imports
|
||||
// github.com/hanzoai/beego/v2; that fork carries process-global state
|
||||
// (web.BeeApp singleton, ORM model registry, logger registration). The
|
||||
// fear is that importing iam alongside the other subsystems collides at
|
||||
// package load regardless of subcommand. It does not, for two reasons
|
||||
// this codebase already established:
|
||||
//
|
||||
// 1. iam's ~150 route registrations and ORM table creation are NOT at
|
||||
// package init() — they live inside iamserver.Init() / routers.InitAPI()
|
||||
// / object.CreateTables(), which run only when iam actually serves.
|
||||
// Blank-importing the package is inert: no router, no ORM, no listener.
|
||||
// 2. There is exactly ONE Beego v2 import path in the graph
|
||||
// (hanzoai/beego/v2), so there is exactly one Beego global to
|
||||
// initialize, and it is initialized lazily by whichever path serves
|
||||
// iam. (visor, the other Beego service, pins the *v1* fork
|
||||
// github.com/beego/beego and is intentionally NOT linked here — two
|
||||
// Beego majors in one binary is the collision to avoid, so we don't.)
|
||||
//
|
||||
// The proof is mechanical: the existing cmd/cloud binary already links
|
||||
// this same graph (iam Beego v2 + kms + commerce + gateway + …) and builds
|
||||
// + boots. cmd/hanzo links the same set plus iamserver for the standalone
|
||||
// path; nothing new collides.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
// cli is the cloud-control CLI (client mode). `hanzo <verb>` — login, apps,
|
||||
// deploy, clusters, build, k8s, config — is a thin client over IAM +
|
||||
// platform + cloud; `hanzo <subsystem>` (below) is server mode. The two
|
||||
// worlds share one binary and are selected by the first token. Imported
|
||||
// FIRST so its init() captures the real stdout before the server-graph
|
||||
// dependencies' init() functions emit startup chatter to it.
|
||||
"github.com/hanzoai/cloud/cli"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
|
||||
// iamserver is the body of the standalone iamd main() — full Beego
|
||||
// server (login UI, all routes, LDAP/RADIUS). `hanzo iam` calls Run().
|
||||
"github.com/hanzoai/iam/iamserver"
|
||||
|
||||
// Every subsystem registers into cloud.Registry via init(); the set is
|
||||
// defined ONCE in the subsystems bundle (shared with cmd/cloud), so the
|
||||
// dispatcher and the full-surface binary mount an identical set. Inert at
|
||||
// load — see THE BEEGO CRUX.
|
||||
_ "github.com/hanzoai/cloud/subsystems"
|
||||
)
|
||||
|
||||
// version is overridden at build time via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
// nonRegistrySubcommands are the dispatch targets that do NOT correspond to
|
||||
// a single cloud.Registry entry: the full fused surface, the standalone IAM
|
||||
// boot, and the datastore (a ClickHouse C++ fork with no Go serve target —
|
||||
// see the datastore case in dispatch()). Listed in --help alongside the
|
||||
// registry-backed subcommands.
|
||||
var nonRegistrySubcommands = map[string]string{
|
||||
"cloud": "serve the full unified surface (all enabled subsystems, one listener)",
|
||||
"iam": "serve standalone Hanzo IAM (full Beego server: login UI, OAuth2/OIDC, LDAP/RADIUS)",
|
||||
"datastore": "ClickHouse-fork analytics DB — not a Go serve target (see help text)",
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Restore the real stdout (cli.init redirected it to stderr so dependency
|
||||
// startup chatter cannot corrupt machine-readable output).
|
||||
cli.RestoreStdout()
|
||||
|
||||
// Share the build version with the CLI (User-Agent, `hanzo version`).
|
||||
cli.Version = version
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
usage(os.Stdout)
|
||||
return
|
||||
}
|
||||
sub := os.Args[1]
|
||||
switch sub {
|
||||
case "-h", "--help", "help":
|
||||
usage(os.Stdout)
|
||||
return
|
||||
case "version", "--version", "-v":
|
||||
fmt.Printf("hanzo %s\n", version)
|
||||
return
|
||||
}
|
||||
|
||||
// CLIENT MODE. A control-plane verb (login, apps, deploy, clusters, build,
|
||||
// k8s, config) routes to the cobra cloud-control CLI with the full args
|
||||
// (including the verb) so cobra can parse subcommands + flags. cobra prints
|
||||
// its own errors, so just translate to a non-zero exit.
|
||||
if cli.IsControlVerb(sub) {
|
||||
if err := cli.Execute(os.Args[1:]); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SERVER MODE. Reset os.Args so the delegated service / cloud.LoadConfig
|
||||
// sees its own flags at argv[1:], not the subcommand token. e.g.
|
||||
// `hanzo kms --listen=:9000` → the kms serve path parses `--listen=:9000`.
|
||||
os.Args = append(os.Args[:1], os.Args[2:]...)
|
||||
|
||||
if err := dispatch(sub); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "hanzo %s: %v\n", sub, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch routes a subcommand to its serve entrypoint.
|
||||
func dispatch(sub string) error {
|
||||
switch sub {
|
||||
case "cloud":
|
||||
// Full fused surface: --enable governs the set (empty = all).
|
||||
return cloud.Serve(nil)
|
||||
|
||||
case "iam":
|
||||
// Standalone IAM = the body of iamd's main(). Full Beego server:
|
||||
// login UI at /, ~150 routes at root, LDAP + RADIUS listeners,
|
||||
// background sync loops. iamserver.Run() blocks until the process
|
||||
// is signalled. This is intentionally NOT the /v1/iam/*-wrapped
|
||||
// registry Mount — `hanzo iam` IS the identity provider, not a
|
||||
// route-prefixed subsystem inside the cloud surface.
|
||||
iamserver.Run()
|
||||
return nil
|
||||
|
||||
case "datastore":
|
||||
// Hanzo Datastore is a ClickHouse C++ fork. It has no Go
|
||||
// Serve()/Run() to dispatch to: the server is the ClickHouse
|
||||
// engine (built via CMake), and the only Go in the repo is
|
||||
// cmd/zap-bridge — a SEPARATE per-package Go module
|
||||
// (github.com/hanzoai/datastore/cmd/zap-bridge) built solely by
|
||||
// the datastore Dockerfile's zap-builder stage, not part of this
|
||||
// module graph. Folding it into `hanzo` would mean either cgo-
|
||||
// linking ClickHouse into every Hanzo binary (a non-starter) or
|
||||
// vendoring a second main module (violates one-binary). So
|
||||
// datastore stays its own artifact; `hanzo datastore` documents
|
||||
// that boundary instead of pretending to serve it.
|
||||
return fmt.Errorf(
|
||||
"datastore is a ClickHouse-fork analytics DB, not a Go serve target.\n" +
|
||||
" - server: the ClickHouse engine (CMake build) — run its own image ghcr.io/hanzoai/datastore\n" +
|
||||
" - zap-bridge: github.com/hanzoai/datastore/cmd/zap-bridge is a separate Go module,\n" +
|
||||
" built only by the datastore Dockerfile; it is not linked into hanzo.\n" +
|
||||
" use the standalone datastore deployment; `hanzo` composes the request-tier Go services")
|
||||
|
||||
default:
|
||||
// Registry-backed single-service mode: serve exactly `sub`.
|
||||
// Validate it is a known subsystem before booting anything.
|
||||
if !registryHas(sub) {
|
||||
usage(os.Stderr)
|
||||
return fmt.Errorf("unknown subcommand %q", sub)
|
||||
}
|
||||
return cloud.Serve([]string{sub})
|
||||
}
|
||||
}
|
||||
|
||||
// registryHas reports whether name is a registered subsystem.
|
||||
func registryHas(name string) bool {
|
||||
for _, spec := range cloud.Registry {
|
||||
if spec.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// usage prints the subcommand list: the non-registry targets (cloud, iam,
|
||||
// datastore) plus every subsystem registered into cloud.Registry, sorted.
|
||||
func usage(w *os.File) {
|
||||
fmt.Fprintf(w, "hanzo %s — the unified Hanzo Go binary\n\n", version)
|
||||
fmt.Fprintf(w, "Usage:\n hanzo <command> [flags]\n\n")
|
||||
|
||||
// Control commands (client mode) — manage the live estate. Defined once in
|
||||
// the cli package so this list cannot drift from the router.
|
||||
fmt.Fprintf(w, "Control commands (gcloud/doctl-style):\n")
|
||||
ctrl := cli.ControlCommands()
|
||||
ctrlNames := make([]string, 0, len(ctrl))
|
||||
for name := range ctrl {
|
||||
ctrlNames = append(ctrlNames, name)
|
||||
}
|
||||
sort.Strings(ctrlNames)
|
||||
for _, name := range ctrlNames {
|
||||
fmt.Fprintf(w, " %-12s %s\n", name, ctrl[name])
|
||||
}
|
||||
fmt.Fprintf(w, "\nService subcommands (server mode):\n")
|
||||
|
||||
// Collect: registry names ∪ non-registry names, dedup, sort.
|
||||
seen := map[string]string{}
|
||||
for name, desc := range nonRegistrySubcommands {
|
||||
seen[name] = desc
|
||||
}
|
||||
for _, spec := range cloud.Registry {
|
||||
if _, ok := seen[spec.Name]; !ok {
|
||||
seen[spec.Name] = fmt.Sprintf("serve the %s subsystem standalone", spec.Name)
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(seen))
|
||||
for name := range seen {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
fmt.Fprintf(w, " %-12s %s\n", name, seen[name])
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\nMeta:\n")
|
||||
fmt.Fprintf(w, " %-12s %s\n", "help", "show this message")
|
||||
fmt.Fprintf(w, " %-12s %s\n", "version", "print version and exit")
|
||||
fmt.Fprintf(w, "\nFlags are per-subcommand (e.g. `hanzo cloud --enable=iam,kms --brand=hanzo`,\n")
|
||||
fmt.Fprintf(w, "`hanzo kms --listen=:8443`). Run a subcommand to see its config via env/flags.\n")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Command migrate-pg-to-sqlite copies a legacy `hanzo_cloud`
|
||||
// PostgreSQL database into per-(org, user) SQLite files served by the
|
||||
// Hanzo cloud orchestrator (HIP-0106).
|
||||
//
|
||||
// The migrator is introspective: it reads information_schema for the
|
||||
// table set and columns, then routes each row to its destination file
|
||||
// based on org_id / user_id columns (or canonical fallbacks). Rows
|
||||
// with neither column land under /<root>/_global/_org/cloud.sqlite so
|
||||
// the operator can triage rather than silently dropping them.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// migrate-pg-to-sqlite \
|
||||
// --src 'postgres://cloud:pass@postgres.hanzo.svc:5432/hanzo_cloud?sslmode=disable' \
|
||||
// --dst /data
|
||||
//
|
||||
// Per ~/work/hanzo/CLAUDE_PG_TO_SQLITE_MIGRATION.md (service #4 — cloud).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/migration"
|
||||
)
|
||||
|
||||
func main() {
|
||||
src := flag.String("src", "", "PostgreSQL DSN (postgres://user:pass@host:5432/dbname)")
|
||||
dst := flag.String("dst", "", "Destination data root (default /data)")
|
||||
schemas := flag.String("schemas", "public", "Comma-separated PG schemas to include")
|
||||
excludes := flag.String("exclude", "schema_migrations,knex_migrations", "Comma-separated table basenames to skip")
|
||||
batch := flag.Int("batch", 500, "Insert batch size")
|
||||
flag.Parse()
|
||||
if *src == "" || *dst == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: migrate-pg-to-sqlite --src <pg-dsn> --dst <data-root>")
|
||||
os.Exit(2)
|
||||
}
|
||||
reports, err := migration.Run(context.Background(), migration.Options{
|
||||
SrcDSN: *src,
|
||||
DstRoot: *dst,
|
||||
IncludeSchemas: split(*schemas),
|
||||
ExcludeTables: split(*excludes),
|
||||
BatchSize: *batch,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "migrate-pg-to-sqlite:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("table\tsource\twritten\ttargets")
|
||||
for _, r := range reports {
|
||||
fmt.Printf("%s\t%d\t%d\t%d\n", r.Table, r.SourceRows, r.WrittenRows, len(r.Targets))
|
||||
}
|
||||
}
|
||||
|
||||
func split(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -17,15 +17,49 @@ type Config struct {
|
||||
// Brand is the white-label brand identifier.
|
||||
Brand string
|
||||
|
||||
// Env is the deployment environment (mainnet|testnet|devnet) per the 3-env
|
||||
// split. Billing fires in EVERY env — test/dev meter against their own
|
||||
// sandbox commerce/Square, never free — so Env is an attribution label, not
|
||||
// a gate. Empty when the operator has not set CLOUD_ENV.
|
||||
Env string
|
||||
|
||||
// Domain is the deployment's primary public domain.
|
||||
Domain string
|
||||
|
||||
// IAMIssuer is the JWKS issuer for JWT validation (usually iam.hanzo.id).
|
||||
// IAMIssuer is the JWKS issuer for JWT validation (usually iam.hanzo.ai).
|
||||
IAMIssuer string
|
||||
|
||||
// KMSMasterKeyRef points at the KMS master key for per-tenant DEK derivation.
|
||||
// AdminOrg is the IAM org slug whose members are GLOBAL admins (IAM's
|
||||
// IsGlobalAdmin: owner == AdminOrg). The in-binary identity sanitizer grants
|
||||
// admin authority — the c.IsAdmin() that gates /v1/admin/* writes, the
|
||||
// /v1/pricing/sync trigger, and the literal "admin" tenant bucket — ONLY to a
|
||||
// validated principal from this org, never to a raw header. Env IAM_ADMIN_ORG
|
||||
// (default "admin"), matching the gateway's admin-guard.
|
||||
AdminOrg string
|
||||
|
||||
// JWKSURL is the JSON Web Key Set endpoint the identity sanitizer fetches IAM
|
||||
// signing keys from. Defaults to {IAMIssuer}/v1/iam/.well-known/jwks
|
||||
// (HIP-0111); override with CLOUD_JWKS_URL.
|
||||
JWKSURL string
|
||||
|
||||
// JWTAudiences is the audience allowlist the sanitizer accepts (OR semantics).
|
||||
// Defaults to the known Hanzo IAM client_ids; override with CLOUD_JWT_AUDIENCES
|
||||
// (comma-separated) or GATEWAY_ALLOWED_AUDIENCES.
|
||||
JWTAudiences []string
|
||||
|
||||
// KMSMasterKeyRef is the base64-encoded 32-byte KMS master key (KEK) the
|
||||
// embedded luxfi/kms store seals every secret's DEK under. The operator
|
||||
// injects it from a K8s Secret as CLOUD_KMS_MASTER_KEY_REF; cloud reads it
|
||||
// ONLY from env (never from the store it hosts — the bootstrap chicken-and-egg)
|
||||
// and never logs it. Empty ⇒ the KMS subsystem runs fail-closed (health-only).
|
||||
KMSMasterKeyRef string
|
||||
|
||||
// KMSMPCAddr / KMSMPCVaultID configure the MPC threshold-signing backend for
|
||||
// KMS Sign. Both empty (the default) ⇒ Sign fails closed with a clear error;
|
||||
// signing is never fabricated. Set via CLOUD_KMS_MPC_ADDR / CLOUD_KMS_MPC_VAULT_ID.
|
||||
KMSMPCAddr string
|
||||
KMSMPCVaultID string
|
||||
|
||||
// DataDir is the on-disk data root.
|
||||
DataDir string
|
||||
|
||||
@@ -35,6 +69,11 @@ type Config struct {
|
||||
// ZAPListenAddr is the ZAP-RPC listener (default :9653).
|
||||
ZAPListenAddr string
|
||||
|
||||
// ZAPWebOrigins is the WebSocket Origin allowlist for the browser-facing
|
||||
// /zap ZAP plane (the SPA hosts that may open a ZAP-over-WS connection).
|
||||
// Empty == same-origin only. Set via CLOUD_ZAP_WEB_ORIGINS (comma-sep).
|
||||
ZAPWebOrigins []string
|
||||
|
||||
// HealthListenAddr is the health/metrics listener (default :9090).
|
||||
HealthListenAddr string
|
||||
|
||||
@@ -46,6 +85,39 @@ type Config struct {
|
||||
// service-discovery resolution.
|
||||
PaymentsZAPAddr string
|
||||
VaultZAPAddr string
|
||||
|
||||
// Billing gate (commerce metering) — the request-edge balance gate.
|
||||
//
|
||||
// CommerceHTTPURL is the commerce service base over HTTP (the metering
|
||||
// client speaks net/http, not ZAP). Empty disables the gate entirely.
|
||||
//
|
||||
// CommerceServiceToken is the admin-scoped commerce S2S token. It is a
|
||||
// SECRET sourced from a KMS-backed secret the operator injects as
|
||||
// COMMERCE_SERVICE_TOKEN — never hard-coded or read from disk here.
|
||||
//
|
||||
// BillingFailOpen flips the gate to allow-on-error. Default is
|
||||
// fail-closed (deny when balance can't be determined), matching the
|
||||
// gateway. Set only where availability outranks billing.
|
||||
CommerceHTTPURL string
|
||||
CommerceServiceToken string
|
||||
BillingFailOpen bool
|
||||
|
||||
// ZAP RPC endpoints for subsystems that are NOT enabled in this
|
||||
// process but are still needed by an enabled subsystem. Empty
|
||||
// means "no remote endpoint" — the client falls back to the
|
||||
// disabled stub which fails closed with a clear error.
|
||||
//
|
||||
// Convention: <subsystem>.<env>.<deployment>.svc:9653 — the same
|
||||
// inter-subsystem listener port the unified binary exposes. The
|
||||
// transport is hanzoai/zap, never JSON.
|
||||
IAMZAPAddr string
|
||||
KMSZAPAddr string
|
||||
BaseZAPAddr string
|
||||
CommerceZAPAddr string
|
||||
AIZAPAddr string
|
||||
O11yZAPAddr string
|
||||
VFSZAPAddr string
|
||||
MQZAPAddr string
|
||||
}
|
||||
|
||||
// LoadConfig reads flags + env into a Config. Flags override env.
|
||||
@@ -55,13 +127,31 @@ func LoadConfig() *Config {
|
||||
ZAPListenAddr: getenv("CLOUD_ZAP_LISTEN", ":9653"),
|
||||
HealthListenAddr: getenv("CLOUD_HEALTH_LISTEN", ":9090"),
|
||||
AdminListenAddr: getenv("CLOUD_ADMIN_LISTEN", ":8081"),
|
||||
Brand: getenv("CLOUD_BRAND", "hanzo"),
|
||||
Brand: getenv("CLOUD_BRAND", DefaultBrand),
|
||||
Env: getenv("CLOUD_ENV", ""),
|
||||
Domain: getenv("CLOUD_DOMAIN", "api.hanzo.ai"),
|
||||
IAMIssuer: getenv("CLOUD_IAM_ISSUER", "https://iam.hanzo.id"),
|
||||
KMSMasterKeyRef: getenv("CLOUD_KMS_MASTER_KEY_REF", ""),
|
||||
DataDir: getenv("CLOUD_DATA_DIR", "/var/lib/cloud"),
|
||||
PaymentsZAPAddr: getenv("CLOUD_PAYMENTS_ZAP_ADDR", ""),
|
||||
VaultZAPAddr: getenv("CLOUD_VAULT_ZAP_ADDR", ""),
|
||||
// IAMIssuer left empty here; resolved from Brand below unless pinned.
|
||||
IAMIssuer: getenv("CLOUD_IAM_ISSUER", ""),
|
||||
AdminOrg: getenv("IAM_ADMIN_ORG", "admin"),
|
||||
JWKSURL: getenv("CLOUD_JWKS_URL", ""),
|
||||
KMSMasterKeyRef: getenv("CLOUD_KMS_MASTER_KEY_REF", ""),
|
||||
KMSMPCAddr: getenv("CLOUD_KMS_MPC_ADDR", ""),
|
||||
KMSMPCVaultID: getenv("CLOUD_KMS_MPC_VAULT_ID", ""),
|
||||
DataDir: getenv("CLOUD_DATA_DIR", "/var/lib/cloud"),
|
||||
PaymentsZAPAddr: getenv("CLOUD_PAYMENTS_ZAP_ADDR", ""),
|
||||
VaultZAPAddr: getenv("CLOUD_VAULT_ZAP_ADDR", ""),
|
||||
// Billing gate (KMS-backed COMMERCE_SERVICE_TOKEN; never plaintext).
|
||||
CommerceHTTPURL: getenv("CLOUD_COMMERCE_HTTP_URL", ""),
|
||||
CommerceServiceToken: getenv("COMMERCE_SERVICE_TOKEN", ""),
|
||||
BillingFailOpen: getenvBool("BILLING_FAIL_OPEN"),
|
||||
IAMZAPAddr: getenv("CLOUD_IAM_ZAP_ADDR", ""),
|
||||
KMSZAPAddr: getenv("CLOUD_KMS_ZAP_ADDR", ""),
|
||||
BaseZAPAddr: getenv("CLOUD_BASE_ZAP_ADDR", ""),
|
||||
CommerceZAPAddr: getenv("CLOUD_COMMERCE_ZAP_ADDR", ""),
|
||||
AIZAPAddr: getenv("CLOUD_AI_ZAP_ADDR", ""),
|
||||
O11yZAPAddr: getenv("CLOUD_O11Y_ZAP_ADDR", ""),
|
||||
VFSZAPAddr: getenv("CLOUD_VFS_ZAP_ADDR", ""),
|
||||
MQZAPAddr: getenv("CLOUD_MQ_ZAP_ADDR", ""),
|
||||
}
|
||||
|
||||
var enableCSV string
|
||||
@@ -81,6 +171,32 @@ func LoadConfig() *Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// White-label by brand (HIP-0111): when the operator does not pin
|
||||
// CLOUD_IAM_ISSUER / --iam-issuer, derive the canonical OIDC issuer from the
|
||||
// brand so a lux deployment validates against lux.id, zoo against zoo.id,
|
||||
// etc. — never silently defaulting every brand to iam.hanzo.ai.
|
||||
if cfg.IAMIssuer == "" {
|
||||
cfg.IAMIssuer = IssuerForBrand(cfg.Brand)
|
||||
}
|
||||
|
||||
// JWKS endpoint for the in-binary identity sanitizer. Default follows the
|
||||
// HIP-0111 convention {IAMIssuer}/v1/iam/.well-known/jwks so a brand
|
||||
// deployment validates against its own IAM; override with CLOUD_JWKS_URL.
|
||||
if cfg.JWKSURL == "" {
|
||||
cfg.JWKSURL = strings.TrimRight(cfg.IAMIssuer, "/") + "/v1/iam/.well-known/jwks"
|
||||
}
|
||||
cfg.JWTAudiences = jwtAudiencesFromEnv()
|
||||
|
||||
// Browser ZAP-over-WS Origin allowlist. Default to the console SPA hosts so
|
||||
// console2 can connect cross-origin; override with CLOUD_ZAP_WEB_ORIGINS.
|
||||
zapOrigins := getenv("CLOUD_ZAP_WEB_ORIGINS",
|
||||
"console2.hanzo.ai,console.hanzo.ai,cloud.hanzo.ai,localhost:4000")
|
||||
for _, o := range strings.Split(zapOrigins, ",") {
|
||||
if s := strings.TrimSpace(o); s != "" {
|
||||
cfg.ZAPWebOrigins = append(cfg.ZAPWebOrigins, s)
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -105,6 +221,60 @@ func getenv(key, dflt string) string {
|
||||
return dflt
|
||||
}
|
||||
|
||||
// defaultJWTAudiences mirrors github.com/hanzoai/gateway/v2/iamauth.DefaultAudiences
|
||||
// (the known Hanzo IAM client_ids — each app's `aud` is its client_id) plus
|
||||
// hanzo-cloud, cloud's own session client. Forwards-only: append new client_ids,
|
||||
// never remove. Non-hanzo brands set CLOUD_JWT_AUDIENCES to their own client_ids.
|
||||
var defaultJWTAudiences = []string{
|
||||
"hanzo-app",
|
||||
"hanzo-console",
|
||||
"hanzo-chat",
|
||||
"hanzo-id",
|
||||
"hanzo-cloud",
|
||||
"cowork",
|
||||
"https://api.hanzo.ai",
|
||||
}
|
||||
|
||||
// jwtAudiencesFromEnv resolves the JWT audience allowlist for the identity
|
||||
// sanitizer. CLOUD_JWT_AUDIENCES wins; GATEWAY_ALLOWED_AUDIENCES (the gateway's
|
||||
// own override, shared so both agree) is honored next; otherwise the baked
|
||||
// default. Never empty, so the audience check is always enforced.
|
||||
func jwtAudiencesFromEnv() []string {
|
||||
for _, key := range []string{"CLOUD_JWT_AUDIENCES", "GATEWAY_ALLOWED_AUDIENCES"} {
|
||||
if list := splitTrim(os.Getenv(key)); len(list) > 0 {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append([]string(nil), defaultJWTAudiences...)
|
||||
}
|
||||
|
||||
// splitTrim splits a comma-separated list, trimming and dropping empties.
|
||||
func splitTrim(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getenvBool reports whether an env var is set to a truthy value
|
||||
// (true/1/yes, case-insensitive). Matches metering's envTrue semantics so the
|
||||
// billing flags read consistently across products.
|
||||
func getenvBool(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate returns an error if the config is missing required values.
|
||||
func (c *Config) Validate() error {
|
||||
if c.Brand == "" {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Deploying hanzoai/cloud
|
||||
|
||||
Reference deployment manifests for the unified Hanzo Cloud binary
|
||||
(HIP-0106). Each manifest demonstrates a different deployment topology.
|
||||
|
||||
| Manifest | Topology | Use case |
|
||||
|----------|----------|----------|
|
||||
| `compose.yml` | single-node Docker | VPS, dev, demo |
|
||||
|
||||
Coming soon: `kustomize/` and `helm/` for k8s deploys (see luxfi/operator
|
||||
for the canonical CRD-driven shape).
|
||||
|
||||
## Quick start (Docker Compose)
|
||||
|
||||
```bash
|
||||
cp deploy/compose.env.example deploy/compose.env
|
||||
# edit HANZO_BRAND / HANZO_DOMAIN / HANZO_IAM_ISSUER as needed
|
||||
docker compose -f deploy/compose.yml --env-file deploy/compose.env up -d
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Required:
|
||||
- `HANZO_IAM_ISSUER` — OIDC issuer URL. Without it the IAM subsystem
|
||||
refuses to mount and the binary exits.
|
||||
|
||||
Optional (defaults shown):
|
||||
- `HANZO_BRAND=hanzo`
|
||||
- `HANZO_DOMAIN=api.hanzo.local`
|
||||
- `HANZO_ENABLE=iam,base,kms,gateway,o11y`
|
||||
- `HANZO_DATA_DIR=/var/lib/cloud`
|
||||
|
||||
The full subsystem list is in `cmd/cloud/main.go`. Per HIP-0106
|
||||
payments and vault NEVER co-resident — leave them off this binary
|
||||
unless you understand the PCI scope implications.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user