aml: the API and the console are two deploy units

The daemon was serving its own dashboard from /_/aml/ inside the same pod
and the same image. That is one host doing two jobs: the API belongs at
api.<brand>/v1/aml, one door for data, and a console belongs on its own
host as a compiled bundle. Sharing a pod shares a blast radius and a
rollout, and the embedded copy was unreachable from outside in any case.

ghcr.io/luxfi/aml is now the daemon alone — no UI stage, no embedui tag, no
embed seam. ghcr.io/luxfi/aml-ui is ghcr.io/hanzoai/static with the bundle
in /public and -spa, so a hard refresh on /cases resolves. Both are built
by the release workflow under one tag, so a version names one state of the
product; the console's smoke proves it serves, that SPA_* reaches
config.json, and that a deep link falls back to index.html. CI typechecks
and builds the bundle on every branch, so a type error fails before a
release rather than during one.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-30 17:58:01 -07:00
co-authored by Hanzo Dev
parent 7aa70719c7
commit a80bbcf3b4
5 changed files with 149 additions and 34 deletions
+23 -3
View File
@@ -69,9 +69,8 @@ jobs:
run: go test -race -count=1 ./...
- name: Build the daemon
# No embedui tag here: the embedded operator surface needs ui/dist, which
# is built in the Dockerfile's UI stage, not committed. CI proves the Go
# code compiles and runs; the release build proves the embedded UI.
# The daemon is the API and nothing else — the console is a separate
# artifact with its own job below, so this needs no Node toolchain.
run: |
CGO_ENABLED=0 go build -trimpath -o /tmp/amld ./cmd/amld
test -x /tmp/amld
@@ -108,3 +107,24 @@ jobs:
exit 1
fi
echo "serves, and refuses an unauthenticated read"
console:
runs-on: [lux-build-linux-amd64]
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install from the lockfile
# `npm ci` fails on drift rather than resolving fresh, so what CI builds
# is what the image builds.
run: npm --prefix ui ci
- name: Typecheck
run: npm --prefix ui exec tsc -- --noEmit
- name: Build the bundle
run: |
npm --prefix ui run build
test -f ui/dist/index.html
+110 -1
View File
@@ -1,5 +1,8 @@
name: Release
# Builds ghcr.io/luxfi/aml on the native runners (git.hanzo.ai → git-runner).
# Builds ghcr.io/luxfi/aml and ghcr.io/luxfi/aml-ui on the native runners
# (git.hanzo.ai → git-runner). Two images because they are two deploy units: the
# daemon answers api.<brand>/v1/aml, the console is a static bundle on its own
# host. They share a tag so a release names one state of the product.
#
# A tag push vX.Y.Z is a release: the image carries the semver tags plus the sha
# tag; a main push carries only the sha tag. The image must pass the smoke below
@@ -174,3 +177,109 @@ jobs:
- name: Note when the sovereign copy was skipped
if: ${{ secrets.OCI_USER == '' || secrets.OCI_TOKEN == '' }}
run: echo "::warning::oci.hanzo.ai copy SKIPPED — OCI_USER/OCI_TOKEN not provisioned on this org. Canonical ghcr.io/luxfi/aml image is published; provision the IAM registry credential to enable the sovereign copy."
console:
# The console: the same bundle every brand is served, with its deployment's
# API and clientId templated into /config.json at container start. It holds
# no secret and cannot — it is a static file anyone may read.
runs-on: [lux-build-linux-amd64]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Compute tags
id: meta
run: |
set -euo pipefail
SHA_TAG="ghcr.io/luxfi/aml-ui:sha-${GITHUB_SHA:0:7}"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
{
echo "tags<<EOF"
echo "ghcr.io/luxfi/aml-ui:v${VERSION}"
echo "ghcr.io/luxfi/aml-ui:${VERSION}"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "probe=ghcr.io/luxfi/aml-ui:v${VERSION}" >> "$GITHUB_OUTPUT"
else
{
echo "tags<<EOF"
echo "$SHA_TAG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "probe=$SHA_TAG" >> "$GITHUB_OUTPUT"
fi
- name: Typecheck and build the bundle
# The type error is caught here, before an image exists to deploy.
run: |
set -euo pipefail
npm --prefix ui ci
npm --prefix ui run build
test -f ui/dist/index.html
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build
run: |
set -euo pipefail
args=""
while IFS= read -r t; do [ -n "$t" ] && args="$args -t $t"; done <<< "${{ steps.meta.outputs.tags }}"
docker build $args -f ui/Dockerfile ui/
- name: Smoke — it serves, it templates its config, and it answers deep links
# Three facts, each of which has to hold for the console to work at all:
# the bundle is served, the deployment's API is templated into
# /config.json from SPA_* env, and an unknown path returns index.html so
# a refresh on /cases does not 404.
run: |
set -euo pipefail
img="${{ steps.meta.outputs.probe }}"
cid=$(docker run -d -e SPA_API=https://api.hanzo.ai -e SPA_CLIENT_ID=hanzo-aml \
-p 127.0.0.1:8798:3000 "$img")
trap 'docker logs "$cid" | tail -40; docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
ready=""
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://127.0.0.1:8798/healthz; then ready=1; break; fi
sleep 1
done
[ -n "$ready" ] || { echo "::error::the console never answered /healthz"; exit 1; }
curl -sf http://127.0.0.1:8798/ | grep -q '<div id="root">' \
|| { echo "::error::/ did not serve the bundle"; exit 1; }
cfg=$(curl -sf http://127.0.0.1:8798/config.json)
echo "$cfg" | grep -q '"api":"https://api.hanzo.ai"' \
|| { echo "::error::SPA_API was not templated into config.json: $cfg"; exit 1; }
echo "$cfg" | grep -q '"clientId":"hanzo-aml"' \
|| { echo "::error::SPA_CLIENT_ID was not templated into config.json: $cfg"; exit 1; }
curl -sf http://127.0.0.1:8798/cases/anything | grep -q '<div id="root">' \
|| { echo "::error::a deep link did not fall back to index.html"; exit 1; }
echo "serves, templates its config, and answers deep links"
- name: Push
run: |
set -euo pipefail
while IFS= read -r t; do
[ -n "$t" ] && docker push "$t"
done <<< "${{ steps.meta.outputs.tags }}"
- name: Verify the pushed manifest resolves
run: |
set -euo pipefail
img="${{ steps.meta.outputs.probe }}"
for i in 1 2 3 4 5 6; do
if docker manifest inspect "$img" >/dev/null 2>&1; then
echo "$img is resolvable"; exit 0
fi
echo "manifest not visible yet (attempt ${i}/6) — retrying"; sleep 5
done
echo "::error::$img not pullable after push"; exit 1
+6 -16
View File
@@ -1,15 +1,6 @@
# Stage 1: Build UI (Vite + React + @hanzo/gui)
# pnpm must match pnpm-lock.yaml's lockfileVersion (9.0), and --frozen-lockfile
# is mandatory so lockfile drift fails the build instead of resolving fresh.
FROM node:22-alpine AS ui
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /ui
COPY ui/package.json ui/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY ui/ .
RUN pnpm build
# Stage 2: Build Go binary with embedded UI
# The API daemon. The console is a separate image built from ui/Dockerfile and
# served on its own host — see ui/Dockerfile and LLM.md.
#
# Every dependency resolves through the default module proxy and is checked
# against the default checksum database, so a tag that moves after the fact
# cannot change what this image contains: proxy.golang.org serves the zip it
@@ -24,16 +15,15 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui /ui/dist ./ui/dist/
RUN CGO_ENABLED=0 go build -trimpath -tags embedui \
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" -o /app/amld ./cmd/amld/
# Third-party license and notice texts for the binary just built, derived from
# its module graph while the module cache is still present.
RUN CGO_ENABLED=0 go run ./internal/notice -tags embedui -pkg ./cmd/amld -name amld \
RUN CGO_ENABLED=0 go run ./internal/notice -pkg ./cmd/amld -name amld \
-o /app/THIRD-PARTY-NOTICES
# Stage 3: Runtime
# Runtime
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /app/amld /app/amld
+10 -11
View File
@@ -4,23 +4,22 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
NOTICE := THIRD-PARTY-NOTICES
# The product binary embeds the admin dashboard, so it always builds the UI
# first. Bare `go build`/`go vet`/`go test`/`go install` need no UI: without
# the embedui tag ui/embed.go supplies a placeholder tree.
build: ui notice
CGO_ENABLED=0 go build -trimpath -tags embedui $(LDFLAGS) -o amld ./cmd/amld/
# The daemon is the API. The console is a separate artifact with its own image
# (ui/Dockerfile), so nothing here waits on a bundle.
build: notice
CGO_ENABLED=0 go build -trimpath $(LDFLAGS) -o amld ./cmd/amld/
# The console, for a local look. The image build runs the same two commands.
ui:
pnpm --dir ui install --frozen-lockfile
pnpm --dir ui build
npm --prefix ui ci
npm --prefix ui run build
# Third-party license and notice texts for the binary, derived from its module
# graph. Generated with the same tags and CGO setting the product ships with, so
# it describes the artifact that is distributed and not some other graph — which
# also means dist/ has to exist first for the embed directive to resolve. No
# it describes the artifact that is distributed and not some other graph. No
# -platforms: this target builds one binary, for this host.
notice: ui
CGO_ENABLED=0 go run ./internal/notice -tags embedui -pkg ./cmd/amld -name amld -o $(NOTICE)
notice:
CGO_ENABLED=0 go run ./internal/notice -pkg ./cmd/amld -name amld -o $(NOTICE)
test:
go test -race -count=1 ./...
-3
View File
@@ -21,7 +21,6 @@ import (
"time"
"github.com/hanzoai/base"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/hook"
"github.com/spf13/cobra"
@@ -37,7 +36,6 @@ import (
"github.com/luxfi/aml/pkg/screen"
"github.com/luxfi/aml/pkg/token"
"github.com/luxfi/aml/pkg/velocity"
uiaml "github.com/luxfi/aml/ui"
)
var version = "(dev)"
@@ -198,7 +196,6 @@ func main() {
// Destroy records whose retention period has run out, daily.
retention.Cron(app, records)
se.Router.GET("/_/aml/{path...}", apis.Static(uiaml.DistDirFS(), true))
return se.Next()
},
})