Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f44cb4a03 |
@@ -1,59 +0,0 @@
|
||||
name: imgver
|
||||
description: The semver an image build publishes. We don't ship shas.
|
||||
# For the repos that build images from a hand-rolled .hanzo/workflows/deploy.yml
|
||||
# instead of importing hanzoai/ci's build.yml. Those workflows each carry their
|
||||
# own `tag=sha-$(echo $GITHUB_SHA | cut -c1-7)` line, which is why 14 of the
|
||||
# fleet's 117 pins named a commit rather than a release. Replace that line with:
|
||||
#
|
||||
# - id: ver
|
||||
# uses: hanzoai/ci/.github/actions/imgver@v1
|
||||
# with: { repo: ghcr.io/hanzoai/<name> }
|
||||
# env: { GH_PAT: '${{ secrets.GH_PAT }}' }
|
||||
# ...
|
||||
# tags: ghcr.io/hanzoai/<name>:${{ steps.ver.outputs.version }}
|
||||
#
|
||||
# and keep the sha tag alongside if you want the forensics. The version is the
|
||||
# one universe PINS. Same bin/imgver build.yml runs — one implementation.
|
||||
inputs:
|
||||
repo:
|
||||
description: Image repository, e.g. ghcr.io/hanzoai/iam
|
||||
required: true
|
||||
context:
|
||||
description: Build context, where the version manifest is looked for first
|
||||
required: false
|
||||
default: .
|
||||
version:
|
||||
description: >-
|
||||
Override the declared version: a literal x.y.z, or "<file>:<command
|
||||
printing it>". Defaults to the repo's package.json / Cargo.toml / VERSION
|
||||
/ pyproject.toml.
|
||||
required: false
|
||||
default: ''
|
||||
outputs:
|
||||
version:
|
||||
description: The semver to publish and to pin
|
||||
value: ${{ steps.run.outputs.version }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Fetch imgver
|
||||
shell: bash
|
||||
# The action ref is the script ref: an action pinned to @v1 runs v1's
|
||||
# imgver. Both forges, because this repo is served from each.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ref="${GITHUB_ACTION_REF:-v1}"
|
||||
for url in https://github.com/hanzoai/ci https://git.hanzo.ai/hanzo/ci; do
|
||||
git clone -q --depth 1 --branch "$ref" "$url" "$RUNNER_TEMP/imgver-ci" 2>/dev/null && break
|
||||
done
|
||||
[ -x "$RUNNER_TEMP/imgver-ci/bin/imgver" ] \
|
||||
|| { echo "::error::could not fetch hanzoai/ci@$ref (bin/imgver)"; exit 1; }
|
||||
- id: run
|
||||
shell: bash
|
||||
env:
|
||||
IMGVER_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$("$RUNNER_TEMP/imgver-ci/bin/imgver" '${{ inputs.repo }}' '${{ inputs.context }}')
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "$v"
|
||||
+47
-1148
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
# `go build ./...` writes the binary into the package directory, named after the
|
||||
# package — which is how a 12MB darwin/arm64 `ci` came to be committed here, in
|
||||
# a repo whose image is built linux/amd64 from source by the Dockerfile. The
|
||||
# artifact is never an input to anything; only the source is.
|
||||
/ci
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
name: CI/CD
|
||||
# The gate for hanzoai/ci itself, on our own runners against git.hanzo.ai.
|
||||
#
|
||||
# THE LAW: `.github/workflows` runs zero CI; everything that gates or builds
|
||||
# lives here. All real config is the repo-root hanzo.yml — this file is the
|
||||
# ~7-line caller, exactly like cloud/commerce/console/git.
|
||||
#
|
||||
# Self-referential on purpose: this repo's dashboard image is built by this
|
||||
# repo's own reusable pipeline, pinned at the @v2 TAG rather than at the working
|
||||
# tree. That pin is what keeps a broken edit to build.yml from also breaking the
|
||||
# build that would have caught it — the tag moves only when a release is cut.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A v* tag is what produces a published, immutable image tag (branch pushes
|
||||
# only ever yield sha-<sha7>). Without this trigger a release tag builds
|
||||
# nothing at all and the CR has no version to pin.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: cicd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
gate:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
|
||||
secrets: inherit
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# ci — the ci.hanzo.ai dashboard. Pure-Go, no cgo, no node: the page is
|
||||
# server-rendered from a template compiled into the binary, and the design
|
||||
# tokens it spends are @hanzo/brand's published stylesheet, go:embed-ed beside
|
||||
# it. So the image is still the binary and a CA bundle — nothing served from
|
||||
# disk, nothing to go stale against the code, and no JS toolchain on the path
|
||||
# that ships the board you read when the builds are broken.
|
||||
FROM golang:1.26.5-alpine AS builder
|
||||
WORKDIR /build
|
||||
# Resolve through the module proxy: proxy.golang.org and sum.golang.org agree
|
||||
# and neither can change under us, which a direct fetch against a moved tag
|
||||
# cannot promise.
|
||||
ENV GOPROXY=https://proxy.golang.org,direct
|
||||
# The base image above is pinned to exactly the Go go.mod asks for, so nothing
|
||||
# is downloaded here — the pin is what makes this build hermetic. GOTOOLCHAIN
|
||||
# is set to auto anyway, because the golang images default it to `local` and
|
||||
# that turns the NEXT go.mod bump from "fetches the toolchain it needs" into
|
||||
# "dies mid-build with go.mod requires go >= X". The pin is the fast path; this
|
||||
# is the one that keeps a version bump from being a build break. bin/gover
|
||||
# gates the same rule for every repo this pipeline builds.
|
||||
ENV GOTOOLCHAIN=auto
|
||||
COPY go.mod ./
|
||||
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 .
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S hanzo && adduser -S hanzo -G hanzo
|
||||
COPY --from=builder /build/ci /app/ci
|
||||
USER hanzo
|
||||
EXPOSE 8080
|
||||
# Liveness only. Readiness deliberately does not gate on having a snapshot — see
|
||||
# the /healthz comment in main.go: a Hanzo Git outage must render as a dashboard
|
||||
# saying so, not as this pod leaving the load balancer as well.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||
ENTRYPOINT ["/app/ci"]
|
||||
@@ -40,144 +40,10 @@ jobs:
|
||||
|
||||
That's it. The build/test/deploy logic lives here, once.
|
||||
|
||||
## `client:` — one document, eight generated clients
|
||||
## Runners — our cloud or your own
|
||||
|
||||
A generated SDK is a **projection** of one API document at one version. This lane
|
||||
is the only place in the fleet that says how a projection is made, so the eight
|
||||
client repos (`python-sdk`, `js-sdk`, `go-sdk`, `rust-sdk`, `java-sdk`,
|
||||
`kotlin-sdk`, `cpp-sdk`, `cli`) stop carrying eight copies of the same eight
|
||||
lines.
|
||||
|
||||
```yaml
|
||||
client:
|
||||
spec: { repo: hanzoai/cloud, path: openapi.yaml } # these are the defaults
|
||||
generate: ./scripts/generate.sh # $SPEC is the fetched document
|
||||
version: 'package.json:jq -r .version package.json' # optional; see below
|
||||
```
|
||||
|
||||
There is deliberately **no `build:`**. The repo already declared how it proves
|
||||
itself, in `test:`, and that block runs over the regenerated tree — which is
|
||||
exactly the gate. A second declaration would be one assertion written twice.
|
||||
|
||||
It fires on `repository_dispatch: spec-update`, which **hanzoai/cloud sends once
|
||||
per release**:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
repository_dispatch: { types: [spec-update] }
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
The coupler is the document, **passed by value at a pinned ref**. The payload
|
||||
carries `(version, sha, spec_sha256)`; the lane fetches `openapi.yaml` at that
|
||||
sha and refuses if the bytes hash to anything else — every projection of one
|
||||
release is generated from one digest. Reading a live host instead would be a lie
|
||||
about which deploy the client describes.
|
||||
|
||||
Three gates, in order:
|
||||
|
||||
| gate | refuses |
|
||||
|---|---|
|
||||
| digest | a client generated from a different document than its siblings |
|
||||
| `test:` | a spec change that produces a client which does not compile — **including its examples** |
|
||||
| `.spec-lock` | is committed beside the code: `ref` + `sha256`, so anyone can ask a client repo *which document are you?* without running a generator |
|
||||
|
||||
On a delta — and only after `test:` has passed over exactly those bytes — the
|
||||
lane commits the projection, bumps the **patch** (derived, never typed: a
|
||||
projection never earns a minor or a major) and pushes the tag. The repo's own tag
|
||||
lane publishes it, so the registry credential stays where the publish is.
|
||||
|
||||
`version:` says **where this client's version lives**, because that answer is
|
||||
genuinely different per language:
|
||||
|
||||
| value | meaning |
|
||||
|---|---|
|
||||
| `"<file>:<command printing it>"` | it lives in a file — rewrite it, commit, tag |
|
||||
| `tag` | the tag **is** the version (a Go module has nothing to rewrite) |
|
||||
| absent | CI cannot derive one — the projection is committed and gated, nothing is cut |
|
||||
|
||||
The third state is not a gap to fill later. A repo whose version is not `x.y.z`
|
||||
(a `-alpha.N` gradle build) has no patch for this lane to derive, and guessing
|
||||
one would tag bytes under a number nobody chose.
|
||||
|
||||
Credential: **`SPEC_TOKEN`** — a fine-grained token with `contents:read` on the
|
||||
spec repo.
|
||||
|
||||
## `binaries:` — publish a plugin once, install it everywhere
|
||||
|
||||
`images:` ships an OCI image a **cluster** runs. `binaries:` ships an
|
||||
executable a **running host** installs: a [zip](https://github.com/zap-proto/zip)
|
||||
plugin, fetched at run time by URL and verified against its SHA-256 before it is
|
||||
ever made executable. Build it once per OS/arch here; every host picks up the
|
||||
same bits, and nobody rebuilds the world to ship a plugin.
|
||||
|
||||
```yaml
|
||||
binaries:
|
||||
- name: billing
|
||||
main: ./cmd/billing # the Go package; default "."
|
||||
platforms: [linux/amd64, linux/arm64] # default [linux/amd64]
|
||||
ldflags: "-s -w" # default
|
||||
```
|
||||
|
||||
`main:` is the zero-config **Go** lane. Every other toolchain uses the same block
|
||||
with `run:` (the command that builds) and `out:` (the glob of what it produced) —
|
||||
which is how a repo with no Dockerfile and no Go still publishes an artifact:
|
||||
|
||||
```yaml
|
||||
binaries:
|
||||
- name: sdk
|
||||
run: npm install && npm run build && npm pack --pack-destination .
|
||||
out: "*.tgz"
|
||||
image: node:22-bookworm # the toolchain — see below
|
||||
```
|
||||
|
||||
`image:` names the container the **platform** lane runs `run:` in
|
||||
(`POST /v1/runner`, one initContainer per entry, in-cluster). Here the toolchain
|
||||
IS the runner, so this workflow reads past it. It is not a second recipe: both
|
||||
lanes read the same `binaries:` block out of the same `hanzo.yml` and publish the
|
||||
same `binaries.json` at the same URL.
|
||||
|
||||
Artifacts land under `<name>` in the index regardless of lane; a `run:` entry is
|
||||
`os: any, arch: any`, because an npm tarball or a wheel is not per-platform and
|
||||
an index entry that claimed one would be a lie a host acts on.
|
||||
|
||||
Built on every push (an arm64 cross-compile that breaks fails the PR that broke
|
||||
it) and **published on a tag**, after the `test:` gate — a host installs an
|
||||
artifact unattended, so the tests gate the bits. Each artifact lands on the
|
||||
GitHub Release for that tag:
|
||||
|
||||
```
|
||||
https://github.com/<owner>/<repo>/releases/download/<tag>/<name>-<os>-<arch>
|
||||
```
|
||||
|
||||
plus `binaries.json` beside them — `{name, os, arch, url, sha256}` for every
|
||||
artifact, so the bits and the digest that authorizes them ship as one release
|
||||
and a host reads both from one place. The job summary prints the
|
||||
`zip.Load(zip.Plugin{URL, Sum})` a host pastes.
|
||||
|
||||
Add a top-level `bucket:` and they publish to **hanzoai/s3** instead — same
|
||||
artifacts, same index, only the url changes:
|
||||
|
||||
```yaml
|
||||
bucket: plugins # → https://s3.hanzo.ai/plugins/<owner>/<repo>/<tag>/binaries.json
|
||||
```
|
||||
|
||||
Credentials are the `S3_ADMIN_*` names the services already read, pulled from
|
||||
KMS at run time; a declared bucket with no credential fails the publish rather
|
||||
than shipping an index whose artifacts are missing. Use it for anything large or
|
||||
frequent — a GitHub release stores it on a quota we do not own.
|
||||
|
||||
Builds are `CGO_ENABLED=0 -trimpath`: the host that installs this runs it on
|
||||
whatever base image the host is, and the digest must be a function of the
|
||||
source, not of the checkout path.
|
||||
|
||||
## Runners — our fleet or your own
|
||||
|
||||
By default the build runs on the **Hanzo `git-runner` fleet** on git.hanzo.ai
|
||||
(we run it; metered as build minutes) — the only pool that serves the default
|
||||
`hanzo-build-linux-amd64` label. There is no arc pool: arc (arcd) was retired
|
||||
2026-08-01 and never served any label in this default. To run on **your own**
|
||||
self-hosted runners, pass their labels:
|
||||
By default the build runs on the **Hanzo cloud** arc pool (we run it; metered as
|
||||
build minutes). To run on **your own** self-hosted arc runners, pass their labels:
|
||||
|
||||
```yaml
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v1
|
||||
@@ -188,7 +54,7 @@ self-hosted runners, pass their labels:
|
||||
|
||||
## Delegate to platform (skip runner buildx)
|
||||
|
||||
By default the build runs buildx **on** the runner. To instead hand the build
|
||||
By default the build runs buildx **on** the arc runner. To instead hand the build
|
||||
to **platform.hanzo.ai** — which builds in-cluster with BuildKit and rolls the
|
||||
service itself — pass `mode: delegate`:
|
||||
|
||||
@@ -200,7 +66,7 @@ service itself — pass `mode: delegate`:
|
||||
```
|
||||
|
||||
The GitHub job then just POSTs each image in `hanzo.yml` to platform's direct
|
||||
build webhook (`/v1/runner`) and exits in **seconds** — no runner buildx,
|
||||
build webhook (`/v1/arcd/enqueue`) and exits in **seconds** — no runner buildx,
|
||||
no KMS, no runner-side deploy. Platform creates the build job, launches an
|
||||
in-cluster BuildKit Job on its own pool, pushes to the registry, and patches the
|
||||
operator `Service` CR to roll it. It's the same build path as the platform
|
||||
@@ -208,10 +74,10 @@ GitHub-App webhook — one build path, two front doors.
|
||||
|
||||
Requires one extra secret, `PLATFORM_BUILD_CALLBACK_TOKEN` (org- or repo-level,
|
||||
picked up via `secrets: inherit`). Override the endpoint with the
|
||||
`PLATFORM_ENQUEUE_URL` repo/org variable (default `https://platform.hanzo.ai/v1/runner`).
|
||||
`PLATFORM_ENQUEUE_URL` repo/org variable (default `https://platform.hanzo.ai/v1/arcd/enqueue`).
|
||||
|
||||
`mode: buildx` (the default) is unchanged — existing repos keep running buildx on
|
||||
the fleet runner, so delegation is strictly opt-in.
|
||||
arc, so delegation is strictly opt-in.
|
||||
|
||||
## Credentials
|
||||
|
||||
@@ -223,6 +89,6 @@ run time. No long-lived registry or cluster credentials live in GitHub.
|
||||
## Platform-native
|
||||
|
||||
`hanzo.yml` is also read by platform.hanzo.ai: a repo on the platform webhook
|
||||
needs **only** `hanzo.yml` — the platform builds it in-cluster and rolls it out, no
|
||||
needs **only** `hanzo.yml` — the platform builds it on arc and rolls it out, no
|
||||
workflow file at all. This reusable is the GitHub-Actions path for repos that
|
||||
trigger through GitHub instead of the platform.
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# gover — refuse a Dockerfile whose Go builder image is older than the module
|
||||
# it compiles. One implementation, every caller.
|
||||
#
|
||||
# gover <dockerfile> [context-dir] # e.g. gover Dockerfile .
|
||||
#
|
||||
# WHAT THIS CATCHES, AND WHY IT IS A GATE AND NOT A CONVENTION
|
||||
#
|
||||
# The official `golang` images set GOTOOLCHAIN=local. That is deliberate on
|
||||
# their part — the image promises the Go it ships and refuses to silently
|
||||
# fetch another. The consequence is that a go.mod requiring a NEWER Go than
|
||||
# the base image does not degrade, it dies:
|
||||
#
|
||||
# go: go.mod requires go >= 1.26.5 (running go 1.26.4; GOTOOLCHAIN=local)
|
||||
#
|
||||
# hanzoai/visor v1.108.16 is the shipped instance. The failure is invisible
|
||||
# until the image build runs, because every local `go build` succeeds: a
|
||||
# developer box runs GOTOOLCHAIN=auto and simply downloads what the module
|
||||
# asks for. So the mismatch is introduced by editing go.mod — a file that has
|
||||
# nothing to do with Docker — and is discovered by a red release build.
|
||||
#
|
||||
# It is also not one repo's problem. A sweep of every Dockerfile across the
|
||||
# orgs found 58 FROM lines in 23 repos already below their own go.mod, and
|
||||
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those 58 fixes
|
||||
# today; this gate is what makes the 59th impossible, which is the part worth
|
||||
# having.
|
||||
#
|
||||
# WHAT IT DELIBERATELY DOES NOT DO
|
||||
#
|
||||
# It does not require the newest Go, and it does not care that an image is
|
||||
# behind `latest`. A newer toolchain compiling an older `go` directive is
|
||||
# always valid, so pinning ahead of go.mod is fine and stays silent. The only
|
||||
# thing refused is an image BELOW the module's floor, because that is the only
|
||||
# arrangement that cannot build.
|
||||
#
|
||||
# A floating tag (`golang:1.26-alpine`, no patch) resolves to the newest patch
|
||||
# of that minor when the image is pulled. Against a patch-pinned go.mod that
|
||||
# is correct today and fragile tomorrow — a stale registry mirror serves an
|
||||
# older patch and the build dies with the message above. That earns a warning,
|
||||
# never a failure: it builds, and a gate that fails what builds trains people
|
||||
# to skip gates.
|
||||
#
|
||||
# EXIT: 0 clean (warnings still print), 1 when a stage cannot build.
|
||||
set -uo pipefail
|
||||
|
||||
df=${1:?usage: gover <dockerfile> [context-dir]}
|
||||
ctx=${2:-.}
|
||||
[ -f "$df" ] || { echo "gover: no such Dockerfile: $df" >&2; exit 1; }
|
||||
|
||||
# --- the module floor -------------------------------------------------------
|
||||
# Nearest go.mod walking up from the Dockerfile, then the build context, then
|
||||
# the repo root. Multi-module repos are the reason this walks rather than
|
||||
# assuming the root: hanzoai/s3 builds s3-rdma-sidecar/ and telemetry/server/
|
||||
# from their own go.mod files, each with a different floor.
|
||||
#
|
||||
# WHICH go.mod, precisely: the BUILD CONTEXT decides, not where the file sits.
|
||||
# A Dockerfile under test/kafka/ built with `context: ../..` compiles the ROOT
|
||||
# module, and judging it by test/kafka/go.mod reads the wrong floor — in
|
||||
# hanzoai/s3 that difference is 1.25.0 vs 1.26.5, i.e. the difference between
|
||||
# "fine" and "cannot build". So when a Dockerfile copies the context's own
|
||||
# go.mod (`COPY go.mod ...`, the overwhelmingly common shape), that is the
|
||||
# module being compiled and the context's go.mod wins.
|
||||
#
|
||||
# Otherwise fall back to the nearest go.mod above the Dockerfile, which is the
|
||||
# right answer for a subdirectory that is its own module and is built with its
|
||||
# own directory as the context — hanzoai/s3's s3-rdma-sidecar/ and
|
||||
# telemetry/server/ are both that shape.
|
||||
govers=""; gosrc=""
|
||||
if grep -qiE '^[[:space:]]*COPY([[:space:]]+--[^[:space:]]+)*[[:space:]]+([^[:space:]]+[[:space:]]+)*go\.mod([[:space:]]|$)' "$df" 2>/dev/null \
|
||||
&& [ -f "$ctx/go.mod" ]; then
|
||||
gosrc="$ctx/go.mod"
|
||||
fi
|
||||
if [ -z "$gosrc" ]; then
|
||||
d=$(dirname "$df")
|
||||
while :; do
|
||||
if [ -f "$d/go.mod" ]; then gosrc="$d/go.mod"; break; fi
|
||||
[ "$d" = "." ] || [ "$d" = "/" ] || [ -z "$d" ] && break
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
fi
|
||||
[ -z "$gosrc" ] && [ -f "$ctx/go.mod" ] && gosrc="$ctx/go.mod"
|
||||
[ -z "$gosrc" ] && [ -f "go.mod" ] && gosrc="go.mod"
|
||||
# No module in play — nothing to compare against, and a non-Go image is not
|
||||
# this gate's business.
|
||||
[ -z "$gosrc" ] && exit 0
|
||||
govers=$(grep -m1 -E '^go[[:space:]]+[0-9]' "$gosrc" 2>/dev/null | awk '{print $2}')
|
||||
[ -z "$govers" ] && exit 0
|
||||
|
||||
# --- ARG defaults -----------------------------------------------------------
|
||||
# `FROM golang:${GO_VERSION}-bookworm` is only as good as its default, and the
|
||||
# default is the value CI builds with unless hanzo.yml passes `args:`. Read
|
||||
# them so the parameterised Dockerfiles are checked too, not skipped.
|
||||
declare -A args=()
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^[[:space:]]*[Aa][Rr][Gg][[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
v="${BASH_REMATCH[2]}"
|
||||
v="${v%%#*}" # strip trailing comment
|
||||
v="${v//\"/}"; v="${v//\'/}" # strip quotes
|
||||
v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"
|
||||
args[${BASH_REMATCH[1]}]="$v"
|
||||
fi
|
||||
done < "$df"
|
||||
|
||||
# semver -> comparable integer; missing patch becomes -1 so a floating tag is
|
||||
# distinguishable from an explicit .0 rather than silently equal to it.
|
||||
num() { # num <major> <minor> <patch|-1>
|
||||
printf '%d' $(( $1 * 1000000 + $2 * 1000 + ($3 < 0 ? 999 : $3) ))
|
||||
}
|
||||
parse() { # parse <version-ish> -> "major minor patch"; empty when unparseable
|
||||
local v=$1
|
||||
[[ $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]] && { echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"; return; }
|
||||
[[ $v =~ ^([0-9]+)\.([0-9]+) ]] && { echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} -1"; return; }
|
||||
echo ""
|
||||
}
|
||||
|
||||
read -r mM mm mp <<<"$(parse "$govers")"
|
||||
[ -z "${mM:-}" ] && exit 0 # go.mod says something we cannot read; not our call
|
||||
|
||||
rc=0; n=0
|
||||
while IFS= read -r line; do
|
||||
# FROM [--flag ...] [registry/]golang:<tag> [AS stage]
|
||||
[[ $line =~ ^[[:space:]]*[Ff][Rr][Oo][Mm][[:space:]]+(--[^[:space:]]+[[:space:]]+)*([^[:space:]]*golang:[^[:space:]]+) ]] || continue
|
||||
ref="${BASH_REMATCH[2]}"
|
||||
tag="${ref##*golang:}"
|
||||
# resolve ${VAR} / $VAR against the ARG defaults
|
||||
while [[ $tag =~ \$\{?([A-Za-z_][A-Za-z0-9_]*)\}? ]]; do
|
||||
name="${BASH_REMATCH[1]}"; sub="${args[$name]:-}"
|
||||
[ -z "$sub" ] && { tag=""; break; }
|
||||
tag="${tag//\$\{$name\}/$sub}"; tag="${tag//\$$name/$sub}"
|
||||
done
|
||||
[ -z "$tag" ] && continue
|
||||
n=$((n+1))
|
||||
read -r iM im ip <<<"$(parse "$tag")"
|
||||
# `golang:alpine`, `golang:1-alpine`, `ARG GO_VERSION=INVALID` — no version to
|
||||
# compare. Say so once; do not guess and do not fail.
|
||||
[ -z "${iM:-}" ] && { echo "gover: $df: '$ref' names no Go version — cannot check it against $gosrc ($govers)"; continue; }
|
||||
if [ "$(num "$iM" "$im" "$ip")" -lt "$(num "$mM" "$mm" "$mp")" ]; then
|
||||
echo "::error file=$df::Go builder image is older than the module it builds: '$ref' provides Go $iM.$im${ip:+.$ip} but $gosrc requires go $govers. The golang images set GOTOOLCHAIN=local, so this build fails with 'go.mod requires go >= $govers'. Fix: pin the base to golang:$govers-<variant>, and add 'ENV GOTOOLCHAIN=auto' to the builder stage so a future go.mod bump downloads the toolchain instead of failing."
|
||||
rc=1
|
||||
elif [ "$ip" -lt 0 ] && [ "$mp" -ge 0 ]; then
|
||||
echo "::warning file=$df::'$ref' floats to the newest patch of $iM.$im, while $gosrc pins go $govers. It builds today and fails the moment a registry mirror serves an older patch. Pin golang:$govers-<variant> to make it hermetic."
|
||||
fi
|
||||
done < "$df"
|
||||
|
||||
[ "$n" = 0 ] && exit 0
|
||||
[ "$rc" = 0 ] && echo "gover: OK — $df ($n Go stage$([ "$n" = 1 ] || echo s)) satisfies $gosrc (go $govers)"
|
||||
exit $rc
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/gover. Offline and deterministic: every case is a Dockerfile and
|
||||
# a go.mod written into a temp dir, so this needs no registry and no network.
|
||||
# Run: bash bin/gover_test.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GOVER="$PWD/bin/gover"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
# t <name> <want-rc> <go.mod-directive> <dockerfile-body...>
|
||||
t() {
|
||||
local name=$1 want=$2 gomod=$3; shift 3
|
||||
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
|
||||
printf 'module x\n\ngo %s\n' "$gomod" > "$d/go.mod"
|
||||
printf '%s\n' "$@" > "$d/Dockerfile"
|
||||
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = "$want" ]; then printf 'ok %-56s rc=%s\n' "$name" "$rc"
|
||||
else printf 'FAIL %-56s rc=%s (want %s)\n %s\n' "$name" "$rc" "$want" "$out"; fail=1; fi
|
||||
}
|
||||
# grep-based assertion for the message body, not just the code
|
||||
tmsg() {
|
||||
local name=$1 pat=$2 gomod=$3; shift 3
|
||||
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
|
||||
printf 'module x\n\ngo %s\n' "$gomod" > "$d/go.mod"
|
||||
printf '%s\n' "$@" > "$d/Dockerfile"
|
||||
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1)
|
||||
if printf '%s' "$out" | grep -q "$pat"; then printf 'ok %-56s\n' "$name"
|
||||
else printf 'FAIL %-56s\n got: %s\n' "$name" "$out"; fail=1; fi
|
||||
}
|
||||
|
||||
# --- the refusal: image below the module floor ------------------------------
|
||||
# This is the visor v1.108.16 shape exactly.
|
||||
t "patch below floor is refused" 1 1.26.5 'FROM golang:1.26.4-alpine'
|
||||
t "minor below floor is refused" 1 1.26.5 'FROM golang:1.25-alpine'
|
||||
t "ancient relic is refused" 1 1.26.4 'FROM golang:1.10.1'
|
||||
t "second stage is checked too" 1 1.26.5 'FROM node:22 AS web' 'FROM golang:1.26.1-bookworm AS api'
|
||||
|
||||
# --- the allowances ---------------------------------------------------------
|
||||
t "exact match builds" 0 1.26.5 'FROM golang:1.26.5-alpine'
|
||||
t "newer image than floor is fine" 0 1.26.4 'FROM golang:1.26.5-alpine'
|
||||
t "much newer image is fine" 0 1.25.0 'FROM golang:1.26.5-bookworm'
|
||||
t "alpine suffix is not a Go patch" 0 1.26 'FROM golang:1.26-alpine3.24'
|
||||
t "registry prefix is stripped" 0 1.26.5 'FROM docker.io/library/golang:1.26.5-alpine'
|
||||
t "--platform flag is skipped" 0 1.26.5 'FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine AS b'
|
||||
t "non-Go image is not our business" 0 1.26.5 'FROM alpine:3.21'
|
||||
|
||||
# --- floating tags warn, never fail -----------------------------------------
|
||||
# They build. A gate that fails what builds trains people to skip gates.
|
||||
t "floating tag does not fail" 0 1.26.5 'FROM golang:1.26-alpine'
|
||||
tmsg "floating tag warns" '::warning' 1.26.5 'FROM golang:1.26-alpine'
|
||||
t "floating minor below floor IS refused" 1 1.26.5 'FROM golang:1.25-alpine'
|
||||
|
||||
# --- ARG resolution ---------------------------------------------------------
|
||||
# A parameterised FROM is only as good as its default; check it, don't skip it.
|
||||
t "ARG default below floor is refused" 1 1.26.5 'ARG GO_VERSION=1.26.4' 'FROM golang:${GO_VERSION}-bookworm'
|
||||
t "ARG default at floor builds" 0 1.26.5 'ARG GO_VERSION=1.26.5' 'FROM golang:${GO_VERSION}-bookworm'
|
||||
t "unbraced \$VAR resolves" 1 1.26.5 'ARG GO_VERSION=1.24' 'FROM golang:$GO_VERSION-bookworm'
|
||||
t "ARG with trailing comment parses" 1 1.26.5 'ARG GO_VERSION=1.26.4 # keep in step' 'FROM golang:${GO_VERSION}-alpine'
|
||||
# luxfi/node ships this literally, to silence a buildx warning on a Dockerfile
|
||||
# that is never built with the default. It must not crash the gate.
|
||||
t "unparseable ARG default is skipped" 0 1.26.5 'ARG GO_VERSION=INVALID # silences a warning' 'FROM golang:${GO_VERSION}-bookworm'
|
||||
t "unversioned golang:alpine is skipped" 0 1.26.5 'FROM golang:alpine'
|
||||
t "golang:1-alpine is skipped" 0 1.26.5 'FROM golang:1-alpine'
|
||||
|
||||
# --- module resolution ------------------------------------------------------
|
||||
# Multi-module repos build subdirectories against their OWN go.mod. Taking the
|
||||
# root's floor would report a mismatch that does not exist (or miss one that
|
||||
# does) — hanzoai/s3 is the live case.
|
||||
d="$tmp/multi"; mkdir -p "$d/sub"
|
||||
printf 'module root\n\ngo 1.26.5\n' > "$d/go.mod"
|
||||
printf 'module sub\n\ngo 1.24.0\n' > "$d/sub/go.mod"
|
||||
printf 'FROM golang:1.24-alpine\n' > "$d/sub/Dockerfile"
|
||||
out=$(cd "$d" && bash "$GOVER" sub/Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "nearest go.mod wins over the root"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "nearest go.mod wins over the root" "$rc" "$out"; fail=1; fi
|
||||
printf 'FROM golang:1.24-alpine\n' > "$d/Dockerfile"
|
||||
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 1 ]; then printf 'ok %-56s rc=1\n' "root Dockerfile is judged by the root go.mod"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "root Dockerfile is judged by the root go.mod" "$rc" "$out"; fail=1; fi
|
||||
|
||||
# --- no module at all -------------------------------------------------------
|
||||
d2="$tmp/nomod"; mkdir -p "$d2"
|
||||
printf 'FROM golang:1.20-alpine\n' > "$d2/Dockerfile"
|
||||
out=$(cd "$d2" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "no go.mod: nothing to compare, stays silent"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "no go.mod: nothing to compare, stays silent" "$rc" "$out"; fail=1; fi
|
||||
|
||||
# --- the remediation is in the message --------------------------------------
|
||||
# A gate that says only "no" costs the next person the same hour it cost the
|
||||
# last one.
|
||||
tmsg "error names the fix (pin + GOTOOLCHAIN=auto)" 'GOTOOLCHAIN=auto' 1.26.5 'FROM golang:1.26.4-alpine'
|
||||
tmsg "error quotes the runtime failure it prevents" 'go.mod requires go >=' 1.26.5 'FROM golang:1.26.4-alpine'
|
||||
|
||||
# --- build context decides the module, not file location -------------------
|
||||
# hanzoai/s3's live shape: a Dockerfile under test/kafka/ built with
|
||||
# `context: ../..` that does `COPY go.mod go.sum ./` compiles the ROOT module.
|
||||
# Judging it by test/kafka/go.mod reads 1.25.0 where the truth is 1.26.5 — the
|
||||
# difference between "fine" and "cannot build".
|
||||
d3="$tmp/ctxwins"; mkdir -p "$d3/test/kafka"
|
||||
printf 'module root\n\ngo 1.26.5\n' > "$d3/go.mod"
|
||||
printf 'module sub\n\ngo 1.25.0\n' > "$d3/test/kafka/go.mod"
|
||||
printf 'FROM golang:1.25-alpine\nCOPY go.mod go.sum ./\nRUN go build ./...\n' > "$d3/test/kafka/Dockerfile.s3"
|
||||
out=$(cd "$d3" && bash "$GOVER" test/kafka/Dockerfile.s3 . 2>&1); rc=$?
|
||||
if [ "$rc" = 1 ]; then printf 'ok %-56s rc=1\n' "context go.mod wins when Dockerfile COPYs it"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "context go.mod wins when Dockerfile COPYs it" "$rc" "$out"; fail=1; fi
|
||||
# ...and the converse: a subdir module built from its OWN directory as context,
|
||||
# copying its OWN go.mod, is still judged by its own floor.
|
||||
d4="$tmp/subctx"; mkdir -p "$d4/sidecar"
|
||||
printf 'module root\n\ngo 1.26.5\n' > "$d4/go.mod"
|
||||
printf 'module sidecar\n\ngo 1.24.0\n' > "$d4/sidecar/go.mod"
|
||||
printf 'FROM golang:1.24-alpine\nCOPY go.mod go.sum ./\n' > "$d4/sidecar/Dockerfile"
|
||||
out=$(cd "$d4/sidecar" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "own-directory context keeps its own floor"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "own-directory context keeps its own floor" "$rc" "$out"; fail=1; fi
|
||||
# A Dockerfile that copies a SUBDIRECTORY's go.mod is judged by the nearest one,
|
||||
# not the context root — otherwise every multi-module repo reports false alarms.
|
||||
d5="$tmp/nocopy"; mkdir -p "$d5/svc"
|
||||
printf 'module root\n\ngo 1.26.5\n' > "$d5/go.mod"
|
||||
printf 'module svc\n\ngo 1.24.0\n' > "$d5/svc/go.mod"
|
||||
printf 'FROM golang:1.24-alpine\nCOPY svc/go.mod ./\n' > "$d5/svc/Dockerfile"
|
||||
out=$(cd "$d5" && bash "$GOVER" svc/Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "subdir go.mod copy is not the context root"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "subdir go.mod copy is not the context root" "$rc" "$out"; fail=1; fi
|
||||
|
||||
echo
|
||||
[ $fail = 0 ] && echo "all gover tests passed" || echo "gover tests FAILED"
|
||||
exit $fail
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# imgver — the version an image build publishes. One implementation, every caller.
|
||||
#
|
||||
# imgver <image-repo> [context-dir] # e.g. imgver ghcr.io/hanzoai/iam .
|
||||
#
|
||||
# We don't ship shas. A build that cannot name a version is a broken build, so
|
||||
# this exits non-zero rather than letting a caller fall back to sha-<short>.
|
||||
#
|
||||
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL: the fleet has two build front doors
|
||||
# — hanzoai/ci's build.yml (imported by repos with a hanzo.yml) and the
|
||||
# hand-rolled .hanzo/workflows/deploy.yml that 11 repos carry instead. Both need
|
||||
# the identical number. Written twice it would be right twice and then wrong
|
||||
# once, which is how `sha-<short>` became the only tag those 11 repos ever
|
||||
# published.
|
||||
#
|
||||
# THE NUMBER IS DERIVED, NEVER TYPED, and monotonic against two floors:
|
||||
# declared — the repo's own manifest (package.json / Cargo.toml / VERSION /
|
||||
# pyproject.toml, or $IMGVER_VERSION to name it outright). The
|
||||
# human's say: bump the minor there and the series jumps there.
|
||||
# published — the highest semver already at the registry for THIS image.
|
||||
# max(declared, published) + a patch is what stops one name from ever covering
|
||||
# two digests. Deriving from the manifest alone re-publishes the same number on
|
||||
# every push until someone edits the file, and a node running
|
||||
# imagePullPolicy: IfNotPresent never picks up the second one. universe's
|
||||
# images.yml learned that on iam-secret-sync; this is that rule, everywhere.
|
||||
#
|
||||
# ENV: GH_PAT (or GITHUB_TOKEN) to read the registry floor; IMGVER_PUBLISHED to
|
||||
# supply it directly (a registry this cannot read, and the test seam).
|
||||
# Without either, only the manifest carries the series.
|
||||
set -euo pipefail
|
||||
|
||||
repo="${1:?usage: imgver <image-repo> [context-dir]}"
|
||||
ctx="${2:-.}"
|
||||
|
||||
semver='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
|
||||
# ---- declared ---------------------------------------------------------------
|
||||
declared="${IMGVER_VERSION:-}"
|
||||
case "$declared" in
|
||||
# The "<file>:<command printing it>" shape hanzo.yml's client lane already uses.
|
||||
*:*) declared=$(bash -c "${declared#*:}" 2>/dev/null || true) ;;
|
||||
esac
|
||||
if [ -z "$declared" ]; then
|
||||
# The image's own context first, then the repo root: a monorepo's web/ or api/
|
||||
# carries the version of the thing being built, not the workspace stub.
|
||||
for d in "$ctx" .; do
|
||||
[ -d "$d" ] || continue
|
||||
if [ -f "$d/package.json" ]; then
|
||||
declared=$(jq -r '.version // ""' "$d/package.json" 2>/dev/null || true)
|
||||
elif [ -f "$d/Cargo.toml" ]; then
|
||||
declared=$(sed -n '/^\[\(workspace\.\)\?package\]/,/^\[/p' "$d/Cargo.toml" \
|
||||
| sed -n 's/^version *= *"\([^"]*\)".*/\1/p' | head -1)
|
||||
elif [ -f "$d/VERSION" ]; then
|
||||
declared=$(tr -d ' \n' < "$d/VERSION")
|
||||
elif [ -f "$d/pyproject.toml" ]; then
|
||||
declared=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' "$d/pyproject.toml" | head -1)
|
||||
fi
|
||||
[ -n "$declared" ] && break
|
||||
done
|
||||
fi
|
||||
declared="${declared#v}"
|
||||
# A workspace stub (0.0.0) or a placeholder is not a version anyone declared.
|
||||
[ "$declared" = "0.0.0" ] && declared=""
|
||||
echo "$declared" | grep -qE "$semver" || declared=""
|
||||
|
||||
# ---- published --------------------------------------------------------------
|
||||
# The GitHub Packages API, not the registry v2 tags list: an anonymous ghcr pull
|
||||
# token can fetch a manifest by name but returns an EMPTY tag list, so a v2 read
|
||||
# would silently report "nothing published" and restart the series at 0.
|
||||
published="${IMGVER_PUBLISHED:-}"
|
||||
tok="${GH_PAT:-${GITHUB_TOKEN:-}}"
|
||||
if [ -z "$published" ] && [ -n "$tok" ] && [ "${repo#ghcr.io/}" != "$repo" ]; then
|
||||
org="${repo#ghcr.io/}"; pkg="${org#*/}"; org="${org%%/*}"
|
||||
published=$(curl -fsSL -H "Authorization: Bearer $tok" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/orgs/${org}/packages/container/${pkg}/versions?per_page=100" 2>/dev/null \
|
||||
| jq -r '.[].metadata.container.tags[]?' 2>/dev/null \
|
||||
| sed 's/^v//' | grep -E "$semver" | sort -V | tail -1 || true)
|
||||
fi
|
||||
|
||||
# ---- the number -------------------------------------------------------------
|
||||
max=$(printf '%s\n%s\n' "$declared" "$published" | grep -E "$semver" | sort -V | tail -1 || true)
|
||||
if [ -z "$max" ]; then
|
||||
echo "imgver: no version for $repo. Declare one — a package.json/Cargo.toml/VERSION/pyproject.toml under '$ctx', or IMGVER_VERSION. We don't ship shas." >&2
|
||||
exit 1
|
||||
elif [ "$max" = "$declared" ] && [ "$declared" != "$published" ]; then
|
||||
ver="$declared" # the human bumped it — publish exactly that
|
||||
else
|
||||
ver="${max%.*}.$(( ${max##*.} + 1 ))" # already out there — next patch
|
||||
fi
|
||||
|
||||
echo "imgver $repo: declared=${declared:-none} published=${published:-none} -> $ver" >&2
|
||||
echo "$ver"
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/imgver. Runs offline: no GH_PAT means no registry read, so the
|
||||
# published floor is injected through IMGVER_PUBLISHED and every case is
|
||||
# deterministic. Run: bash bin/imgver_test.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
IMGVER="$PWD/bin/imgver"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
run() { # run <declared-env> <published> <ctx>
|
||||
IMGVER_VERSION="$1" IMGVER_PUBLISHED="$2" GH_PAT= GITHUB_TOKEN= \
|
||||
bash "$IMGVER" ghcr.io/hanzoai/test "$3" 2>/dev/null
|
||||
}
|
||||
t() { # t <name> <declared> <published> <ctx> <want>
|
||||
got=$(run "$2" "$3" "$4"); rc=$?
|
||||
[ $rc -ne 0 ] && got="ERROR"
|
||||
if [ "$got" = "$5" ]; then printf 'ok %-52s -> %s\n' "$1" "$got"
|
||||
else printf 'FAIL %-52s -> %s (want %s)\n' "$1" "$got" "$5"; fail=1; fi
|
||||
}
|
||||
|
||||
# --- the derivation ---------------------------------------------------------
|
||||
t "registry ahead: next patch, monotonic" 1.2.3 1.2.7 "$tmp" 1.2.8
|
||||
t "human bumped the minor: honour it verbatim" 1.3.0 1.2.8 "$tmp" 1.3.0
|
||||
t "same number published: never 2 digests/name" 1.2.3 1.2.3 "$tmp" 1.2.4
|
||||
t "no manifest version: registry carries it" "" 1.2.7 "$tmp" 1.2.8
|
||||
t "nothing published: seed at declared" 1.2.3 "" "$tmp" 1.2.3
|
||||
t "major bump honoured" 2.0.0 1.9.9 "$tmp" 2.0.0
|
||||
t "sort -V not lexical (1.2.10 > 1.2.9)" 1.2.9 1.2.10 "$tmp" 1.2.11
|
||||
t "cloud's real series" 1.801.341 1.801.341 "$tmp" 1.801.342
|
||||
t "stale manifest cannot drag series backwards" 0.0.1 0.9.0 "$tmp" 0.9.1
|
||||
t "no version anywhere: fail loud, never sha" "" "" "$tmp" ERROR
|
||||
t "leading v stripped" v1.4.0 "" "$tmp" 1.4.0
|
||||
t "0.0.0 workspace stub is not a version" 0.0.0 1.1.1 "$tmp" 1.1.2
|
||||
t "non-semver declared is ignored" "1.2" 2.0.0 "$tmp" 2.0.1
|
||||
|
||||
# --- manifest discovery ------------------------------------------------------
|
||||
m() { rm -rf "$tmp"/m; mkdir -p "$tmp"/m; }
|
||||
m; echo '{"version":"3.4.5"}' > "$tmp/m/package.json"
|
||||
t "package.json" "" "" "$tmp/m" 3.4.5
|
||||
m; printf '[package]\nname="x"\nversion = "6.7.8"\n' > "$tmp/m/Cargo.toml"
|
||||
t "Cargo.toml [package]" "" "" "$tmp/m" 6.7.8
|
||||
m; printf '[workspace.package]\nversion = "1.45.2"\n' > "$tmp/m/Cargo.toml"
|
||||
t "Cargo.toml [workspace.package] (index's shape)" "" "" "$tmp/m" 1.45.2
|
||||
m; echo "9.9.9" > "$tmp/m/VERSION"
|
||||
t "VERSION file" "" "" "$tmp/m" 9.9.9
|
||||
m; printf '[project]\nversion = "2.3.4"\n' > "$tmp/m/pyproject.toml"
|
||||
t "pyproject.toml" "" "" "$tmp/m" 2.3.4
|
||||
m; echo '{"name":"x"}' > "$tmp/m/package.json"
|
||||
t "package.json with no version key -> ERROR" "" "" "$tmp/m" ERROR
|
||||
m; echo '{"version":"0.0.0"}' > "$tmp/m/package.json"
|
||||
t "workspace stub package.json -> ERROR" "" "" "$tmp/m" ERROR
|
||||
m; echo '{"version":"1.0.0"}' > "$tmp/m/package.json"
|
||||
t "IMGVER_VERSION overrides the manifest" 5.5.5 "" "$tmp/m" 5.5.5
|
||||
m; echo '{"version":"1.0.0"}' > "$tmp/m/package.json"
|
||||
t "resolver expression <file>:<command>" "package.json:echo 7.7.7" "" "$tmp/m" 7.7.7
|
||||
|
||||
echo
|
||||
[ $fail -eq 0 ] && echo "imgver: all cases pass" || echo "imgver: FAILURES"
|
||||
exit $fail
|
||||
@@ -1,55 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
// brand.go — where this page's design values come from.
|
||||
//
|
||||
// They come from @hanzo/brand, the one place the fleet's palette, radii, type
|
||||
// scale and spacing are defined, and they arrive as that package's OWN
|
||||
// published artifact rather than as hex codes retyped here. The distinction is
|
||||
// the entire point. Until this file existed the template carried its own
|
||||
// :root block, and being a hand-copy it had already drifted off the house:
|
||||
// the status colours were GitHub Primer's (#3fb950 / #f85149 / #d29922) where
|
||||
// the house says #10b981 / #ef4444 / #f59e0b, the surface blacks were each a
|
||||
// shade wrong (#0b0b0d against --surface-0 #080808), and the hairline border
|
||||
// was a solid #25252b where the house hairline is a 6%-white wash. Only the
|
||||
// accent survived intact. A palette that is copied is a palette that diverges.
|
||||
//
|
||||
// Vendored, not fetched at build time, and compiled in rather than served off
|
||||
// disk. @hanzo/brand publishes this file as a plain custom-property sheet
|
||||
// (`exports["./styles/*"]`, documented for a bare <link>), so consuming it
|
||||
// costs no npm, no bundler and no React — the image build stays `go build`
|
||||
// against an empty go.mod, and the page stays one request that returns the
|
||||
// answer. That matters here more than anywhere: this dashboard is read when
|
||||
// the build system is broken, which is the worst possible moment for it to
|
||||
// need the build system in order to draw itself.
|
||||
//
|
||||
// Refreshing is a deliberate, reviewed act — fetch, then update the pin:
|
||||
//
|
||||
// curl -sSfo brand/variables.css https://unpkg.com/@hanzo/brand@<version>/styles/variables.css
|
||||
//
|
||||
//go:embed brand/variables.css
|
||||
var brandCSS string
|
||||
|
||||
// brandCSSVersion and brandCSSSHA256 record WHICH @hanzo/brand the bytes above
|
||||
// are, and a test rejects any other bytes. This is go.sum's argument, not
|
||||
// ceremony: without it, "just darken that one border" is a one-character local
|
||||
// edit that silently restores the second source of truth this file removed, and
|
||||
// nothing would ever catch it.
|
||||
const (
|
||||
brandCSSVersion = "1.4.5"
|
||||
brandCSSSHA256 = "941dfc0080343d25dc1ef2cd780290a2d8fe6cbd81136912281902f2e8e7741f"
|
||||
)
|
||||
|
||||
// dashboardCSS is what this page adds on top: layout, not design. Every colour,
|
||||
// radius and size in it is a var() into the sheet above.
|
||||
//
|
||||
//go:embed dashboard.css
|
||||
var dashboardCSS string
|
||||
|
||||
// pageCSS is the <style> body: tokens first, then the rules that spend them.
|
||||
// template.CSS because these are two compile-time constants, never input.
|
||||
func pageCSS() template.CSS { return template.CSS(brandCSS + dashboardCSS) }
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* @hanzo/brand CSS Variables
|
||||
*
|
||||
* Hanzo is monochrome — the brand is ink, paper, and a neutral grayscale.
|
||||
* There is no brand hue; the brand color is the ink (dark) / paper (light).
|
||||
*
|
||||
* Usage:
|
||||
* @import '@hanzo/brand/styles/variables.css';
|
||||
* or link: <link rel="stylesheet" href="https://unpkg.com/@hanzo/brand/styles/variables.css">
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ===== Hanzo Brand (Monochrome: Hanzo Black ↔ Hanzo White) ===== */
|
||||
--hanzo-black: #0a0a0b;
|
||||
--hanzo-black-rgb: 10, 10, 11;
|
||||
--hanzo-white: #ffffff;
|
||||
--hanzo-white-rgb: 255, 255, 255;
|
||||
--hanzo-mono-50: #fafafa;
|
||||
--hanzo-mono-100: #f5f5f5;
|
||||
--hanzo-mono-200: #e5e5e5;
|
||||
--hanzo-mono-300: #d4d4d4;
|
||||
--hanzo-mono-400: #a3a3a3;
|
||||
--hanzo-mono-500: #737373;
|
||||
--hanzo-mono-600: #525252;
|
||||
--hanzo-mono-700: #404040;
|
||||
--hanzo-mono-800: #262626;
|
||||
--hanzo-mono-900: #171717;
|
||||
--hanzo-mono-950: #0a0a0a;
|
||||
|
||||
/* ===== Accent — the ONE Hanzo accent: PURPLE (palette = White · Gray · Purple).
|
||||
The monochrome base stays (primary action = white, neutrals = gray); purple is
|
||||
the single interactive/brand accent — links, active, focus, selection. NO blue,
|
||||
green, or orange. White-label tenants override --hanzo-accent per host so
|
||||
lux/zoo/pars never inherit Hanzo purple. ===== */
|
||||
--hanzo-accent: #8b5cf6; /* violet-500 */
|
||||
--hanzo-accent-hover: #7c3aed; /* violet-600 */
|
||||
--hanzo-accent-muted: #a78bfa; /* violet-400 — accent text on dark */
|
||||
--hanzo-accent-soft: rgba(139, 92, 246, 0.12); /* subtle fill / selected row */
|
||||
--hanzo-accent-rgb: 139, 92, 246;
|
||||
|
||||
/* ===== Layered surface blacks (Builder v2 — no gray panels; each subtly different) ===== */
|
||||
--surface-0: #080808; /* app background */
|
||||
--surface-1: #0d0d0d; /* panels */
|
||||
--surface-2: #111111; /* raised */
|
||||
--surface-3: #171717; /* controls / hover */
|
||||
|
||||
/* ===== Hairline border — 1px, almost invisible (no thick outlines) ===== */
|
||||
--border-hairline: rgba(255, 255, 255, 0.06);
|
||||
--border-hairline-strong: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* ===== Semantic radius (Builder v2): cards 8 · controls/toolbar 10 · preview/panels 12 ===== */
|
||||
--radius-card: 0.5rem; /* 8px */
|
||||
--radius-control: 0.625rem; /* 10px — buttons, toolbar, inputs */
|
||||
--radius-panel: 0.75rem; /* 12px — preview, large panels */
|
||||
|
||||
/* ===== Semantic type roles (Builder v2): heading 20 · body 14 · secondary 12 ===== */
|
||||
--text-heading: 1.25rem; /* 20px @ 600 */
|
||||
--text-body: 0.875rem; /* 14px @ 400 */
|
||||
--text-secondary: 0.75rem; /* 12px @ 400/500 */
|
||||
|
||||
/* ===== Semantic Aliases (monochrome; flips with scheme) ===== */
|
||||
--brand: var(--hanzo-black);
|
||||
--brand-light: var(--hanzo-mono-800);
|
||||
--brand-dark: #000000;
|
||||
--brand-hover: var(--hanzo-mono-900);
|
||||
--brand-secondary: var(--hanzo-mono-600);
|
||||
|
||||
/* ===== Dark Theme Backgrounds ===== */
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #141414;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--bg-card: rgba(23, 23, 23, 0.5);
|
||||
|
||||
/* ===== Light Theme Backgrounds ===== */
|
||||
--bg-light: #ffffff;
|
||||
--bg-light-secondary: #fafafa;
|
||||
--bg-light-tertiary: #f5f5f5;
|
||||
|
||||
/* ===== Borders ===== */
|
||||
--border: #262626;
|
||||
--border-light: #e5e5e5;
|
||||
--border-focus: var(--hanzo-black);
|
||||
|
||||
/* ===== Text Colors (Dark Theme) ===== */
|
||||
--text-primary: #fafafa;
|
||||
--text-secondary: #a3a3a3;
|
||||
--text-muted: #737373;
|
||||
--text-disabled: #525252;
|
||||
|
||||
/* ===== Text Colors (Light Theme) ===== */
|
||||
--text-light-primary: #0a0a0b;
|
||||
--text-light-secondary: #525252;
|
||||
--text-light-muted: #737373;
|
||||
|
||||
/* ===== Neutral Scale ===== */
|
||||
--neutral-0: #ffffff;
|
||||
--neutral-50: #fafafa;
|
||||
--neutral-100: #f5f5f5;
|
||||
--neutral-200: #e5e5e5;
|
||||
--neutral-300: #d4d4d4;
|
||||
--neutral-400: #a3a3a3;
|
||||
--neutral-500: #737373;
|
||||
--neutral-600: #525252;
|
||||
--neutral-700: #404040;
|
||||
--neutral-800: #262626;
|
||||
--neutral-900: #171717;
|
||||
--neutral-950: #0a0a0a;
|
||||
--neutral-1000: #000000;
|
||||
|
||||
/* ===== Semantic Colors ===== */
|
||||
--success: #10b981;
|
||||
--success-light: #34d399;
|
||||
--success-dark: #059669;
|
||||
|
||||
--warning: #f59e0b;
|
||||
--warning-light: #fcd34d;
|
||||
--warning-dark: #d97706;
|
||||
|
||||
--error: #ef4444;
|
||||
--error-light: #f87171;
|
||||
--error-dark: #dc2626;
|
||||
|
||||
--info: #3b82f6;
|
||||
--info-light: #60a5fa;
|
||||
--info-dark: #2563eb;
|
||||
|
||||
/* ===== Gradients ===== */
|
||||
--gradient-brand: linear-gradient(135deg, var(--hanzo-mono-800) 0%, var(--hanzo-black) 100%);
|
||||
--gradient-accent: linear-gradient(135deg, var(--hanzo-black) 0%, #000000 100%);
|
||||
--gradient-dark: linear-gradient(135deg, #0a0a0b 0%, #262626 100%);
|
||||
|
||||
/* ===== Spacing ===== */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
--space-24: 6rem;
|
||||
|
||||
/* ===== Border Radius ===== */
|
||||
--radius-sm: 0.125rem;
|
||||
--radius: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ===== Shadows ===== */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* ===== Transitions ===== */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ===== Typography ===== */
|
||||
--font-sans: 'Geist Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Geist Mono', ui-monospace, Monaco, monospace;
|
||||
|
||||
/* ===== Font Size scale (mirrors typography.ts `fontSize`) =====
|
||||
TIGHT app-first default — the compact developer-app register (linear.app /
|
||||
vercel.com), NOT a roomy marketing scale. Base is 14px, nav 13px, labels 11px.
|
||||
Every surface that imports @hanzo/brand inherits this; a brand/tenant can
|
||||
override any --font-size-* on :root to retune density on demand. */
|
||||
--font-size-xs: 0.6875rem; /* 11px — eyebrows / section labels */
|
||||
--font-size-sm: 0.8125rem; /* 13px — nav labels, dense body */
|
||||
--font-size-base: 0.875rem; /* 14px — base app text (was 16px) */
|
||||
--font-size-lg: 0.9375rem; /* 15px */
|
||||
--font-size-xl: 1.0625rem; /* 17px */
|
||||
--font-size-2xl: 1.3125rem; /* 21px */
|
||||
--font-size-3xl: 1.625rem; /* 26px */
|
||||
--font-size-4xl: 2rem; /* 32px */
|
||||
--font-size-5xl: 2.5rem; /* 40px */
|
||||
--font-size-6xl: 3.25rem; /* 52px */
|
||||
--font-size-7xl: 4rem; /* 64px */
|
||||
--font-size-8xl: 5.25rem; /* 84px */
|
||||
--font-size-9xl: 7rem; /* 112px */
|
||||
|
||||
/* ===== Z-index ladder (mirrors tokens.ts `zIndex`) ===== */
|
||||
--z-0: 0;
|
||||
--z-10: 10;
|
||||
--z-20: 20;
|
||||
--z-30: 30;
|
||||
--z-40: 40;
|
||||
--z-50: 50;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-popover: 500;
|
||||
--z-tooltip: 600;
|
||||
--z-notification: 700;
|
||||
}
|
||||
|
||||
/* Dark theme (default for Hanzo) */
|
||||
[data-theme="dark"],
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"],
|
||||
.light {
|
||||
--bg-primary: var(--bg-light);
|
||||
--bg-secondary: var(--bg-light-secondary);
|
||||
--bg-tertiary: var(--bg-light-tertiary);
|
||||
--bg-card: rgba(255, 255, 255, 0.8);
|
||||
--border: var(--border-light);
|
||||
--text-primary: var(--text-light-primary);
|
||||
--text-secondary: var(--text-light-secondary);
|
||||
--text-muted: var(--text-light-muted);
|
||||
/* purple accent flips a shade deeper for contrast on paper */
|
||||
--hanzo-accent: #7c3aed;
|
||||
--hanzo-accent-hover: #6d28d9;
|
||||
--hanzo-accent-muted: #7c3aed;
|
||||
/* layered "blacks" become layered near-whites in light */
|
||||
--surface-0: #ffffff;
|
||||
--surface-1: #fafafa;
|
||||
--surface-2: #f5f5f5;
|
||||
--surface-3: #ededed;
|
||||
--border-hairline: rgba(0, 0, 0, 0.08);
|
||||
--border-hairline-strong: rgba(0, 0, 0, 0.12);
|
||||
color-scheme: light;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/* dashboard.css — this page's own rules: what is a row, what sticks, what
|
||||
collapses on a phone. It names no colour, radius or type size of its own;
|
||||
every such value is a var() into @hanzo/brand (see brand.go). That is the
|
||||
difference between consuming the design system and being a second copy of it,
|
||||
and it is asserted, not merely intended — see TestDashboardCSSNamesNoColours. */
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--surface-0);color:var(--text-primary);
|
||||
font-family:var(--font-sans);font-size:var(--font-size-base);line-height:1.5}
|
||||
header{display:flex;align-items:center;gap:var(--space-4);
|
||||
padding:var(--space-4) var(--space-6);background:var(--surface-1);
|
||||
border-bottom:1px solid var(--border-hairline)}
|
||||
h1{margin:0;font-size:var(--font-size-lg);font-weight:600;letter-spacing:-.01em}
|
||||
h1 span{color:var(--hanzo-accent-muted)}
|
||||
.meta{margin-left:auto;color:var(--text-secondary);font-size:var(--font-size-sm);text-align:right}
|
||||
|
||||
.strip{display:flex;gap:var(--space-2);padding:var(--space-4) var(--space-6);flex-wrap:wrap}
|
||||
.chip{padding:var(--space-2) var(--space-3);background:var(--surface-1);
|
||||
border:1px solid var(--border-hairline);border-radius:var(--radius-card);
|
||||
font-size:var(--font-size-sm);color:var(--text-secondary)}
|
||||
.chip b{color:var(--text-primary);font-weight:600}
|
||||
.chip.ok b{color:var(--success)} .chip.fail b{color:var(--error)}
|
||||
.chip.run b{color:var(--warning)} .chip.cancel b{color:var(--text-muted)}
|
||||
|
||||
nav{display:flex;gap:var(--space-2);padding:0 var(--space-6) var(--space-4);flex-wrap:wrap}
|
||||
nav a{padding:var(--space-1) var(--space-3);background:var(--surface-1);
|
||||
border:1px solid var(--border-hairline);border-radius:var(--radius-full);
|
||||
color:var(--text-secondary);text-decoration:none;font-size:var(--font-size-sm)}
|
||||
/* --hanzo-accent-soft is the house "selected row" fill; the active tab is the
|
||||
one place on this page that is a selection, so it is the one place it is used. */
|
||||
nav a.on{border-color:var(--hanzo-accent);background:var(--hanzo-accent-soft);color:var(--text-primary)}
|
||||
nav .who{margin-left:auto;align-self:center;color:var(--text-muted);font-size:var(--font-size-sm)}
|
||||
|
||||
/* The stale banner tints its own border colour rather than introducing an amber
|
||||
of its own — @hanzo/brand ships no warning-surface token, and inventing one
|
||||
here is exactly the drift this file exists to stop. */
|
||||
.warn{margin:0 var(--space-6) var(--space-4);padding:var(--space-3) var(--space-4);
|
||||
border:1px solid var(--warning);border-radius:var(--radius-card);
|
||||
background:color-mix(in srgb, var(--warning) 10%, transparent);
|
||||
color:var(--warning-light);font-size:var(--font-size-sm)}
|
||||
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{position:sticky;top:0;background:var(--surface-1);text-align:left;
|
||||
font-size:var(--font-size-xs);text-transform:uppercase;letter-spacing:.06em;
|
||||
font-weight:600;color:var(--text-muted);padding:var(--space-2) var(--space-3);
|
||||
border-bottom:1px solid var(--border-hairline-strong)}
|
||||
td{padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--border-hairline);
|
||||
vertical-align:top}
|
||||
/* The table is full-bleed but its text has to sit on the same gutter as the
|
||||
header, chips and nav above it, which are all --space-6 in. */
|
||||
th:first-child,td:first-child{padding-left:var(--space-6)}
|
||||
th:last-child,td:last-child{padding-right:var(--space-6)}
|
||||
tr:hover td{background:var(--surface-2)}
|
||||
a{color:inherit}
|
||||
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:var(--radius-full);
|
||||
margin-right:var(--space-2)}
|
||||
.dot.success{background:var(--success)} .dot.failure{background:var(--error)}
|
||||
.dot.running{background:var(--warning);animation:p 1.4s ease-in-out infinite}
|
||||
.dot.cancelled{background:var(--text-disabled)}
|
||||
@keyframes p{50%{opacity:.35}}
|
||||
|
||||
.repo{font-weight:600}
|
||||
.org{color:var(--text-muted)}
|
||||
.title{color:var(--text-secondary);max-width:42ch;
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mono{font-family:var(--font-mono);font-size:var(--font-size-sm);color:var(--text-secondary)}
|
||||
.empty{padding:var(--space-12) var(--space-6);text-align:center;color:var(--text-secondary)}
|
||||
footer{padding:var(--space-4) var(--space-6);color:var(--text-muted);
|
||||
font-size:var(--font-size-sm);border-top:1px solid var(--border-hairline)}
|
||||
@media(max-width:760px){.hide-sm{display:none}}
|
||||
@@ -1,90 +0,0 @@
|
||||
# Hanzo CI — this repo's own build, driven by this repo's own reusable workflow.
|
||||
#
|
||||
# hanzoai/ci is two things that belong together: the reusable pipeline every
|
||||
# other repo imports (.hanzo/workflows/build.yml), and ci.hanzo.ai, the
|
||||
# dashboard that shows what that pipeline did. So the dashboard image is built
|
||||
# by the pipeline it reports on — if the pipeline breaks, the thing that would
|
||||
# tell you cannot ship, which is the correct and honest coupling.
|
||||
#
|
||||
# Until now there was no self-build at all: build.yml is workflow_call-only, so
|
||||
# the v0.1.0 image was produced out of band and there was no repeatable way to
|
||||
# cut a second one.
|
||||
images:
|
||||
- name: ci
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/ci
|
||||
|
||||
test:
|
||||
- name: go-vet
|
||||
run: |
|
||||
set -e
|
||||
export GOWORK=off
|
||||
go vet ./...
|
||||
- name: go-unit
|
||||
# The whole tree, not a named list — an allowlist stops covering whatever is
|
||||
# added after it is written. Small repo; ./... costs nothing.
|
||||
#
|
||||
# scope_test.go is the load-bearing one: it asserts the surface refuses a
|
||||
# request with no X-Org-Id and that `?org=` can only narrow. This service
|
||||
# shipped once with those properties absent and disclosed every org's build
|
||||
# metadata to the internet, so a red gate here must block the image.
|
||||
#
|
||||
# render_test.go is the other one that has to stay green: it pins the
|
||||
# vendored @hanzo/brand sheet to the hash of the version it claims to be and
|
||||
# rejects any colour the page names for itself. Both gates are offline —
|
||||
# checking that we use one design system costs this pipeline no npm, no
|
||||
# registry and no network.
|
||||
run: |
|
||||
set -e
|
||||
export GOWORK=off
|
||||
go test -count=1 ./...
|
||||
- name: build-yml-is-one-file
|
||||
# The reusable pipeline is published at TWO paths because two forges read two
|
||||
# directories — github.com only `.github/workflows`, git.hanzo.ai only
|
||||
# `.hanzo/workflows`. That is one artifact spelled twice, and nothing until
|
||||
# now asserted it: the `.hanzo` copy had drifted nine lines (a truncated
|
||||
# second `on:` block and a duplicate `name:` key) and no reader could see it,
|
||||
# because the only consumer of that copy is a forge no push from here reaches.
|
||||
#
|
||||
# The ONLY legitimate difference is the path each names for itself, so
|
||||
# normalise that one spelling and demand byte equality of the rest. A gate
|
||||
# that allowed "the important parts match" would be a gate that cannot say
|
||||
# what important means.
|
||||
run: |
|
||||
set -e
|
||||
norm() { sed 's|\.hanzo/workflows/build\.yml|.github/workflows/build.yml|g' "$1"; }
|
||||
if ! diff -u <(norm .github/workflows/build.yml) <(norm .hanzo/workflows/build.yml); then
|
||||
echo "::error::the two published copies of the reusable have diverged. They are one file at two paths — edit both, or the forge runs a pipeline github.com has never seen."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: .github and .hanzo copies are one file ($(wc -l < .github/workflows/build.yml) lines)"
|
||||
- name: imgver
|
||||
# bin/imgver decides the version EVERY image in the fleet publishes — this
|
||||
# workflow's build lane calls it, and so does the imgver composite action the
|
||||
# repos that hand-roll their own deploy.yml use. A wrong number here is one
|
||||
# tag covering two digests, which a node running imagePullPolicy:
|
||||
# IfNotPresent never picks up and no reader can see. Offline and
|
||||
# deterministic: the registry floor is injected, so it needs no network.
|
||||
run: bash bin/imgver_test.sh
|
||||
- name: gover
|
||||
# bin/gover is the gate the build lane runs against every Dockerfile before
|
||||
# it builds: it refuses a Go builder image older than the go.mod it
|
||||
# compiles. That failure is not hypothetical — the golang images set
|
||||
# GOTOOLCHAIN=local, so the mismatch is a hard mid-build death, and
|
||||
# hanzoai/visor v1.108.16 shipped it. A sweep of the orgs found 54 more
|
||||
# Dockerfiles already below their own go.mod.
|
||||
#
|
||||
# The gate is only worth having if it is exact in BOTH directions: a false
|
||||
# negative lets the next visor through, and a false positive blocks a build
|
||||
# that would have worked, which is how gates get skipped. So the suite pins
|
||||
# the refusals AND the allowances — a newer image than the floor is fine, an
|
||||
# alpine suffix is not a Go patch, a floating tag warns instead of failing,
|
||||
# and a multi-module repo is judged by its NEAREST go.mod. Offline and
|
||||
# deterministic: temp dirs, no registry, no network.
|
||||
run: bash bin/gover_test.sh
|
||||
|
||||
# No `deploy:` ON PURPOSE. Rollout is a reviewed tag pin in hanzoai/universe
|
||||
# (infra/k8s/operator/crs/ci.yaml), the same rule cloud and git follow: a
|
||||
# pipeline that both builds and rolls itself out can put an unreviewed image on
|
||||
# a public host, and cd.hanzo.ai's selfHeal would undo a direct patch anyway.
|
||||
@@ -1,495 +0,0 @@
|
||||
// ci — the dashboard behind ci.hanzo.ai.
|
||||
//
|
||||
// It owns no build state. Run truth lives in Hanzo Git (git.hanzo.ai), which
|
||||
// schedules the jobs and holds every log; this reads that and presents it. The
|
||||
// alternative — a CI service with its own run database — would put two answers
|
||||
// to "did the build pass" in the fleet, and the one users look at would be the
|
||||
// one that can drift. So: git.hanzo.ai is the store, ci.hanzo.ai is the view.
|
||||
//
|
||||
// This is the CI half of the pair. cd.hanzo.ai reconciles image pins from
|
||||
// hanzoai/universe and is the delivery view; the two are deliberately separate
|
||||
// surfaces over separate systems, not one console pretending build and deploy
|
||||
// are the same event.
|
||||
//
|
||||
// Tenancy is the same value everywhere: an org slug. Hanzo Git namespaces repos
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"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)
|
||||
}
|
||||
|
||||
src := &gitSource{base: cfg.gitBase, token: cfg.gitToken, http: &http.Client{Timeout: 20 * time.Second}}
|
||||
cache := &runCache{}
|
||||
|
||||
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
|
||||
// system it reports on down.
|
||||
go poll(ctx, logger, src, cache, cfg)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Liveness only: up means "serving". Readiness deliberately does NOT
|
||||
// gate on having a snapshot — a Hanzo Git outage must show as a stale
|
||||
// dashboard saying so, not as ci.hanzo.ai disappearing from the LB too.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("/v1/runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := requireViewer(w, r, cfg.adminOrg)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
snap := cache.get()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runs": v.visible(snap.Runs, r.URL.Query().Get("org")),
|
||||
"fetchedAt": snap.FetchedAt,
|
||||
"stale": snap.stale(cfg.staleAfter),
|
||||
"sourceErr": snap.errString(),
|
||||
"repos": snap.Repos,
|
||||
"orgs": v.orgs(snap.Runs),
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
v, ok := requireViewer(w, r, cfg.adminOrg)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
renderDashboard(w, cache.get(), v, r.URL.Query().Get("org"), cfg)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.listen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
sh, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sh)
|
||||
}()
|
||||
|
||||
logger.Info("ci dashboard listening", "addr", cfg.listen, "source", cfg.gitBase, "refresh", cfg.refresh.String())
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("serve", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── config ─────────────────────────────
|
||||
|
||||
type config struct {
|
||||
listen string
|
||||
gitBase string
|
||||
// adminOrg is the ONE org whose members see across tenants. It must match
|
||||
// admin-guard's IAM_ADMIN_ORG — the guard decides who gets in, this decides
|
||||
// who sees everything, and a mismatch would silently demote the fleet view to
|
||||
// a single-org view (or, if set too wide, promote a tenant to it).
|
||||
adminOrg string
|
||||
gitToken string
|
||||
refresh time.Duration
|
||||
staleAfter time.Duration
|
||||
scanRepos int
|
||||
runsPer int
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
c := config{
|
||||
listen: env("CI_LISTEN", ":8080"),
|
||||
gitBase: strings.TrimRight(env("CI_GIT_BASE", "https://git.hanzo.ai"), "/"),
|
||||
adminOrg: env("CI_ADMIN_ORG", "admin"),
|
||||
gitToken: os.Getenv("CI_GIT_TOKEN"),
|
||||
scanRepos: envInt("CI_SCAN_REPOS", 60),
|
||||
runsPer: envInt("CI_RUNS_PER_REPO", 8),
|
||||
}
|
||||
c.refresh = time.Duration(envInt("CI_REFRESH_SECONDS", 45)) * time.Second
|
||||
// Stale is a multiple of refresh, not its own knob: the only meaningful
|
||||
// definition of stale is "we have missed several refreshes", and deriving
|
||||
// it means the two can never be configured into contradiction.
|
||||
c.staleAfter = 4 * c.refresh
|
||||
if c.gitToken == "" {
|
||||
return c, errors.New("CI_GIT_TOKEN required (Hanzo Git API token)")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(k string, def int) int {
|
||||
if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ───────────────────────────── model ─────────────────────────────
|
||||
|
||||
// Run is the projection of a Hanzo Git workflow run this dashboard shows. It is
|
||||
// deliberately a SUBSET: the upstream object carries a dozen more fields, and
|
||||
// copying them all would make this a second schema to maintain against theirs.
|
||||
type Run struct {
|
||||
ID int64 `json:"id"`
|
||||
Org string `json:"org"`
|
||||
Repo string `json:"repo"`
|
||||
Workflow string `json:"workflow"`
|
||||
Title string `json:"title"`
|
||||
|
||||
// Status and Conclusion are BOTH required to know how a run went, and
|
||||
// reading only one is wrong in a way that looks fine. Status answers
|
||||
// "is it over" (queued | in_progress | completed); Conclusion answers
|
||||
// "how did it end" and is empty until it is over. A view that buckets on
|
||||
// Status alone sees `completed` and cannot tell a pass from a failure —
|
||||
// which is exactly the bug this pair replaced: every finished run,
|
||||
// including successes and cancellations, was being drawn as failing.
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
|
||||
Event string `json:"event"`
|
||||
Branch string `json:"branch"`
|
||||
SHA string `json:"sha"`
|
||||
Actor string `json:"actor"`
|
||||
Number int `json:"number"`
|
||||
URL string `json:"url"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt"`
|
||||
}
|
||||
|
||||
// Duration is zero-valued rather than negative when a run has not finished —
|
||||
// callers render "running", and a negative duration would print as one.
|
||||
func (r Run) Duration() time.Duration {
|
||||
if r.StartedAt.IsZero() || r.EndedAt.IsZero() || r.EndedAt.Before(r.StartedAt) {
|
||||
return 0
|
||||
}
|
||||
return r.EndedAt.Sub(r.StartedAt)
|
||||
}
|
||||
|
||||
type snapshot struct {
|
||||
Runs []Run `json:"runs"`
|
||||
Repos int `json:"repos"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Err error `json:"-"`
|
||||
}
|
||||
|
||||
func (s snapshot) stale(after time.Duration) bool {
|
||||
return s.FetchedAt.IsZero() || time.Since(s.FetchedAt) > after
|
||||
}
|
||||
|
||||
func (s snapshot) errString() string {
|
||||
if s.Err == nil {
|
||||
return ""
|
||||
}
|
||||
return s.Err.Error()
|
||||
}
|
||||
|
||||
type runCache struct {
|
||||
mu sync.RWMutex
|
||||
snap snapshot
|
||||
}
|
||||
|
||||
func (c *runCache) get() snapshot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.snap
|
||||
}
|
||||
|
||||
// put keeps the LAST GOOD run list when a refresh fails, recording the error
|
||||
// alongside it. A failed poll must not blank the dashboard: "Hanzo Git is
|
||||
// unreachable, here is what we last saw" is strictly more useful than an empty
|
||||
// page, which reads as "nothing is building".
|
||||
func (c *runCache) put(s snapshot) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if s.Err != nil && len(s.Runs) == 0 && len(c.snap.Runs) > 0 {
|
||||
prev := c.snap
|
||||
prev.Err = s.Err
|
||||
c.snap = prev
|
||||
return
|
||||
}
|
||||
c.snap = s
|
||||
}
|
||||
|
||||
// ───────────────────────────── source ─────────────────────────────
|
||||
|
||||
type gitSource struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func (g *gitSource) getJSON(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+g.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := g.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: %s", path, resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
type repoRef struct {
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
// repos returns the most recently ACTIVE repositories. Sorting by activity and
|
||||
// taking a window is the whole scan strategy: the instance mirrors ~1400 repos
|
||||
// and almost none of them built in the last hour, so walking all of them would
|
||||
// spend the entire refresh budget confirming silence.
|
||||
func (g *gitSource) repos(ctx context.Context, limit int) ([]string, error) {
|
||||
var body struct {
|
||||
Data []repoRef `json:"data"`
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sort", "updated")
|
||||
q.Set("order", "desc")
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
if err := g.getJSON(ctx, "/v1/repos/search?"+q.Encode(), &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(body.Data))
|
||||
for _, r := range body.Data {
|
||||
if r.FullName != "" {
|
||||
names = append(names, r.FullName)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
type apiRun struct {
|
||||
ID int64 `json:"id"`
|
||||
DisplayTitle string `json:"display_title"`
|
||||
Path string `json:"path"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
RunNumber int `json:"run_number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
Actor struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"actor"`
|
||||
}
|
||||
|
||||
func (g *gitSource) runs(ctx context.Context, fullName string, limit int) ([]Run, error) {
|
||||
var body struct {
|
||||
WorkflowRuns []apiRun `json:"workflow_runs"`
|
||||
}
|
||||
path := fmt.Sprintf("/v1/repos/%s/actions/runs?limit=%d", fullName, limit)
|
||||
if err := g.getJSON(ctx, path, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
org, repo := splitFullName(fullName)
|
||||
out := make([]Run, 0, len(body.WorkflowRuns))
|
||||
for _, r := range body.WorkflowRuns {
|
||||
out = append(out, Run{
|
||||
ID: r.ID,
|
||||
Org: org,
|
||||
Repo: repo,
|
||||
Workflow: workflowOf(r.Path),
|
||||
Title: r.DisplayTitle,
|
||||
Status: r.Status,
|
||||
Conclusion: r.Conclusion,
|
||||
Event: r.Event,
|
||||
Branch: r.HeadBranch,
|
||||
SHA: shortSHA(r.HeadSHA),
|
||||
Actor: r.Actor.Login,
|
||||
Number: r.RunNumber,
|
||||
URL: r.HTMLURL,
|
||||
StartedAt: parseTime(r.StartedAt),
|
||||
EndedAt: parseTime(r.CompletedAt),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// poll refreshes the snapshot on an interval, forever.
|
||||
func poll(ctx context.Context, logger *slog.Logger, src *gitSource, cache *runCache, cfg config) {
|
||||
refresh := func() {
|
||||
rctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
names, err := src.repos(rctx, cfg.scanRepos)
|
||||
if err != nil {
|
||||
logger.Warn("repo scan failed", "err", err)
|
||||
cache.put(snapshot{FetchedAt: time.Now().UTC(), Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// Fan out, bounded. The cap is small on purpose: this is a read against
|
||||
// the forge that schedules every build in the fleet, and a dashboard is
|
||||
// never worth degrading it.
|
||||
const workers = 6
|
||||
var (
|
||||
mu sync.Mutex
|
||||
all []Run
|
||||
errs []string
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
jobs := make(chan string)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for name := range jobs {
|
||||
rs, err := src.runs(rctx, name, cfg.runsPer)
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
// A repo with Actions disabled 404s. That is normal and
|
||||
// not worth surfacing as a dashboard-level failure, so
|
||||
// it is counted, not shown.
|
||||
errs = append(errs, name)
|
||||
} else {
|
||||
all = append(all, rs...)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, n := range names {
|
||||
select {
|
||||
case jobs <- n:
|
||||
case <-rctx.Done():
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].StartedAt.After(all[j].StartedAt) })
|
||||
cache.put(snapshot{Runs: all, Repos: len(names) - len(errs), FetchedAt: time.Now().UTC()})
|
||||
logger.Info("refreshed", "repos", len(names), "withRuns", len(names)-len(errs), "runs", len(all))
|
||||
}
|
||||
|
||||
refresh()
|
||||
t := time.NewTicker(cfg.refresh)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
func splitFullName(s string) (org, repo string) {
|
||||
if i := strings.IndexByte(s, '/'); i > 0 {
|
||||
return s[:i], s[i+1:]
|
||||
}
|
||||
return "", s
|
||||
}
|
||||
|
||||
// workflowOf reduces "e2e.yml@refs/heads/main" to "e2e.yml".
|
||||
func workflowOf(path string) string {
|
||||
if i := strings.IndexByte(path, '@'); i > 0 {
|
||||
return path[:i]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func shortSHA(s string) string {
|
||||
if len(s) > 7 {
|
||||
return s[:7]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
// Hanzo Git reports an unset timestamp as the Unix epoch rather than null;
|
||||
// treated as absent so the UI shows "—" instead of 1970.
|
||||
if t.Year() < 2000 {
|
||||
return time.Time{}
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
func filterByOrg(runs []Run, org string) []Run {
|
||||
if org == "" {
|
||||
return runs
|
||||
}
|
||||
out := make([]Run, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
if r.Org == org {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orgsOf(runs []Run) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, r := range runs {
|
||||
if r.Org != "" {
|
||||
seen[r.Org] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for o := range seen {
|
||||
out = append(out, o)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// render.go — the HTML view. Server-rendered on purpose: this page is a table
|
||||
// of build results, and a client-side app would ship a bundle, a fetch layer
|
||||
// and a loading state to show the same rows a second later. The dashboard also
|
||||
// has to be readable when the thing it reports on is broken, which is exactly
|
||||
// when a build pipeline for its own frontend is the wrong dependency.
|
||||
//
|
||||
// That argument is about the BUILD, not about the design. The look is the
|
||||
// house's and is not restated here: the <style> block is @hanzo/brand's own
|
||||
// published token sheet plus this page's layout rules, both compiled in — see
|
||||
// brand.go. Server rendering and one design system are not in tension; only
|
||||
// server rendering and a JS component library are, and it is the tokens, not
|
||||
// the components, that this page ever needed.
|
||||
|
||||
// renderDashboard writes the page for ONE viewer. Every row it renders has
|
||||
// already passed v.visible — the template is never handed the full snapshot and
|
||||
// asked to be careful with it, because a template that can see everything is one
|
||||
// edit away from showing it.
|
||||
func renderDashboard(w http.ResponseWriter, snap snapshot, v viewer, org string, cfg config) {
|
||||
runs := v.visible(snap.Runs, org)
|
||||
if len(runs) > 200 {
|
||||
runs = runs[:200]
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Runs []Run
|
||||
Orgs []string
|
||||
Org string
|
||||
Viewer string
|
||||
Sudo bool
|
||||
Repos int
|
||||
FetchedAt time.Time
|
||||
Age string
|
||||
Stale bool
|
||||
SourceErr string
|
||||
Source string
|
||||
Counts map[string]int
|
||||
}{
|
||||
Runs: runs,
|
||||
Orgs: v.orgs(snap.Runs),
|
||||
Org: org,
|
||||
Viewer: v.org,
|
||||
Sudo: v.sudo,
|
||||
Repos: snap.Repos,
|
||||
FetchedAt: snap.FetchedAt,
|
||||
Age: humanAge(snap.FetchedAt),
|
||||
Stale: snap.stale(cfg.staleAfter),
|
||||
SourceErr: snap.errString(),
|
||||
Source: cfg.gitBase,
|
||||
Counts: countByOutcome(runs),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// countByOutcome buckets runs for the summary strip.
|
||||
func countByOutcome(runs []Run) map[string]int {
|
||||
c := map[string]int{"success": 0, "failure": 0, "running": 0, "cancelled": 0}
|
||||
for _, r := range runs {
|
||||
c[outcome(r)]++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// outcome collapses (status, conclusion) into the four states worth a colour.
|
||||
//
|
||||
// Status alone is NOT enough and getting this wrong is silent: Hanzo Git
|
||||
// reports every finished run as `completed` regardless of how it went, so
|
||||
// bucketing on status painted successes and cancellations as failures — on the
|
||||
// live instance that was 15 of 20 runs mislabelled red.
|
||||
//
|
||||
// `cancelled` gets its own bucket rather than folding into failure. On this
|
||||
// fleet cancellations are the single largest category (superseded pushes cancel
|
||||
// the in-flight run), and a board that shows them as broken is a board nobody
|
||||
// trusts, which is worse than no board.
|
||||
func outcome(r Run) string {
|
||||
if !strings.EqualFold(r.Status, "completed") {
|
||||
return "running" // queued | in_progress | waiting | blocked
|
||||
}
|
||||
switch strings.ToLower(r.Conclusion) {
|
||||
case "success":
|
||||
return "success"
|
||||
case "cancelled", "canceled", "skipped":
|
||||
return "cancelled"
|
||||
case "":
|
||||
// Completed with no conclusion should not happen; if it does, say
|
||||
// "running" rather than inventing a verdict the data does not support.
|
||||
return "running"
|
||||
default:
|
||||
return "failure" // failure | timed_out | action_required
|
||||
}
|
||||
}
|
||||
|
||||
func humanAge(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds ago", int(d.Seconds()))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
default:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
}
|
||||
}
|
||||
|
||||
func humanDur(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "—"
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
|
||||
// `class="dark"` is @hanzo/brand's own dark hook, not a local convention: the
|
||||
// sheet's :root IS the dark scale, and the class is what additionally sets
|
||||
// color-scheme so the scrollbars and form controls the browser draws match.
|
||||
// Elsewhere in the fleet next-themes toggles that class; this page has no JS and
|
||||
// no toggle, so it states its scheme once and means it.
|
||||
var tmpl = template.Must(template.New("ci").Funcs(template.FuncMap{
|
||||
"outcome": outcome,
|
||||
"dur": func(r Run) string { return humanDur(r.Duration()) },
|
||||
"ago": humanAge,
|
||||
"css": pageCSS,
|
||||
}).Parse(`<!doctype html>
|
||||
<html lang="en" class="dark"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hanzo CI</title>
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<style>{{css}}</style></head><body>
|
||||
|
||||
<header>
|
||||
<h1>Hanzo <span>CI</span></h1>
|
||||
<div class="meta">
|
||||
{{.Repos}} repos · refreshed {{.Age}}<br>
|
||||
source {{.Source}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="strip">
|
||||
<span class="chip ok">passing <b>{{index .Counts "success"}}</b></span>
|
||||
<span class="chip fail">failing <b>{{index .Counts "failure"}}</b></span>
|
||||
<span class="chip run">running <b>{{index .Counts "running"}}</b></span>
|
||||
<span class="chip cancel">cancelled <b>{{index .Counts "cancelled"}}</b></span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
{{if .Sudo}}<a href="/" {{if eq .Org ""}}class="on"{{end}}>all orgs</a>{{end}}
|
||||
{{range .Orgs}}<a href="/?org={{.}}" {{if eq $.Org .}}class="on"{{end}}>{{.}}</a>{{end}}
|
||||
<span class="who">signed in as {{.Viewer}}{{if .Sudo}} · fleet view{{end}}</span>
|
||||
</nav>
|
||||
|
||||
{{if .Stale}}<div class="warn">
|
||||
Snapshot is stale — last successful refresh {{.Age}}.
|
||||
{{if .SourceErr}}Hanzo Git said: {{.SourceErr}}{{else}}Hanzo Git is not answering.{{end}}
|
||||
These rows are the last good read, not current state.
|
||||
</div>{{end}}
|
||||
|
||||
{{if .Runs}}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Repository</th><th>Workflow</th><th class="hide-sm">Commit</th>
|
||||
<th class="hide-sm">Actor</th><th>Started</th><th>Took</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Runs}}
|
||||
<tr>
|
||||
<td><span class="dot {{outcome .}}"></span><a href="{{.URL}}"><span class="org">{{.Org}}/</span><span class="repo">{{.Repo}}</span></a></td>
|
||||
<td>{{.Workflow}} <span class="mono">#{{.Number}}</span><div class="title">{{.Title}}</div></td>
|
||||
<td class="hide-sm mono">{{.Branch}}@{{.SHA}}<div>{{.Event}}</div></td>
|
||||
<td class="hide-sm mono">{{.Actor}}</td>
|
||||
<td class="mono">{{ago .StartedAt}}</td>
|
||||
<td class="mono">{{dur .}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody></table>
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
No runs in the scanned window.<br>
|
||||
<span class="mono">Builds land here from {{.Source}} — this view holds no state of its own.</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<footer>
|
||||
Build truth lives in Hanzo Git; this is a view over it.
|
||||
Delivery is <a href="https://cd.hanzo.ai">cd.hanzo.ai</a>.
|
||||
</footer>
|
||||
</body></html>`))
|
||||
@@ -1,78 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// render_test.go guards the two properties the view has to keep: that its design
|
||||
// values come from exactly one place, and that it can only ever draw rows the
|
||||
// viewer was already permitted to see.
|
||||
|
||||
// TestBrandCSSIsUpstreamBytes is the pin. The vendored sheet is only a source of
|
||||
// truth while it is byte-for-byte what @hanzo/brand published; the moment it can
|
||||
// be edited in place it is a fork wearing an upstream name, which is the exact
|
||||
// state this repo was in when it carried its own :root block.
|
||||
func TestBrandCSSIsUpstreamBytes(t *testing.T) {
|
||||
sum := sha256.Sum256([]byte(brandCSS))
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if got != brandCSSSHA256 {
|
||||
t.Fatalf("brand/variables.css is not @hanzo/brand@%s\n got %s\n want %s\n"+
|
||||
"A token refresh: re-fetch the sheet and set brandCSSSHA256 to the got value.\n"+
|
||||
"A local colour edit: make it in @hanzo/brand and release it, not here.",
|
||||
brandCSSVersion, got, brandCSSSHA256)
|
||||
}
|
||||
}
|
||||
|
||||
// colourLiteral matches a value that decides an appearance on its own — a hex,
|
||||
// or an rgb()/hsl() function. `color-mix(in srgb, var(--x) ...)` is deliberately
|
||||
// not one of these: it derives from a token instead of naming a new colour.
|
||||
var colourLiteral = regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(`)
|
||||
|
||||
// TestDashboardCSSNamesNoColours is what makes "one source of truth" a fact
|
||||
// rather than an intention. Vendoring the sheet is only half the job; if the
|
||||
// page can still write a hex next to it, the second palette grows back one
|
||||
// "just this once" at a time — which is how the old :root block came to hold
|
||||
// GitHub's status colours instead of the house's.
|
||||
func TestDashboardCSSNamesNoColours(t *testing.T) {
|
||||
if m := colourLiteral.FindAllString(dashboardCSS, -1); len(m) > 0 {
|
||||
t.Fatalf("dashboard.css names colours directly: %v\n"+
|
||||
"Every colour must be a var() into @hanzo/brand; if the token you need "+
|
||||
"does not exist, add it there rather than here.", m)
|
||||
}
|
||||
if !strings.Contains(dashboardCSS, "var(--") {
|
||||
t.Fatal("dashboard.css references no tokens at all — it has stopped consuming the design system")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderedPageShowsOnlyTheViewersOrg drives the HTML, not the predicates.
|
||||
// scope_test.go proves visible() and orgs() are right; this proves the page is
|
||||
// actually built from them — the leak that started all of this was a handler
|
||||
// handing a template more than the viewer was owed, and a template cannot be
|
||||
// trusted to be careful with a snapshot it can see all of.
|
||||
func TestRenderedPageShowsOnlyTheViewersOrg(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
renderDashboard(w, snapshot{Runs: testRuns(), Repos: 3}, viewer{org: "lux"}, "", config{})
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, ">lux/<") {
|
||||
t.Fatal("lux viewer's own run is missing from the page")
|
||||
}
|
||||
// Rows: no other org's repo may be drawn.
|
||||
for _, leaked := range []string{">hanzo/<", ">zoo/<"} {
|
||||
if strings.Contains(body, leaked) {
|
||||
t.Errorf("page rendered %s to a lux viewer", leaked)
|
||||
}
|
||||
}
|
||||
// Nav: nor may another org's NAME, which discloses who builds here even
|
||||
// when their runs are correctly hidden.
|
||||
for _, leaked := range []string{"/?org=hanzo", "/?org=zoo", "all orgs"} {
|
||||
if strings.Contains(body, leaked) {
|
||||
t.Errorf("nav offered %q to a lux viewer", leaked)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scope.go answers exactly one question: whose builds may THIS request see?
|
||||
//
|
||||
// It exists because the first cut of this service conflated a FILTER with a
|
||||
// GATE. `?org=lux` narrowed what was rendered and read like tenancy, but it
|
||||
// decided nothing about who was allowed to ask — so /v1/runs answered 200 to
|
||||
// anyone on the internet with every org's repo names, branches, commit SHAs and
|
||||
// actor logins. A query parameter is a request for a view; it can never be the
|
||||
// authority for one.
|
||||
//
|
||||
// The authority is X-Org-Id, minted by admin-guard from the IAM-verified `owner`
|
||||
// claim and written onto the request by the ingress middleware's
|
||||
// authResponseHeaders. Traefik OVERWRITES any client-sent X-Org-Id with the
|
||||
// guard's value, so on the wired path the header cannot be forged. This file
|
||||
// still treats its ABSENCE as fatal rather than as "no filter", because absence
|
||||
// is the signal that the request did not come through the guard at all.
|
||||
|
||||
// orgHeader is the identity the whole surface is scoped by. One name, one
|
||||
// meaning, platform-wide (see the X-* header convention: X-Org-Id is the org
|
||||
// slug from the JWT `owner` claim).
|
||||
const orgHeader = "X-Org-Id"
|
||||
|
||||
// viewer is the resolved, trusted answer. Constructed only from headers the
|
||||
// guard controls — never from the query string, never from a cookie.
|
||||
type viewer struct {
|
||||
// org is the caller's home org slug, from the verified `owner` claim.
|
||||
org string
|
||||
// sudo reports whether org is the platform admin org, which is the ONE
|
||||
// identity that may see across tenants (the fleet view).
|
||||
sudo bool
|
||||
}
|
||||
|
||||
// resolveViewer lifts the guard-set header into a viewer. It fails closed: a
|
||||
// missing or blank X-Org-Id yields ok=false and the caller MUST refuse the
|
||||
// request.
|
||||
//
|
||||
// Defaulting an absent header to "no filter" is the specific bug this function
|
||||
// exists to prevent — that default is what turns "reached ci without the guard"
|
||||
// into "rendered every org's builds".
|
||||
func resolveViewer(r *http.Request, adminOrg string) (viewer, bool) {
|
||||
org := strings.TrimSpace(r.Header.Get(orgHeader))
|
||||
if org == "" {
|
||||
return viewer{}, false
|
||||
}
|
||||
return viewer{org: org, sudo: strings.EqualFold(org, strings.TrimSpace(adminOrg))}, true
|
||||
}
|
||||
|
||||
// visible narrows runs to what v is permitted to see, then applies want (the
|
||||
// optional `?org=` selection) WITHIN that permission.
|
||||
//
|
||||
// The ordering is the whole point: permission is applied first and `want` can
|
||||
// only ever narrow the result. A lux viewer asking for `?org=hanzo` gets an
|
||||
// empty list, not hanzo's builds — the parameter selects among what you may
|
||||
// already see, it never reaches for more.
|
||||
func (v viewer) visible(runs []Run, want string) []Run {
|
||||
want = strings.TrimSpace(want)
|
||||
if v.sudo {
|
||||
// The fleet view: every org, narrowed by the requested one if given.
|
||||
return filterByOrg(runs, want)
|
||||
}
|
||||
if want != "" && !strings.EqualFold(want, v.org) {
|
||||
return nil
|
||||
}
|
||||
return filterByOrg(runs, v.org)
|
||||
}
|
||||
|
||||
// orgs lists the org tabs this viewer may choose between. A tenant gets exactly
|
||||
// its own org — rendering the full org list to a tenant would leak the set of
|
||||
// orgs that build on the platform even though their runs are correctly hidden.
|
||||
func (v viewer) orgs(runs []Run) []string {
|
||||
if v.sudo {
|
||||
return orgsOf(runs)
|
||||
}
|
||||
return []string{v.org}
|
||||
}
|
||||
|
||||
// requireViewer resolves the viewer or writes the refusal. It returns ok=false
|
||||
// when the request must not proceed.
|
||||
func requireViewer(w http.ResponseWriter, r *http.Request, adminOrg string) (viewer, bool) {
|
||||
v, ok := resolveViewer(r, adminOrg)
|
||||
if ok {
|
||||
return v, true
|
||||
}
|
||||
// 403, not 401: a 401 invites a credential retry, but there is nothing the
|
||||
// CALLER can add to fix this. The header is set by infrastructure, so its
|
||||
// absence is a routing fault (ci reached off-guard) and the honest answer is
|
||||
// that this path is not authorized to serve, whoever is asking.
|
||||
http.Error(w, "forbidden: no "+orgHeader+" (this service is only reachable through the IAM gate)", http.StatusForbidden)
|
||||
return viewer{}, false
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// scope_test.go is the regression suite for the leak this service shipped with:
|
||||
// /v1/runs answered 200 to anyone, with every org's repo names, branches, commit
|
||||
// SHAs and actor logins, because `?org=` was a filter being used as a gate.
|
||||
//
|
||||
// The properties asserted here are the ones that made it a leak, not merely the
|
||||
// ones that make the new code work.
|
||||
|
||||
func testRuns() []Run {
|
||||
return []Run{
|
||||
{Org: "hanzo", Repo: "cloud", Workflow: "build", Status: "completed", Conclusion: "success"},
|
||||
{Org: "lux", Repo: "node", Workflow: "build", Status: "completed", Conclusion: "failure"},
|
||||
{Org: "zoo", Repo: "app", Workflow: "test", Status: "in_progress"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoOrgHeaderIsRefused is the core fix. An absent X-Org-Id means the request
|
||||
// did not come through the IAM gate; the ONLY safe answer is to refuse. The old
|
||||
// code treated the equivalent condition (no `?org=`) as "show everything".
|
||||
func TestNoOrgHeaderIsRefused(t *testing.T) {
|
||||
for _, hdr := range []string{"", " "} {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/runs", nil)
|
||||
if hdr != "" {
|
||||
r.Header.Set(orgHeader, hdr)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
v, ok := requireViewer(w, r, "admin")
|
||||
if ok {
|
||||
t.Fatalf("X-Org-Id=%q admitted as viewer %+v — absence must fail closed", hdr, v)
|
||||
}
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("X-Org-Id=%q: status=%d want 403", hdr, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantCannotWidenWithQueryParam is the attack the original design invited:
|
||||
// the caller picks the org. Now the header decides and the parameter may only
|
||||
// narrow, so a lux viewer asking for hanzo's builds gets nothing — NOT hanzo's
|
||||
// builds, and not a silent fallback to its own either (that would be confusing,
|
||||
// but it is the empty answer that matters for security).
|
||||
func TestTenantCannotWidenWithQueryParam(t *testing.T) {
|
||||
lux := viewer{org: "lux"}
|
||||
|
||||
got := lux.visible(testRuns(), "hanzo")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("lux viewer asking ?org=hanzo saw %d runs (%+v) — must see none", len(got), got)
|
||||
}
|
||||
|
||||
own := lux.visible(testRuns(), "")
|
||||
if len(own) != 1 || own[0].Org != "lux" {
|
||||
t.Fatalf("lux viewer saw %+v; want exactly its own org", own)
|
||||
}
|
||||
if same := lux.visible(testRuns(), "lux"); len(same) != 1 {
|
||||
t.Errorf("lux viewer asking ?org=lux saw %d runs; want its own 1", len(same))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSudoSeesFleetAndCanNarrow asserts the admin org keeps the cross-tenant
|
||||
// view that makes this dashboard useful to the platform, and that `?org=` still
|
||||
// works as a plain filter for it.
|
||||
func TestSudoSeesFleetAndCanNarrow(t *testing.T) {
|
||||
sudo := viewer{org: "admin", sudo: true}
|
||||
|
||||
if all := sudo.visible(testRuns(), ""); len(all) != 3 {
|
||||
t.Fatalf("sudo saw %d runs; want all 3", len(all))
|
||||
}
|
||||
one := sudo.visible(testRuns(), "zoo")
|
||||
if len(one) != 1 || one[0].Org != "zoo" {
|
||||
t.Fatalf("sudo ?org=zoo saw %+v; want zoo only", one)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveViewerSudoDetection pins the sudo bit to the configured admin org,
|
||||
// case-insensitively, and proves an ordinary org never gets it.
|
||||
func TestResolveViewerSudoDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
hdr, adminOrg string
|
||||
wantSudo bool
|
||||
}{
|
||||
{"admin", "admin", true},
|
||||
{"ADMIN", "admin", true},
|
||||
{" admin ", "admin", true},
|
||||
{"lux", "admin", false},
|
||||
{"administrator", "admin", false}, // prefix must not match
|
||||
{"admin", "root", false}, // honours a non-default admin org
|
||||
}
|
||||
for _, tc := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set(orgHeader, tc.hdr)
|
||||
v, ok := resolveViewer(r, tc.adminOrg)
|
||||
if !ok {
|
||||
t.Fatalf("X-Org-Id=%q: not resolved", tc.hdr)
|
||||
}
|
||||
if v.sudo != tc.wantSudo {
|
||||
t.Errorf("X-Org-Id=%q adminOrg=%q: sudo=%v want %v", tc.hdr, tc.adminOrg, v.sudo, tc.wantSudo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantOrgListIsNotTheFleetList covers the quieter leak: even with runs
|
||||
// correctly hidden, rendering every org's NAME in the nav would disclose the set
|
||||
// of orgs that build on the platform.
|
||||
func TestTenantOrgListIsNotTheFleetList(t *testing.T) {
|
||||
lux := viewer{org: "lux"}
|
||||
orgs := lux.orgs(testRuns())
|
||||
if len(orgs) != 1 || orgs[0] != "lux" {
|
||||
t.Fatalf("tenant org list = %v; want only its own org", orgs)
|
||||
}
|
||||
if sudoOrgs := (viewer{org: "admin", sudo: true}).orgs(testRuns()); len(sudoOrgs) != 3 {
|
||||
t.Errorf("sudo org list = %v; want all 3", sudoOrgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunsEndpointScopesEndToEnd drives the actual HTTP handler wiring, not just
|
||||
// the predicates — the leak was in the handler, so the handler is what must be
|
||||
// asserted.
|
||||
func TestRunsEndpointScopesEndToEnd(t *testing.T) {
|
||||
cache := &runCache{}
|
||||
cache.put(snapshot{Runs: testRuns(), Repos: 3})
|
||||
cfg := config{adminOrg: "admin"}
|
||||
|
||||
h := func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := requireViewer(w, r, cfg.adminOrg)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
snap := cache.get()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runs": v.visible(snap.Runs, r.URL.Query().Get("org")),
|
||||
"orgs": v.orgs(snap.Runs),
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("anonymous → 403", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h(w, httptest.NewRequest(http.MethodGet, "/v1/runs", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d want 403; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "cloud") || strings.Contains(w.Body.String(), "node") {
|
||||
t.Error("refusal body leaked repo names")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lux viewer sees only lux, even asking for hanzo", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/runs?org=hanzo", nil)
|
||||
r.Header.Set(orgHeader, "lux")
|
||||
w := httptest.NewRecorder()
|
||||
h(w, r)
|
||||
|
||||
var got struct {
|
||||
Runs []Run `json:"runs"`
|
||||
Orgs []string `json:"orgs"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got.Runs) != 0 {
|
||||
t.Errorf("lux asking ?org=hanzo got %+v; want none", got.Runs)
|
||||
}
|
||||
if len(got.Orgs) != 1 || got.Orgs[0] != "lux" {
|
||||
t.Errorf("orgs=%v; want [lux]", got.Orgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user