mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
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:
+23
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ./...
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user