Files
deploy/commitserver/commit/commit.go
T
15d87b3e72 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-19 11:27:06 -07:00

259 lines
9.2 KiB
Go

package commit
import (
"context"
"errors"
"fmt"
"os"
"time"
log "github.com/sirupsen/logrus"
"github.com/hanzoai/deploy/commitserver/apiclient"
"github.com/hanzoai/deploy/commitserver/metrics"
"github.com/hanzoai/deploy/util/git"
"github.com/hanzoai/deploy/util/io"
"github.com/hanzoai/deploy/util/io/files"
)
const (
NoteNamespace = "hydrator.metadata" // NoteNamespace is the custom git notes namespace used by the hydrator to store and retrieve commit-related metadata.
ManifestYaml = "manifest.yaml" // ManifestYaml constant for the manifest yaml
)
// Service is the service that handles commit requests.
type Service struct {
metricsServer *metrics.Server
repoClientFactory RepoClientFactory
}
// NewService returns a new instance of the commit service.
func NewService(gitCredsStore git.CredsStore, metricsServer *metrics.Server) *Service {
return &Service{
metricsServer: metricsServer,
repoClientFactory: NewRepoClientFactory(gitCredsStore, metricsServer),
}
}
// CommitNote represents the structure of the git note associated with a hydrated commit.
// This struct is used to serialize/deserialize commit metadata (such as the dry run SHA)
// stored in the custom note namespace by the hydrator.
type CommitNote struct {
DrySHA string `json:"drySha"` // SHA of original commit that triggerd the hydrator
}
// CommitHydratedManifests handles a commit request. It clones the repository, checks out the sync branch, checks out
// the target branch, clears the repository contents, writes the manifests to the repository, commits the changes, and
// pushes the changes. It returns the hydrated revision SHA and an error if one occurred.
func (s *Service) CommitHydratedManifests(ctx context.Context, r *apiclient.CommitHydratedManifestsRequest) (*apiclient.CommitHydratedManifestsResponse, error) {
// This method is intentionally short. It's a wrapper around handleCommitRequest that adds metrics and logging.
// Keep logic here minimal and put most of the logic in handleCommitRequest.
startTime := time.Now()
// We validate for a nil repo in handleCommitRequest, but we need to check for a nil repo here to get the repo URL
// for metrics.
var repoURL string
if r.Repo != nil {
repoURL = r.Repo.Repo
}
var err error
s.metricsServer.IncPendingCommitRequest(repoURL)
defer func() {
s.metricsServer.DecPendingCommitRequest(repoURL)
commitResponseType := metrics.CommitResponseTypeSuccess
if err != nil {
commitResponseType = metrics.CommitResponseTypeFailure
}
s.metricsServer.IncCommitRequest(repoURL, commitResponseType)
s.metricsServer.ObserveCommitRequestDuration(repoURL, commitResponseType, time.Since(startTime))
}()
logCtx := log.WithFields(log.Fields{"branch": r.TargetBranch, "drySHA": r.DrySha})
out, sha, err := s.handleCommitRequest(ctx, logCtx, r)
if err != nil {
logCtx.WithError(err).WithField("output", out).Error("failed to handle commit request")
// No need to wrap this error, sufficient context is build in handleCommitRequest.
return &apiclient.CommitHydratedManifestsResponse{}, err
}
logCtx.Info("Successfully handled commit request")
return &apiclient.CommitHydratedManifestsResponse{
HydratedSha: sha,
}, nil
}
// handleCommitRequest handles the commit request. It clones the repository, checks out the sync branch, checks out the
// target branch, clears the repository contents, writes the manifests to the repository, commits the changes, and pushes
// the changes. It returns the output of the git commands and an error if one occurred.
func (s *Service) handleCommitRequest(ctx context.Context, logCtx *log.Entry, r *apiclient.CommitHydratedManifestsRequest) (string, string, error) {
if r.Repo == nil {
return "", "", errors.New("repo is required")
}
if r.Repo.Repo == "" {
return "", "", errors.New("repo URL is required")
}
if r.TargetBranch == "" {
return "", "", errors.New("target branch is required")
}
if r.SyncBranch == "" {
return "", "", errors.New("sync branch is required")
}
logCtx = logCtx.WithField("repo", r.Repo.Repo)
logCtx.Debug("Initiating git client")
gitClient, dirPath, cleanup, err := s.initGitClient(ctx, logCtx, r)
if err != nil {
return "", "", fmt.Errorf("failed to init git client: %w", err)
}
defer cleanup()
root, err := os.OpenRoot(dirPath)
if err != nil {
return "", "", fmt.Errorf("failed to open root dir: %w", err)
}
defer io.Close(root)
logCtx.Debugf("Checking out sync branch %s", r.SyncBranch)
var out string
out, err = gitClient.CheckoutOrOrphan(ctx, r.SyncBranch, false)
if err != nil {
return out, "", fmt.Errorf("failed to checkout sync branch: %w", err)
}
logCtx.Debugf("Checking out target branch %s", r.TargetBranch)
out, err = gitClient.CheckoutOrNew(ctx, r.TargetBranch, r.SyncBranch, false)
if err != nil {
return out, "", fmt.Errorf("failed to checkout target branch: %w", err)
}
hydratedSha, err := gitClient.CommitSHA(ctx)
if err != nil {
return "", "", fmt.Errorf("failed to get commit SHA: %w", err)
}
/* git note changes
1. Get the git note
2. If found, short-circuit, log a warn and return
3. If not, get the last manifest from git for every path, compare it with the hydrated manifest
3a. If manifest has no changes, continue.. no need to commit it
3b. Else, hydrate the manifest.
3c. Push the updated note
*/
isHydrated, err := IsHydrated(ctx, gitClient, r.DrySha, hydratedSha)
if err != nil {
return "", "", fmt.Errorf("failed to get notes from git %w", err)
}
// short-circuit if already hydrated
if isHydrated {
logCtx.Debugf("this dry sha %s is already hydrated", r.DrySha)
return "", hydratedSha, nil
}
logCtx.Debug("Writing manifests")
shouldCommit, err := WriteForPaths(ctx, root, r.Repo.Repo, r.DrySha, r.DryCommitMetadata, r.Paths, gitClient, r.ReadmeMessage)
if err != nil {
return "", "", fmt.Errorf("failed to write manifests: %w", err)
}
if !shouldCommit {
// Manifests did not change, so we don't need to create a new commit.
// Add a git note to track that this dry SHA has been processed, and return the existing hydrated SHA.
logCtx.Debug("Adding commit note")
err = AddNote(ctx, gitClient, r.DrySha, hydratedSha)
if err != nil {
return "", "", fmt.Errorf("failed to add commit note: %w", err)
}
return "", hydratedSha, nil
}
logCtx.Debug("Committing and pushing changes")
out, err = gitClient.CommitAndPush(ctx, r.TargetBranch, r.CommitMessage)
if err != nil {
return out, "", fmt.Errorf("failed to commit and push: %w", err)
}
logCtx.Debug("Getting commit SHA")
sha, err := gitClient.CommitSHA(ctx)
if err != nil {
return "", "", fmt.Errorf("failed to get commit SHA: %w", err)
}
// add the commit note
logCtx.Debug("Adding commit note")
err = AddNote(ctx, gitClient, r.DrySha, sha)
if err != nil {
return "", "", fmt.Errorf("failed to add commit note: %w", err)
}
return "", sha, nil
}
// initGitClient initializes a git client for the given repository and returns the client, the path to the directory where
// the repository is cloned, a cleanup function that should be called when the directory is no longer needed, and an error
// if one occurred.
func (s *Service) initGitClient(ctx context.Context, logCtx *log.Entry, r *apiclient.CommitHydratedManifestsRequest) (git.Client, string, func(), error) {
dirPath, err := files.CreateTempDir("/tmp/_commit-service")
if err != nil {
return nil, "", nil, fmt.Errorf("failed to create temp dir: %w", err)
}
// Call cleanupOrLog in this function if an error occurs to ensure the temp dir is cleaned up.
cleanupOrLog := func() {
err := os.RemoveAll(dirPath)
if err != nil {
logCtx.WithError(err).Error("failed to cleanup temp dir")
}
}
gitClient, err := s.repoClientFactory.NewClient(r.Repo, dirPath)
if err != nil {
cleanupOrLog()
return nil, "", nil, fmt.Errorf("failed to create git client: %w", err)
}
logCtx.Debugf("Initializing repo %s", r.Repo.Repo)
err = gitClient.Init()
if err != nil {
cleanupOrLog()
return nil, "", nil, fmt.Errorf("failed to init git client: %w", err)
}
logCtx.Debugf("Fetching repo %s", r.Repo.Repo)
err = gitClient.Fetch(ctx, "", 0)
if err != nil {
cleanupOrLog()
return nil, "", nil, fmt.Errorf("failed to clone repo: %w", err)
}
// FIXME: make it work for GHE
// logCtx.Debugf("Getting user info for repo credentials")
// gitCreds := r.Repo.GetGitCreds(s.gitCredsStore)
// startTime := time.Now()
// authorName, authorEmail, err := gitCreds.GetUserInfo(ctx)
// s.metricsServer.ObserveUserInfoRequestDuration(r.Repo.Repo, getCredentialType(r.Repo), time.Since(startTime))
// if err != nil {
// cleanupOrLog()
// return nil, "", nil, fmt.Errorf("failed to get github app info: %w", err)
// }
// Use author name and email from request, defaulting to "Argo CD" if not provided
authorName := r.AuthorName
if authorName == "" {
authorName = "Argo CD"
}
authorEmail := r.AuthorEmail
if authorEmail == "" {
authorEmail = "[email protected]"
}
logCtx.Debugf("Author config: request name='%s', request email='%s', final name='%s', final email='%s'",
r.AuthorName, r.AuthorEmail, authorName, authorEmail)
logCtx.Debugf("Setting author %s <%s>", authorName, authorEmail)
_, err = gitClient.SetAuthor(ctx, authorName, authorEmail)
if err != nil {
cleanupOrLog()
return nil, "", nil, fmt.Errorf("failed to set author: %w", err)
}
return gitClient, dirPath, cleanupOrLog, nil
}