Files
cd/controller/hydration_concurrency_test.go
41536738c8 refactor(cd): hard-fork - rename CRD API group argoproj.io -> deploy.hanzo.ai (#1)
* feat(api): hard-fork CRD API group argoproj.io -> deploy.hanzo.ai

Hanzo CD hard-fork, phase 1.1. Single source of truth is
pkg/apis/application/register.go's Group constant (Application/AppProject/
ApplicationSet all derive their FullName from it); every other Go call-site
that hardcoded the literal group string is updated to match: the 3 generated
informer GVRs, the app-of-apps nested-Application detector in types.go, the
resource-tree Application-kind cache populator, the 3 NotFound error
constructors in server/application, and the applicationset-controller's
leader-election Lease name/ID (kept in lockstep across Go source + RBAC
resourceNames, or leader election would get RBAC-denied on startup).

CRD YAMLs (application/appproject/applicationset) and every RBAC apiGroups
rule that actually governs our 3 CRDs are repointed to deploy.hanzo.ai, across
both manifests/base/* (namespaced Roles) and manifests/cluster-rbac/* (cluster
scope). Two adjacent things that share the "argoproj.io" string but are NOT
ours are deliberately left untouched: gitops-engine's health-check dispatch
for Argo Workflows' `Workflow` kind, and argocd-server-clusterrole's second
apiGroups block granting read on Argo Workflows' `workflows` resource — both
belong to a separate, unrelated Argoproj-family project we are not forking.

Also untouched, per explicit scope: annotation/label keys (argocd.argoproj.io/*)
and finalizers (*.argoproj.io) that track already-applied live resources, and
argocd-cm.yaml's resource.customizations.ignoreResourceUpdates keys (a narrow
app-of-apps diffing nicety, not build- or RBAC-relevant).

CGO_ENABLED=0 go build ./... verified green.

* ci: wire canonical hanzo.yml + cicd.yml (Hanzo CD image build)

Phase 1.3. Follows the same one-way pattern every hanzo/lux/zoo repo uses:
hanzo.yml declares the image (root Dockerfile -> ghcr.io/hanzoai/deploy) and a
fast go-build sanity test (the multi-stage Dockerfile build is the slow, real
gate); cicd.yml is the ~10-line trigger shim importing hanzoai/ci's reusable
build workflow on the arc pool, secrets from KMS only.

No `deploy:` block yet, matching the hanzoai/cms build-only-first-cutover
pattern: this repo's own live hanzo-cd namespace runs the whole Hanzo fleet's
GitOps, so the prod image/CRD swap is a deliberate separate action (see the
[HELD] universe PR), never an automatic rollout on merge. Every PR/push still
builds and pushes an immutable ghcr.io/hanzoai/deploy:sha-<7>-amd64 image.

* feat(ui): rebrand Argo CD -> Hanzo CD (title, favicon, logo, sidebar)

Phase 1.2. User-facing "Argo CD"/"ArgoCD" text replaced with "Hanzo CD"
across the login page, help modal, settings screens, version-info panel,
sync-options warning, fullscreen-logs title, app.tsx page titles, and
index.html's <title>/noscript text (dropped the now-wrong upstream CLI
doc link rather than invent a hanzo.ai URL that doesn't exist).

Logo/favicon assets replaced with the canonical Hanzo mark (never
hand-drawn, copied from ~/work/hanzo/logo/dist): the two real usages of
Argo's logo collapse onto one Hanzo glyph (mono + white variants) at the
same 5 call sites (login icon + footer, login.scss $logoPath/
$logoNegativePath, sidebar text-logo), preserving the existing CSS
invert() filters so both the light login panel and dark sidebar still
render correctly. Old argologo.svg/argo_o.svg/argo.png deleted (no
longer referenced). favicon-{16x16,32x32}.png added at
ui/src/assets/favicon/ (index.html's links to that path were previously
dead - the directory didn't exist in this checkout). package.json name
argo-cd-ui -> hanzo-cd-ui.

Left alone, deliberately: the two Kubernetes resource-kind icon sets
under ui/src/assets/images/resources/ (unrelated to branding), and
upstream doc/project links (argoproj.io, argo-cd.readthedocs.io) since
we have no equivalent hanzo.ai docs to point to yet - except the login
footer logo's outbound link, which now points to hanzo.ai instead of
argoproj.io (a Hanzo-branded mark linking to the upstream project it
was forked from read as an obvious leftover).

* feat(module): rename Go module github.com/argoproj/argo-cd/v3 -> github.com/hanzoai/deploy

Phase 2. No version suffix on the new path (org policy: never publish our
own Go modules above v1.x.x / with a v2+ suffix) — this fork starts a fresh
v0/v1 module identity rather than carrying the v3 the upstream fork point
had reached.

Mechanical, symmetric rewrite: 704 .go files (import statements only; two
disjoint substitutions applied specific-first so the nested gitops-engine
module path never double-matches), plus go.mod (module line, the
gitops-engine require+replace pair, version reset to v0.0.0-... since a
suffix-less path can't carry a v3 semver), gitops-engine/go.mod, Makefile
(ldflags PACKAGE + 2 TEST_MODULE filters), .golangci.yaml, .goreleaser.yaml,
.mockery.yaml (+ gitops-engine's), Procfile, the codegen/proto generation
scripts (hack/generate-proto.sh, update-codegen.sh, update-openapi.sh), and
20 .proto files' go_package options. Also one OpenTelemetry tracer name
string in gitops-engine/pkg/sync/sync_context.go (a cosmestic label, not an
import, but worth keeping consistent).

CGO_ENABLED=0 go build ./... verified green for both the root module and
the nested gitops-engine module; go vet clean on both; go mod tidy run
(network available) to reconcile go.sum, dependency set unchanged.

Left alone, deliberately: doc-only mentions (docs/snyk/*.html scan reports,
gitops-engine/README.md), a comment-only reference in
.github/workflows/release.yaml, and one Renovate bot config line — none are
build- or codegen-load-bearing. Also left alone: comments citing upstream
GitHub issue URLs (github.com/argoproj/argo-cd/issues/NNNNN) and a handful
of test fixtures using "https://github.com/argoproj/argo-cd.git" as a
representative example repo URL — both are accurate historical/test-data
references, not stale self-references.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 18:27:58 -07:00

179 lines
5.0 KiB
Go

package controller
import (
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"k8s.io/client-go/util/workqueue"
hydratortypes "github.com/hanzoai/deploy/controller/hydrator/types"
"github.com/hanzoai/deploy/pkg/ratelimiter"
)
// TestNormalizeHydrationProcessors verifies that the hydration worker count is clamped to a safe minimum.
// The --hydration-processors CLI flag can be set to 0 or a negative value (the env default is clamped, but
// an explicit flag value is not), which would otherwise start zero workers and silently stall hydration.
// See https://github.com/argoproj/argo-cd/issues/27926.
func TestNormalizeHydrationProcessors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
configured int
want int
}{
{"zero clamps to one", 0, 1},
{"negative clamps to one", -3, 1},
{"one stays one", 1, 1},
{"default preserved", 5, 5},
{"large value preserved", 50, 50},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, normalizeHydrationProcessors(tc.configured))
})
}
}
func newTestHydrationQueue() workqueue.TypedRateLimitingInterface[hydratortypes.HydrationQueueKey] {
return workqueue.NewTypedRateLimitingQueue(
ratelimiter.NewCustomAppControllerRateLimiter[hydratortypes.HydrationQueueKey](ratelimiter.GetDefaultAppRateLimiterConfig()),
)
}
// TestHydrationQueue_DistinctKeysProcessConcurrently asserts the core feature claim of #27926: when the
// hydration queue is drained by multiple workers, distinct hydration keys are processed concurrently, while
// no single key is ever processed by two workers at the same time. The worker loop mirrors the one started
// in ApplicationController.Run.
func TestHydrationQueue_DistinctKeysProcessConcurrently(t *testing.T) {
t.Parallel()
queue := newTestHydrationQueue()
t.Cleanup(queue.ShutDown)
const numKeys = 12
const numWorkers = 4
for i := range numKeys {
queue.Add(hydratortypes.HydrationQueueKey{SourceRepoURL: fmt.Sprintf("https://example.com/repo-%d", i)})
}
var (
mu sync.Mutex
inFlight int
maxConcurrent int
processed int
perKeyActive = map[hydratortypes.HydrationQueueKey]bool{}
sameKeyOverlap bool
)
processNext := func() bool {
key, shutdown := queue.Get()
if shutdown {
return false
}
defer queue.Done(key)
mu.Lock()
if perKeyActive[key] {
sameKeyOverlap = true
}
perKeyActive[key] = true
inFlight++
if inFlight > maxConcurrent {
maxConcurrent = inFlight
}
mu.Unlock()
// Hold the key briefly so that concurrent processing of distinct keys is observable.
time.Sleep(20 * time.Millisecond)
mu.Lock()
inFlight--
perKeyActive[key] = false
processed++
allDone := processed == numKeys
mu.Unlock()
if allDone {
queue.ShutDown()
}
return true
}
var wg sync.WaitGroup
for range numWorkers {
wg.Go(func() {
for processNext() {
}
})
}
wg.Wait()
mu.Lock()
defer mu.Unlock()
assert.Equal(t, numKeys, processed, "every distinct key should be processed exactly once")
assert.False(t, sameKeyOverlap, "the same hydration key must never be processed by two workers at once")
assert.GreaterOrEqual(t, maxConcurrent, 2, "distinct hydration keys should be processed concurrently with multiple workers")
}
// TestHydrationQueue_SameKeyNotProcessedConcurrently asserts the dedup guarantee the feature relies on: a
// hydration key that is re-enqueued while it is still in-flight is withheld until the in-flight processing
// calls Done, so the same key is never handed to two workers simultaneously. See #27926.
func TestHydrationQueue_SameKeyNotProcessedConcurrently(t *testing.T) {
t.Parallel()
queue := newTestHydrationQueue()
t.Cleanup(queue.ShutDown)
key := hydratortypes.HydrationQueueKey{SourceRepoURL: "https://example.com/repo", DestinationBranch: "env/dev"}
queue.Add(key)
started := make(chan struct{})
release := make(chan struct{})
secondPickup := make(chan hydratortypes.HydrationQueueKey, 1)
// Worker 1 takes the key and holds it (does not call Done) until released.
go func() {
k, shutdown := queue.Get()
if shutdown {
return
}
close(started)
<-release
queue.Done(k)
}()
<-started
// Re-enqueue the same key while it is still in-flight. The workqueue must not hand it to another
// worker until the first one calls Done.
queue.Add(key)
// Worker 2 attempts to take work; it must block while the key is in-flight.
go func() {
k, shutdown := queue.Get()
if !shutdown {
secondPickup <- k
}
}()
select {
case <-secondPickup:
t.Fatal("the same hydration key was delivered to a second worker while still in-flight")
case <-time.After(150 * time.Millisecond):
// Expected: the re-added key is withheld until the first worker calls Done.
}
// Release the first worker; the re-added key should now become available to worker 2.
close(release)
select {
case got := <-secondPickup:
assert.Equal(t, key, got)
queue.Done(got)
case <-time.After(2 * time.Second):
t.Fatal("re-added key was not delivered after the first worker finished")
}
}