the dashboard can be mounted, not only run

It was package main, so nothing could import it — and that is why this
surface is the one a valid hanzo.id token cannot read. api.hanzo.ai
already carries /v1/deploy, the CD half of this same delivery plane,
behind the gateway's IAM identity; the CI half sits behind admin-guard
instead, which trusts an X-Org-Id header and answers a bearer with a
bare 401. A capability can be mounted; a package main can only be
deployed beside things.

Serve builds the handler and starts the pollers, taking a context so the
caller owns their lifetime. Both caches answer "no snapshot yet" rather
than blocking, so a mount never waits on an upstream fetch. Main keeps
the standalone server — listener and shutdown — and cmd/ci is four lines
over it, so the image is unchanged apart from the path it builds.

Main, not Run: a Run in this package is a CI run, the domain object, and
the binary's body is not one.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-30 17:55:42 -07:00
co-authored by Hanzo Dev
parent b15622b109
commit 1b90a652ad
15 changed files with 57 additions and 27 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ RUN --mount=type=cache,id=ci-gomod,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,id=ci-gomod,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /build/ci .
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /build/ci ./cmd/ci
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata \
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"fmt"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
_ "embed"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"context"
+12
View File
@@ -0,0 +1,12 @@
// Command ci runs the fleet dashboard as its own server.
//
// The surface itself lives in the package, not here, so it can be MOUNTED as
// well as run — hanzoai/cloud carries /v1/deploy behind the gateway's IAM
// identity, and this is the CI half of that same delivery plane. A package main
// cannot be imported by anything, which is why this surface was the one a valid
// hanzo.id token could not read.
package main
import ci "hanzo.ai/ci"
func main() { ci.Main() }
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import "strings"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import "testing"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"context"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"testing"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"context"
+32 -14
View File
@@ -22,7 +22,7 @@
// by org, IAM issues that slug in the `owner` claim, and Hanzo CD fences
// projects by it. Filtering here by `org` is therefore the same boundary those
// enforce, not a parallel notion of who-sees-what.
package main
package ci
import (
"context"
@@ -43,15 +43,17 @@ import (
"time"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
cfg, err := loadConfig()
if err != nil {
logger.Error("config", "err", err)
os.Exit(1)
}
// Serve builds the dashboard's handler and starts the pollers that feed it.
//
// Exported so this surface can be MOUNTED as well as run: hanzoai/cloud carries
// /v1/deploy behind the gateway's IAM identity, and this is the CI half of that
// same delivery plane. A package main cannot be mounted by anything, which is
// why it was the one surface a valid hanzo.id token could not read.
//
// The caller owns the lifetime: cancelling ctx stops the pollers. The returned
// handler is safe to serve immediately — both caches answer "no snapshot yet"
// rather than blocking, so a mount never waits on an upstream fetch.
func Serve(ctx context.Context, logger *slog.Logger, cfg config) http.Handler {
src := &gitSource{base: cfg.gitBase, token: cfg.gitToken, http: &http.Client{Timeout: 20 * time.Second}}
cache := &runCache{}
board := &fleetCache{}
@@ -65,9 +67,6 @@ func main() {
logger.Warn("cluster identity", "err", err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// One poller, one cache. Every viewer reads the same snapshot, so N open
// dashboards cost Hanzo Git exactly as much as one — a dashboard that
// fanned each page load into upstream calls is how a status page takes the
@@ -75,9 +74,28 @@ func main() {
go poll(ctx, logger, src, cache, cfg)
go watch(ctx, logger, src, cl, board, cfg)
return routes(cfg, cache, board)
}
// Main is the standalone server: the same handler, with a listener and shutdown
// around it. cmd/ci is a thin wrapper over this. Named Main rather than Run
// because a Run in this package is a CI run — the domain object — and the
// binary's body is not one.
func Main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
cfg, err := loadConfig()
if err != nil {
logger.Error("config", "err", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: cfg.listen,
Handler: routes(cfg, cache, board),
Handler: Serve(ctx, logger, cfg),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"fmt"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"crypto/sha256"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"net/http"
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package ci
import (
"encoding/json"