Files
zooqueenandHanzo Dev 1a77be3758 Restyle the dashboard in Hanzo's monochrome
The palette is the --hz-* tokens from @hanzo/design and the hanzo.ai app
shell: a black page, #171717 surfaces, #2e2e2e hairlines, white ink and a
#a3a3a3 secondary, inverted under the light theme and stamped on <html> so
tooltips, menus and suggestion lists follow it. The kit's blue accent ramp
points at them, so no blue is left in the chrome; state keeps its hue
(green, amber, red, at 60% on edges and the ratio bar), the neutral states
take three grey rungs, and native checkboxes and radios take the ink.

- Sidebar: one small mark and "Hanzo CD", the version only when the build
  was stamped, line icons from the app shell's set, rows that tint for the
  current page, and the list filters and their text fields restyled to
  match.
- Sentence case everywhere: no uppercase transforms, and the all-caps
  titles, labels and table headers are written in sentence case.
- Buttons: the first toolbar action is the white pill; the rest, tile
  actions, banner actions and filter clears are ghosts. The search box,
  view switch, health bar toggle, sort and Log out are quiet controls, and
  icon-only buttons carry their names.
- Font Awesome 7 ignored the global $fa-font-path and asked for
  /webfonts/*, so every fa icon drew an empty box; main.scss configures its
  module with the assets/fonts path the build copies them to.
- The empty list is a small outline icon, one line and a ghost action.
- On a phone the sidebar is a 56px rail that opens over the page (Escape or
  the scrim closes it), and the toolbar wraps and scrolls with the page.
- The operator banner, the clusters notice, tooltips, resource icons and the
  pod stats bar use the same tokens, and sit beside the rail when the
  sidebar is collapsed.
- Keyboard focus draws one ring from the tokens, and the login card keeps
  its own colours when a session ends inside the light theme.

The smoke gate also fails on any font that does not load, and checks the
sidebar's mark, name and version, the empty state, the phone rail, a filter
suggestion over the sidebar, the collapsed toolbar, the network view and the
login page after a session ends.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-15 23:15:39 -07:00

24 KiB
Raw Permalink Blame History

hanzoai/cd — agent guide

Hanzo CD: the continuous-delivery control plane behind cd.hanzo.ai. It reconciles declared state from hanzoai/universe into the clusters.

Lineage, stated plainly. This is a fork of argoproj/argo-cd, Apache-2.0. Module github.com/hanzoai/cd. Upstream's Application, ApplicationSet and AppProject are here under apps.hanzo.ai/v1, and the controllers keep their upstream shape (controller/appcontroller.go, applicationset/, cmpserver/, commitserver/). Knowing that is the fastest way to understand any file here — but do not carry upstream's project rules across: Hanzo CD is not a CNCF project, has no upstream PR gate, and per the house rule work lands on main, not in a PR queue.

What CD actually reconciles here

Two planes, and mixing them up is the most common mistake:

plane where who reconciles
chart values universe charts/app/values/<ns>/<name>.yaml the fleet ApplicationSet (git files generator charts/app/values/*/*.yaml)
operator CRs universe infra/k8s/operator/crs/*.yaml the universe-crs Application, selfHeal

New services go in the chart plane. 57 services already moved there, and crs/kustomization.yaml states outright that the App CRD is no longer how those are declared. A CR dropped into crs/ that is not listed in that kustomization is not "inactive" — CD keeps the live object flagged requiresPruning and holds the whole application OutOfSync, so the file is silently inert. That failure looks like Synced/Healthy with the service simply absent.

Sync is manual by convention. 268 of 277 Applications carry no automated policy; 215 sit OutOfSync / Healthy on purpose. A generated Application at OutOfSync / Missing is a new workload awaiting a person, not a fault.

mirror/ — which repos live on the forge, and which way they sync

mirror/ plus cmd/cd-mirror push our own repos FORWARD onto git.hanzo.ai. It is a subcommand of the one CD binary, dispatched by argv[0] like every other hanzocd-*, so it travels in the image the control plane already builds and pins — no second artifact to version, and no second chance for the two to disagree about which repos exist. The table ships beside it at common.DefaultMirrorTable, so the JSON validated in CI is the JSON a scheduled run reads.

It replaces ONE of four legs in hanzoai/mirrors. Deleting that repo now loses work:

leg every what it does here?
sync.py 15m push native repos forward onto the forge yes
reconcile.py 6h create/refresh the pull-mirror for every qualifying repo across 15 orgs — how a new repo becomes visible here at all no
releases.py 6h copy releases and their assets from GitHub and GitLab no
sync-from-github 10m keeps that repo itself current dies with it

The differences below are the bugs sync.py had — each of which failed silently:

  • Names are resolved, never matched against a listing. Resolve and List are separate methods so a call site must choose. GET /repos/{org}/{name} follows a rename and a transfer permanently; a listing reports a repo only under its current name in its current org. Six entries whose repos had moved were never visited once, because an absent name raises nothing.

  • The table is config (mirror/repos.json), not a list literal. Adding a repo used to be a code edit. hanzoai/cloud was in neither leg — is_mirror=0 so nothing pulled in, absent from the table so nothing pushed in — and its CI ran on a commit four behind for a day while merely looking red. 40 entries, and the port originally kept 24 of them: hanzoai/ci was among the sixteen dropped, the reusable pipeline every repo in three orgs imports, where a stale pipeline runs green and no caller can tell. Two tests now guard the count and the allowlist.

  • Owned is an allowlist and deliberately broader than the table. A declared repo can be transferred, and owned guards the name GitHub RESOLVES to — so the org a repo may land in tomorrow has to be listed today. The live run proves the point: eight of the forty resolve only because names are followed (three moved to hanzo-apps, three to hanzo-inc, esign→sign, kms-v1→kms), and the three in hanzo-inc were REFUSED by name until that org was listed rather than pushed into an org nobody had vouched for.

  • Direction is one typed value. mirror previously meant both a vestigial git config flag and the forge's real push refspecs; reading the wrong one gives a confident wrong answer about whether a push can delete history.

  • Fast-forward is git's guarantee, not the program's — no leading + in the refspec. A check in Go can be reasoned around by a later edit; a missing + cannot. TestFastForwardNeverForces asserts on the argument, so it fails if anyone adds one. A push mirror once pruned the v1.0–v1.31 tag range off an IAM repo and the objects were then collected.

  • Divergence is reported and left alone, exiting non-zero. Two people disagreeing about history is a question for a person; settling it destroys whichever side lost.

  • Absent credentials fail closed. An empty token yields a 401 that reads like a permissions problem — the shape that hid a broken npm publish for weeks, every request unauthenticated because a name resolved to "" and nothing said so.

  • Every git command runs in a repository. RealGit takes the directory; a scheduled job starts in none, and git answers fatal: not a git repository to the fetch — on the branch that reports GITHUB as unreadable. Perfect credentials and a perfect table would still have produced a fleet-wide "cannot read github", and the dry run would have stayed green throughout, because a dry run never touches git. Workspace keeps one object store per repo under a root that MAY survive between runs; it is an optimisation, so an empty root is equally correct.

  • Both sides are fetched, forge first. Its tip has to be local before a real divergence can be NAMED rather than reported as "ancestry undetermined" — and once its history is present those objects are offered in negotiation, so the internet side of a run is a delta even on an empty store. That is what makes a ten-minute schedule affordable across two dozen repos, and why the job needs no volume.

go test ./mirror/ ./cmd/cd-mirror/...   # incl. a real two-repository end-to-end

# Dispatch is by argv[0], so the name is NOT a subcommand argument — passing it as
# one gets "unknown command" plus a plugin-not-found warning that reads like a
# broken install. From source, name the binary through the env var the switch
# already reads; from the image, the symlink is the name.
CD_BINARY_NAME=hanzocd-mirror FORGE_TOKEN=… GITHUB_TOKEN=… \
  go run ./cmd --table mirror/repos.json --dry-run

The fakes in sync_test.go all agree with the code about what git is, which is exactly why none of them caught the missing working directory. TestFastForwardMovesARealRepository builds two bare repositories, leaves the forge a commit behind, requires it to reach GitHub's tip — then rewinds GitHub so the two genuinely disagree and requires the forge NOT to move.

Declared but OFF: universe charts/app/values/hanzo/mirror.yaml, suspend: true. ONE precondition is left, and it is a credential.

The image half is DONE — the pinned digest carries hanzocd-mirror and the repository table, proven by running a probe pod on that exact digest rather than by reading its tag's date. (The tag v3.7.0 genuinely predates the subcommand; the digest beside it does not. Ask the image, not the version number.)

What remains is hanzo/mirror/{FORGE_TOKEN,GITHUB_TOKEN}, which is an owner write. Everything around them is built: the KMSSecret mirror-env is live and authenticates (LoadedKMSToken: True), and it fails on exactly one sentence — fetch secret "FORGE_TOKEN": kms: secret not found. So the two values simply have not been written; nothing else is missing, and turning the job on is then a one-line diff.

⚠️ Do not check that with kubectl get kmssecret. The short name resolves to kmssecrets.hanzo.ai, a DIFFERENT and empty CRD, and answers "No resources found" about the wrong thing — which is how this was reported as "no KMSSecret exists". The real resource is kmssecrets.secrets.lux.network and holds 122 objects.

hanzoai/mirrors still runs all four legs.

The table is 37 rows, all in sync, and 3 repos are deliberately NOT in it

Swept both hosts by tip. It started at 40 rows: 32 in sync, 8 out of step. All eight are now resolved — five reconciled, three removed — and a re-sweep reads 37 of 37 in sync. So the job's first real run has nothing to refuse, and a red run after this means something new, not the backlog.

The eight, and what each turned out to be:

repo what it actually was how it ended
hanzoai/o11y forge one commit ahead, GitHub its strict ancestor GitHub fast-forwarded onto it
hanzoai/kms disjoint files — CI/deps here, a README notice there merged; forge only (GitHub archived)
hanzoai/python-sdk the same consolidation applied twice merged -s ours, tree verified
hanzoai/hanzo.sh GitHub's history was scrubbed, forge kept the original forge moved onto GitHub
hanzoai/hanzo.network same same
hanzo-apps/id same same
hanzoai/cloud claim inverted — see below left the table
hanzoai/gui archived upstream — see below left the table

The last three were one thing wearing three names, and the commit messages gave it away. They read as "unrelated histories": same root DATE, different root SHA, no common ancestor, so merge-base had nothing to say and every count came back as total-commits rather than divergence. What actually happened is that GitHub's history was REWRITTEN to scrub the scaffold vendor's name. Two commit messages are the tell: on hanzo.sh's forge side each names the vendor outright — its build plugin, and its project template — where GitHub says "drop the scaffold vendor's build plugin" and "Update the generated project template". A rewrite changes every sha, which is exactly what "unrelated" looks like from underneath.

(The forge-side wording is described rather than quoted, and that is the same rule this repo enforces on itself: the structural gate refuses a tracked line carrying the vendor's name, and it was refusing THIS paragraph — the note about the scrub was the last thing still carrying what it documented.)

Measured before touching anything: the vendor name appears 0 times in GitHub's messages and tree, and 3-10 times in each forge tree. So the forge was holding the UNSCRUBBED original on the host CI reads — the scrub had been done on the copy and not on the canonical. Comparing commit SUBJECTS across the two (which works when shas cannot) showed the forge had no unique work in any of the three.

Each forge tip is preserved as archive/pre-scrub before main was moved, the same way universe keeps archive/github-divergence. Nothing was thrown away; the branch that CI reads is simply the scrubbed one now.

The general lesson: when two histories look unrelated, compare their SUBJECTS before concluding they are different projects. Shas are worthless across a rewrite, and a rewrite is far more common than a genuine fork.

The refusal is loud by construction: FastForward asks merge-base --is-ancestor forgeTip ghTip, treats exit 1 as git's ANSWER rather than an error, and pushes with no leading + so git itself refuses anything else. Diverged prints to stderr and failed() returns an error so cobra sets the exit status. Nothing is at risk of being overwritten.

native in this table is a CLAIM — "GitHub is canonical, move the forge onto it". Three repos are out of the table because that claim cannot be made true for them, and a row that can never go green is worse than no row: it teaches people to ignore a red job. Named here rather than silently dropped, the same way kustomization.yaml in universe names its deliberate exclusions.

not in the table why
hanzoai/kms archived on GitHub. An archived repo refuses pushes, so the two can never re-converge by pushing and the forge is canonical by construction. Its one GitHub commit (a README notice) was merged onto the forge by hand — that is as reconciled as it can get.
hanzoai/gui archived on GitHub, same reason. Note GitHub holds 15901 commits from 2020 (the upstream fork history) against 374 on the forge from 2026-03-21 — the forge is a deliberate restart, so "GitHub has more history" is not an argument for moving the forge onto it.
hanzoai/cloud the claim is inverted. GitHub's repo was CREATED 2026-08-06 and holds 8 commits; the forge's root is 2026-05-18 with 5001, and the forge is what CI and CD read. Both are being written today. Which one is canonical going forward is an owner's decision, not a table's.

An archived upstream is worth checking for directly — it is the one condition that makes native structurally impossible rather than merely wrong, and it is two of the three. Exactly 2 of the original 40 were archived, both listed above.

Of what remained, all forge copies are NATIVE-on-forge in the forge's own mirror table, i.e. written directly, so both sides take writes. Two shapes:

No common ancestor — which in every case here meant a rewritten history, not a fork:

repo GitHub forge which holds the history
hanzo-apps/id 45 43 same root DATE, different root SHA — a re-import that then took writes on both sides (26 files differ)
hanzoai/hanzo.network 21 19 same shape (28 files differ)
hanzoai/hanzo.sh 93 91 same shape (4 files differ)

A real common ancestor — both were merged, and neither needed a winner:

hanzoai/kms (ancestor 2026-07-27) had touched disjoint files: six commits on the forge to .hanzo/workflows/release.yml, go.mod and go.sum, one on GitHub to README.md. Nothing conflicted and both statements were true, so the merge kept both. It lives on the forge only — GitHub is archived and refused it.

hanzoai/python-sdk (ancestor 2026-08-07) is the instructive one. It looked like a 14-vs-2 divergence and was really the SAME work done twice: both sides had applied a commit named "one generated client in the wheel", 2,213 files and 800,555 deletions each, and the forge retains nothing GitHub dropped. git cherry — patch-ids, not commit ids — showed the other GitHub commit was already present under a different sha. The forge had then gone on to five client regenerations, 3.2.2 → 3.2.7.

Merged -s ours, so the histories join (GitHub fast-forwards, second head gone) while the tree stays the newer regeneration. A plain merge reported ~140 modify/delete conflicts purely because the two deletions carry different shas — resolving those by taking trees would have RESURRECTED modules both sides had agreed to delete. pkg/hanzoai/cloud/models/wrote.py is the tell: GitHub deleted it, the forge regenerated it, and models/__init__.py:2345 imports it, so the "obvious" resolution breaks the package. Verified after merging — the tree imports, 4,660 names, version 3.2.7.

hanzoai/o11y was simplest: the forge was strictly one commit ahead and GitHub was its ancestor, so GitHub was fast-forwarded onto it. Both read 0a75f53cc. That is exactly what this job automates, in the direction it does not run.

⚠️ Do not "fix" a row by force-pushing the side you happened to measure first. cloud and gui diverge in OPPOSITE directions, so a rule applied uniformly destroys 5001 commits on one or 15901 on the other. Ancestry decides, per repo, every time — and where there is no ancestor, a person decides.

Build

go build ./...          # covers the whole module — do this, not just changed pkgs
go test ./mirror/
make codegen            # REQUIRED if any API struct changed, or manifests drift

UI (ui/, pnpm): pnpm build writes ui/dist/app (and deletes the tracked ui/dist/app/gitkeep; git checkout it back), pnpm smoke drives that bundle in Chromium against ui/smoke/stub.mjs, a fixture API, and fails on any page error, console error or unanswered /v1 call. hanzo.yml's ui-smoke gate runs both. The build does not type-check, and webpack only warns when an import names an export that does not exist, so a removed API ships as undefined and throws at load: v3.7.3 called react-router v5's withRouter under v7 and served a blank page. Routing is v7 (HistoryRouter over shared/history, nested <Routes>); shared/components/router-compat.tsx's RouteChild hands routed components the v5 {history, location, match} props they still read. import * as x of an ESM-only package (minimatch 10) is a namespace, not a function.

Look. The palette is the --hz-* custom properties at the top of ui/src/kit/src/styles/main.scss (dark under :root, .theme-dark, light under .theme-light), taken from @hanzo/design and the hanzo.ai app shell. The kit's accent ramp ($kit-color-teal-1..8 in kit/src/styles/config.scss and kit/v2/styles/colors.scss) points at them, so those SCSS variables are var()s and cannot go through lighten/rgba/mix; reach for another rung instead. Hue is spent on state only (shared/components/colors.ts); in progress is neutral. .kit-button--base is the one primary (ink pill), --base-o a ghost. Chrome icons are shared/components/glyph.tsx; Font Awesome 7 reads its font path from its own module, so main.scss configures it with @use … with ($font-path) — a global $fa-font-path is ignored and every fa icon renders as an empty box.

Version. GET /v1/version and the sidebar report gitTag when the build was stamped clean. The image build has no usable git, so hanzo.yml's args pass GIT_TAG (a tag the image is published under: 3.7.11 from main; from a git tag, v3.7.5 on the delegated lane and 3.7.5 on buildx, both published), GIT_COMMIT and BUILD_DATE. An unstamped build reports v<VERSION>+unknown and the sidebar shows nothing for it.

A wrong internal import does not read as a typo — it reads as a missing dependency (cannot find module providing package … -mod=readonly) and sends you hunting a require line that was never the problem. That is what a stale github.com/hanzoai/deploy/... path looks like after the module rename to github.com/hanzoai/cd.

Running clientgen/mockgen outside Docker (make clientgen, make mockgen, or make codegen-local/codegen-local-fast): make mod-vendor-local first — hack/update-codegen.sh reads vendor/k8s.io/code-generator/kube_codegen.sh directly and there's no vendor/ by default. That script also sed -e '/go install/d' on its own vendored copy — the upstream self-install of deepcopy-gen/client-gen/informer-gen/lister-gen is deliberately stripped, so those binaries must already be on PATH (./hack/installers/install-codegen-go-tools.sh puts them in dist/, which update-codegen.sh prepends to PATH) or every generator call fails command not found — set -x still traces the call cleanly, it just never wrote anything. make mockgen needs mockery the same way (that installer script gets it too).

client-gen names the clientset accessor after the group's first DNS label, so after a regen off group apps.hanzo.ai the symbols are AppsV1alpha1() / AppsV1alpha1Interface / AppsV1alpha1Client, and the informer factory's group accessor is Apps() (its .V1alpha1() child is unaffected — only the group segment renames). None of that is generated in the ~40 hand-written call sites across controller/, server/, util/, cmd/, hack/gen-resources/, and test/ that hold a versioned.Interface — a clientgen run does not fix those, they still say .ArgoprojV1alpha1() / .Argoproj() until someone renames them too, and go build ./... won't tell you until it hits every one.

make protogen/make openapigen can vandalize the shared Go module cache. go-to-protobuf's "clean stale output before regenerating" step resolves k8s.io/api, k8s.io/apimachinery, k8s.io/apiextensions-apiserver (the --apimachinery-packages entries) through the module cache ($(go env GOMODCACHE)) even with GO111MODULE=off set, not through the vendor/ copy the script stages into $GOPATH/src. The cache ships read-only (dr-xr-xr-x/444); if it's writable (e.g. after chmod -R u+w to get past a permission denied abort), the tool deletes those packages' own generated.proto/generated.pb.go and does not always recreate them, which breaks every OTHER project on the machine that imports them. Fix: chmod -R u+w only the exact k8s.io/{api,apimachinery,apiextensions-apiserver}@<ver> dirs immediately before the run, and immediately after (success or fail) rm -rf those same dirs and go mod download <module>@<version> to re-extract from the untouched, hash-verified zip cache (go mod verify after — "all modules verified" proves no lasting damage). Never leave the cache chmod'd writable between runs.

Once regenerated, pkg/apis/application/v1alpha1/generated.proto's package line is derived mechanically from the Go import path (github.com/hanzoai/cd/pkg/apis/application/v1alpha1 → github.com.hanzoai.cd.pkg.apis.application.v1alpha1) — no .v3. segment, because this module has none. Every hand-written .proto (server/*, reposerver/*, commitserver/*) that references application/v1alpha1 types by fully-qualified name must match that exact string or protoc fails "is not defined"; grep for argoproj.argo_cd across *.proto to find stale ones.

app.kubernetes.io/part-of has exactly one live value: hanzocd. util/settings/settings.go's isSettingsObject/partOfCDSelector/the ConfigMap-informer's label selector all hard-check hanzocd — that's what every real manifest carries. hack/gen-crd-spec/main.go, cmd/cd/commands/admin/settings.go (setSettingsMeta), and test/testdata.go's NewFakeConfigMap/NewFakeSecret (used by ~30 test files) previously stamped cd instead — a silent version of silent-failure-pattern: util/settings tests panicked on a 0-item slice because the fixture the informer needed was invisible to it. The other ~90 part-of: cd literals scattered through unrelated *_test.go files never touch that selector and were left alone on purpose — check with git log on this file before assuming they're fair game too.

Multiple stale local scratch-path conventions from the pre-rename codebase, now unified (all self-consistent-but-wrong, not lookup-key mismatches, so low risk to touch as a block, but verify with rg before assuming any are still stale): /tmp/argocd-local → /tmp/cd-local (Procfiles, Makefile start-local/run-in-test-*), /tmp/argocd-e2e-env → /tmp/cd-e2e-env (matches test/e2e/fixture/goreman.go's e2eEnvVariableFilePath, the actual restart-marker file both Procfiles check for), /tmp/argo-e2e → /tmp/cd-e2e (matches test/e2e/fixture/fixture.go's defaultTmpDir/CD_E2E_DIR, wired through test/remote/, test/fixture/testrepos/, and the SSH-kustomize testdata — all hardcode the literal path, not just the env var default), and the GOPATH-style container mount /go/src/github.com/argoproj/argo-cd → /go/src/github.com/hanzoai/cd (Makefile's DOCKER_WORKDIR/LEGACY_PATH/ DOCKER_SRC_MOUNT, test/container/Dockerfile's PATH, test/container/reaper.sh's in-container guard, test/fixture/testrepos/start-git.sh). None of these are Go-module-path-sensitive (module mode doesn't care what directory a build runs in) — purely cosmetic, but every consumer of a given literal has to move together or the guard/mount on the other end silently stops matching.

The e2e container image the manifests actually reference is ghcr.io/hanzoai/cd-e2e-container (tags 0.1/0.2/0.3, already published — see reposerver/repository/repository_test.go); quay.io/argoprojlabs/cd-e2e-container is the pre-fork name and should never come back — check test/e2e/multiarch-container/build.sh (the actual publish script) stays pointed at ghcr.io/hanzoai/ too, not just the ~100 consumers.

Conventions

  • Paths are /v1/… — never an /api/ prefix.
  • Land on main. No PR queue, no branches left behind.
  • LLM.md is the canonical guide; CLAUDE.md and AGENTS.md are symlinks to it.