Files
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

339 lines
12 KiB
Go

package controller
import (
"context"
"fmt"
"slices"
"strings"
"github.com/hanzoai/deploy/gitops-engine/pkg/health"
"github.com/hanzoai/deploy/gitops-engine/pkg/sync/common"
"github.com/hanzoai/deploy/gitops-engine/pkg/sync/hook"
"github.com/hanzoai/deploy/gitops-engine/pkg/utils/kube"
log "github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/attribute"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/rest"
"github.com/hanzoai/deploy/util/lua"
appv1 "github.com/hanzoai/deploy/pkg/apis/application/v1alpha1"
argoutil "github.com/hanzoai/deploy/util/argo"
traceutil "github.com/hanzoai/deploy/util/trace"
)
type HookType string
const (
PreDeleteHookType HookType = "PreDelete"
PostDeleteHookType HookType = "PostDelete"
)
var hookTypeAnnotations = map[HookType]map[string]string{
PreDeleteHookType: {
"argocd.argoproj.io/hook": string(PreDeleteHookType),
"helm.sh/hook": "pre-delete",
},
PostDeleteHookType: {
"argocd.argoproj.io/hook": string(PostDeleteHookType),
"helm.sh/hook": "post-delete",
},
}
func isHookOfType(obj *unstructured.Unstructured, hookType HookType) bool {
if obj == nil || obj.GetAnnotations() == nil {
return false
}
for k, v := range hookTypeAnnotations[hookType] {
if val, ok := obj.GetAnnotations()[k]; ok {
if slices.ContainsFunc(strings.Split(val, ","), func(item string) bool {
return strings.TrimSpace(item) == v
}) {
return true
}
}
}
return false
}
func isHook(obj *unstructured.Unstructured) bool {
if hook.IsHook(obj) {
return true
}
for hookType := range hookTypeAnnotations {
if isHookOfType(obj, hookType) {
return true
}
}
return false
}
func isPreDeleteHook(obj *unstructured.Unstructured) bool {
return isHookOfType(obj, PreDeleteHookType)
}
func isPostDeleteHook(obj *unstructured.Unstructured) bool {
return isHookOfType(obj, PostDeleteHookType)
}
// hasGitOpsEngineSyncPhaseHook is true when gitops-engine would run the resource during a sync
// phase (PreSync, Sync, PostSync, SyncFail). PreDelete/PostDelete are not sync phases;
// without this check, state reconciliation drops such resources
// entirely because isPreDeleteHook/isPostDeleteHook match any comma-separated value.
// HookTypeSkip is omitted as it is not a sync phase.
func hasGitOpsEngineSyncPhaseHook(obj *unstructured.Unstructured) bool {
for _, t := range hook.Types(obj) {
switch t {
case common.HookTypePreSync, common.HookTypeSync, common.HookTypePostSync, common.HookTypeSyncFail:
return true
}
}
return false
}
// executeHooks is a generic function to execute hooks of a specified type
func (ctrl *ApplicationController) executeHooks(ctx context.Context, hookType HookType, app *appv1.Application, proj *appv1.AppProject, liveObjs map[kube.ResourceKey]*unstructured.Unstructured, config *rest.Config, logCtx *log.Entry) (completed bool, retErr error) {
ctx, span := tracer.Start(ctx, "controller.executeHooks")
setAppTraceAttrs(span, app, attribute.String("argocd.hook.type", string(hookType)))
defer func() { traceutil.EndSpan(span, retErr) }()
appLabelKey, err := ctrl.settingsMgr.GetAppInstanceLabelKey()
if err != nil {
return false, err
}
trackingMethodStr, err := ctrl.settingsMgr.GetTrackingMethod()
if err != nil {
return false, err
}
installationID, err := ctrl.settingsMgr.GetInstallationID()
if err != nil {
return false, err
}
trackingMethod := appv1.TrackingMethod(trackingMethodStr)
resourceTracking := argoutil.NewResourceTracking()
var revisions []string
for _, src := range app.Spec.GetSources() {
revisions = append(revisions, src.TargetRevision)
}
// Fetch target objects from Git to know which hooks should exist
targets, _, _, err := ctrl.appStateManager.GetRepoObjs(ctx, app, app.Spec.GetSources(), appLabelKey, revisions, false, false, nil, proj, true)
if err != nil {
return false, err
}
// Find existing hooks of the specified type
runningHooks := map[kube.ResourceKey]*unstructured.Unstructured{}
for key, obj := range liveObjs {
if isHookOfType(obj, hookType) {
runningHooks[key] = obj
}
}
// Find expected hooks that need to be created
expectedHook := map[kube.ResourceKey]*unstructured.Unstructured{}
for _, obj := range targets {
if obj.GetNamespace() == "" {
obj.SetNamespace(app.Spec.Destination.Namespace)
}
if !isHookOfType(obj, hookType) {
continue
}
if _, alreadyExists := runningHooks[kube.GetResourceKey(obj)]; !alreadyExists {
expectedHook[kube.GetResourceKey(obj)] = obj
}
}
// Create hooks that don't exist yet
createdCnt := 0
for key, obj := range expectedHook {
// Apply app instance tracking metadata so the hook can be tracked and cleaned up.
// Use the same code path as regular sync resources so the configured
// tracking method (label, annotation, annotation+label) is honored.
// When the configured tracking method writes a label, this also ensures
// the label value is truncated to fit Kubernetes' 63-character label
// limit (see https://github.com/argoproj/argo-cd/issues/27527).
if err := resourceTracking.SetAppInstance(obj, appLabelKey, app.InstanceName(ctrl.namespace), app.Spec.Destination.Namespace, trackingMethod, installationID); err != nil {
return false, fmt.Errorf("failed to set app instance tracking on %s hook %s: %w", hookType, key, err)
}
logCtx.Infof("Creating %s hook resource: %s", hookType, key)
_, err = ctrl.kubectl.CreateResource(ctx, config, obj.GroupVersionKind(), obj.GetName(), obj.GetNamespace(), obj, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
logCtx.Warnf("Hook resource %s already exists, skipping", key)
continue
}
return false, err
}
createdCnt++
}
if createdCnt > 0 {
logCtx.Infof("Created %d %s hooks", createdCnt, hookType)
return false, nil
}
// Check health of running hooks
resourceOverrides, err := ctrl.settingsMgr.GetResourceOverrides()
if err != nil {
return false, err
}
healthOverrides := lua.ResourceHealthOverrides(resourceOverrides)
progressingHooksCount := 0
var failedHooks []string
var failedHookObjects []*unstructured.Unstructured
for key, obj := range runningHooks {
hookHealth, err := health.GetResourceHealth(obj, healthOverrides)
if err != nil {
return false, err
}
if hookHealth == nil {
logCtx.WithFields(log.Fields{
"group": obj.GroupVersionKind().Group,
"version": obj.GroupVersionKind().Version,
"kind": obj.GetKind(),
"name": obj.GetName(),
"namespace": obj.GetNamespace(),
}).Info("No health check defined for resource, considering it healthy")
hookHealth = &health.HealthStatus{
Status: health.HealthStatusHealthy,
}
}
switch hookHealth.Status {
case health.HealthStatusProgressing:
logCtx.Debugf("Hook %s is progressing", key)
progressingHooksCount++
case health.HealthStatusDegraded:
logCtx.Warnf("Hook %s is degraded: %s", key, hookHealth.Message)
failedHooks = append(failedHooks, fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetName()))
failedHookObjects = append(failedHookObjects, obj)
case health.HealthStatusHealthy:
logCtx.Debugf("Hook %s is healthy", key)
}
}
if len(failedHooks) > 0 {
// Delete failed hooks to allow retry with potentially fixed hook definitions
logCtx.Infof("Deleting %d failed %s hook(s) to allow retry", len(failedHookObjects), hookType)
for _, obj := range failedHookObjects {
err = ctrl.kubectl.DeleteResource(ctx, config, obj.GroupVersionKind(), obj.GetName(), obj.GetNamespace(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
logCtx.WithError(err).Warnf("Failed to delete failed hook %s/%s", obj.GetNamespace(), obj.GetName())
}
}
return false, fmt.Errorf("%s hook(s) failed: %s", hookType, strings.Join(failedHooks, ", "))
}
if progressingHooksCount > 0 {
logCtx.Infof("Waiting for %d %s hooks to complete", progressingHooksCount, hookType)
return false, nil
}
return true, nil
}
// cleanupHooks is a generic function to clean up hooks of a specified type
func (ctrl *ApplicationController) cleanupHooks(ctx context.Context, hookType HookType, liveObjs map[kube.ResourceKey]*unstructured.Unstructured, config *rest.Config, logCtx *log.Entry) (completed bool, retErr error) {
ctx, span := tracer.Start(ctx, "controller.cleanupHooks")
span.SetAttributes(attribute.String("argocd.hook.type", string(hookType)))
defer func() { traceutil.EndSpan(span, retErr) }()
resourceOverrides, err := ctrl.settingsMgr.GetResourceOverrides()
if err != nil {
return false, err
}
healthOverrides := lua.ResourceHealthOverrides(resourceOverrides)
pendingDeletionCount := 0
aggregatedHealth := health.HealthStatusHealthy
var hooks []*unstructured.Unstructured
// Collect hooks and determine overall health
for _, obj := range liveObjs {
if !isHookOfType(obj, hookType) {
continue
}
hookHealth, err := health.GetResourceHealth(obj, healthOverrides)
if err != nil {
return false, err
}
if hookHealth == nil {
hookHealth = &health.HealthStatus{
Status: health.HealthStatusHealthy,
}
}
if health.IsWorse(aggregatedHealth, hookHealth.Status) {
aggregatedHealth = hookHealth.Status
}
hooks = append(hooks, obj)
}
if len(hooks) == 0 {
return true, nil
}
// Process hooks for deletion
for _, obj := range hooks {
deletePolicies := hook.DeletePolicies(obj)
shouldDelete := false
if len(deletePolicies) == 0 {
// If no delete policy is specified, always delete hooks during cleanup phase
shouldDelete = true
} else {
// Check if any delete policy matches the current hook state
for _, policy := range deletePolicies {
if (policy == common.HookDeletePolicyHookFailed && aggregatedHealth == health.HealthStatusDegraded) ||
(policy == common.HookDeletePolicyHookSucceeded && aggregatedHealth == health.HealthStatusHealthy) {
shouldDelete = true
break
}
}
}
if shouldDelete {
pendingDeletionCount++
if obj.GetDeletionTimestamp() != nil {
continue
}
logCtx.Infof("Deleting %s hook %s/%s", hookType, obj.GetNamespace(), obj.GetName())
err = ctrl.kubectl.DeleteResource(ctx, config, obj.GroupVersionKind(), obj.GetName(), obj.GetNamespace(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return false, err
}
}
}
if pendingDeletionCount > 0 {
logCtx.Infof("Waiting for %d %s hooks to be deleted", pendingDeletionCount, hookType)
return false, nil
}
return true, nil
}
// Execute and cleanup hooks for pre-delete and post-delete operations
func (ctrl *ApplicationController) executePreDeleteHooks(ctx context.Context, app *appv1.Application, proj *appv1.AppProject, liveObjs map[kube.ResourceKey]*unstructured.Unstructured, config *rest.Config, logCtx *log.Entry) (bool, error) {
return ctrl.executeHooks(ctx, PreDeleteHookType, app, proj, liveObjs, config, logCtx)
}
func (ctrl *ApplicationController) cleanupPreDeleteHooks(ctx context.Context, liveObjs map[kube.ResourceKey]*unstructured.Unstructured, config *rest.Config, logCtx *log.Entry) (bool, error) {
return ctrl.cleanupHooks(ctx, PreDeleteHookType, liveObjs, config, logCtx)
}
func (ctrl *ApplicationController) executePostDeleteHooks(ctx context.Context, app *appv1.Application, proj *appv1.AppProject, liveObjs map[kube.ResourceKey]*unstructured.Unstructured, config *rest.Config, logCtx *log.Entry) (bool, error) {
return ctrl.executeHooks(ctx, PostDeleteHookType, app, proj, liveObjs, config, logCtx)
}
func (ctrl *ApplicationController) cleanupPostDeleteHooks(ctx context.Context, liveObjs map[kube.ResourceKey]*unstructured.Unstructured, config *rest.Config, logCtx *log.Entry) (bool, error) {
return ctrl.cleanupHooks(ctx, PostDeleteHookType, liveObjs, config, logCtx)
}