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

392 lines
14 KiB
Go

package cache
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"time"
"github.com/redis/go-redis/v9"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/hanzoai/deploy/common"
certutil "github.com/hanzoai/deploy/util/cert"
"github.com/hanzoai/deploy/util/env"
)
const (
// envRedisPassword is an env variable name which stores redis password
envRedisPassword = "REDIS_PASSWORD"
// envRedisUsername is an env variable name which stores redis username (for acl setup)
envRedisUsername = "REDIS_USERNAME"
// envRedisRetryCount is an env variable name which stores redis retry count
envRedisRetryCount = "REDIS_RETRY_COUNT"
// defaultRedisRetryCount holds default number of retries
defaultRedisRetryCount = 3
// envRedisSentinelPassword is an env variable name which stores redis sentinel password
envRedisSentinelPassword = "REDIS_SENTINEL_PASSWORD"
// envRedisSentinelUsername is an env variable name which stores redis sentinel username
envRedisSentinelUsername = "REDIS_SENTINEL_USERNAME"
// envRedisCredsFilePath is an env variable name which stores path to redis credentials file
envRedisCredsDirPath = "REDIS_CREDS_DIR_PATH"
)
const (
// CLIFlagRedisCompress is a cli flag name to define the redis compression setting for data sent to redis
CLIFlagRedisCompress = "redis-compress"
)
func NewCache(client CacheClient) *Cache {
return &Cache{client}
}
func buildRedisClient(redisAddress, password, username string, redisDB, maxRetries int, tlsConfig *tls.Config) *redis.Client {
opts := &redis.Options{
Addr: redisAddress,
Password: password,
DB: redisDB,
MaxRetries: maxRetries,
TLSConfig: tlsConfig,
Username: username,
}
client := redis.NewClient(opts)
client.AddHook(redis.Hook(NewArgoRedisHook(func() {
*client = *buildRedisClient(redisAddress, password, username, redisDB, maxRetries, tlsConfig)
})))
return client
}
func buildFailoverRedisClient(sentinelMaster, sentinelUsername, sentinelPassword, password, username string, redisDB, maxRetries int, tlsConfig *tls.Config, sentinelAddresses []string) *redis.Client {
opts := &redis.FailoverOptions{
MasterName: sentinelMaster,
SentinelAddrs: sentinelAddresses,
DB: redisDB,
Password: password,
MaxRetries: maxRetries,
TLSConfig: tlsConfig,
Username: username,
SentinelUsername: sentinelUsername,
SentinelPassword: sentinelPassword,
}
client := redis.NewFailoverClient(opts)
client.AddHook(redis.Hook(NewArgoRedisHook(func() {
*client = *buildFailoverRedisClient(sentinelMaster, sentinelUsername, sentinelPassword, password, username, redisDB, maxRetries, tlsConfig, sentinelAddresses)
})))
return client
}
type Options struct {
FlagPrefix string
OnClientCreated func(client *redis.Client)
}
func (o *Options) callOnClientCreated(client *redis.Client) {
if o.OnClientCreated != nil {
o.OnClientCreated(client)
}
}
func (o *Options) getEnvPrefix() string {
return strings.ReplaceAll(strings.ToUpper(o.FlagPrefix), "-", "_")
}
func mergeOptions(opts ...Options) Options {
var result Options
for _, o := range opts {
if o.FlagPrefix != "" {
result.FlagPrefix = o.FlagPrefix
}
if o.OnClientCreated != nil {
result.OnClientCreated = o.OnClientCreated
}
}
return result
}
func getFlagVal[T any](cmd *cobra.Command, o Options, name string, getVal func(name string) (T, error)) func() T {
return func() T {
var res T
var err error
if o.FlagPrefix != "" && cmd.Flags().Changed(o.FlagPrefix+name) {
res, err = getVal(o.FlagPrefix + name)
} else {
res, err = getVal(name)
}
if err != nil {
panic(err)
}
return res
}
}
// loadRedisCreds loads Redis credentials either from file-based mounts or environment variables.
// If a mount path is provided, Redis credentials are expected to be read only from the mounted files.
// If no mount path is provided, the function falls back to reading credentials from environment variables
// to maintain backward compatibility.
func loadRedisCreds(mountPath string, opt Options) (username, password, sentinelUsername, sentinelPassword string, err error) {
if mountPath != "" {
log.Infof("Loading Redis credentials from mounted directory: %s", mountPath)
if _, statErr := os.Stat(mountPath); statErr != nil {
return "", "", "", "", fmt.Errorf("failed to access Redis credentials: mount path %q does not exist or is inaccessible: %w", mountPath, statErr)
}
password, err = readAuthDetailsFromFile(mountPath, "auth")
if err != nil {
return "", "", "", "", err
}
username, err = readAuthDetailsFromFile(mountPath, "auth_username")
if err != nil {
return "", "", "", "", err
}
sentinelUsername, err = readAuthDetailsFromFile(mountPath, "sentinel_username")
if err != nil {
return "", "", "", "", err
}
sentinelPassword, err = readAuthDetailsFromFile(mountPath, "sentinel_auth")
if err != nil {
return "", "", "", "", err
}
return username, password, sentinelUsername, sentinelPassword, nil
}
log.Info("Loading Redis credentials from environment variables")
username = os.Getenv(envRedisUsername)
password = os.Getenv(envRedisPassword)
sentinelUsername = os.Getenv(envRedisSentinelUsername)
sentinelPassword = os.Getenv(envRedisSentinelPassword)
// If a flag prefix is set, prefer prefixed env vars to allow component-specific overrides (e.g., REPOSERVER_REDIS_PASSWORD).
if opt.FlagPrefix != "" {
pref := opt.getEnvPrefix()
if val := os.Getenv(pref + envRedisUsername); val != "" {
username = val
}
if val := os.Getenv(pref + envRedisPassword); val != "" {
password = val
}
if val := os.Getenv(pref + envRedisSentinelUsername); val != "" {
sentinelUsername = val
}
if val := os.Getenv(pref + envRedisSentinelPassword); val != "" {
sentinelPassword = val
}
}
return username, password, sentinelUsername, sentinelPassword, nil
}
// readAuthDetailsFromFile reads authentication file from the given
// mount path. If the file does not exist, it returns an empty string and no error.
// which is the expected behavior for optional secrets.
//
// An error is returned only when the file exists but cannot be accessed (e.g.,
// permission issues or other filesystem errors). This helps distinguish between
// a missing optional credential (valid case) and a real misconfiguration
func readAuthDetailsFromFile(mountPath, filename string) (string, error) {
path := filepath.Join(mountPath, filename)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// Expected when a particular credential is not used
log.Infof("Redis credential file %s not found; using empty value for Redis credential %s", path, filename)
return "", nil
}
return "", fmt.Errorf("failed to access Redis credential file %s: %w", path, err)
}
return strings.TrimSpace(string(data)), nil
}
// AddCacheFlagsToCmd adds flags which control caching to the specified command
func AddCacheFlagsToCmd(cmd *cobra.Command, opts ...Options) func() (*Cache, error) {
redisAddress := ""
sentinelAddresses := make([]string, 0)
sentinelMaster := ""
redisDB := 0
redisCACertificate := ""
redisClientCertificate := ""
redisClientKey := ""
redisUseTLS := false
insecureRedis := false
compressionStr := ""
opt := mergeOptions(opts...)
var defaultCacheExpiration time.Duration
cmd.Flags().StringVar(&redisAddress, opt.FlagPrefix+"redis", env.StringFromEnv(opt.getEnvPrefix()+"REDIS_SERVER", ""), "Redis server hostname and port (e.g. argocd-redis:6379). ")
redisAddressSrc := getFlagVal(cmd, opt, "redis", cmd.Flags().GetString)
cmd.Flags().IntVar(&redisDB, opt.FlagPrefix+"redisdb", env.ParseNumFromEnv(opt.getEnvPrefix()+"REDISDB", 0, 0, math.MaxInt32), "Redis database.")
redisDBSrc := getFlagVal(cmd, opt, "redisdb", cmd.Flags().GetInt)
cmd.Flags().StringArrayVar(
&sentinelAddresses,
opt.FlagPrefix+"sentinel",
env.StringsFromEnv(
opt.getEnvPrefix()+"REDIS_SENTINEL_HOSTS",
[]string{},
",",
),
"Redis sentinel hostname and port (e.g. argocd-redis-ha-announce-0:6379). ",
)
sentinelAddressesSrc := getFlagVal(cmd, opt, "sentinel", cmd.Flags().GetStringArray)
cmd.Flags().StringVar(
&sentinelMaster,
opt.FlagPrefix+"sentinelmaster",
env.StringFromEnv(
opt.getEnvPrefix()+"REDIS_SENTINEL_MASTER",
"master",
),
"Redis sentinel master group name.",
)
sentinelMasterSrc := getFlagVal(cmd, opt, "sentinelmaster", cmd.Flags().GetString)
cmd.Flags().DurationVar(&defaultCacheExpiration, opt.FlagPrefix+"default-cache-expiration", env.ParseDurationFromEnv("ARGOCD_DEFAULT_CACHE_EXPIRATION", 24*time.Hour, 0, math.MaxInt64), "Cache expiration default")
defaultCacheExpirationSrc := getFlagVal(cmd, opt, "default-cache-expiration", cmd.Flags().GetDuration)
cmd.Flags().BoolVar(&redisUseTLS, opt.FlagPrefix+"redis-use-tls", false, "Use TLS when connecting to Redis. ")
redisUseTLSSrc := getFlagVal(cmd, opt, "redis-use-tls", cmd.Flags().GetBool)
cmd.Flags().StringVar(&redisClientCertificate, opt.FlagPrefix+"redis-client-certificate", "", "Path to Redis client certificate (e.g. /etc/certs/redis/client.crt).")
redisClientCertificateSrc := getFlagVal(cmd, opt, "redis-client-certificate", cmd.Flags().GetString)
cmd.Flags().StringVar(&redisClientKey, opt.FlagPrefix+"redis-client-key", "", "Path to Redis client key (e.g. /etc/certs/redis/client.crt).")
redisClientKeySrc := getFlagVal(cmd, opt, "redis-client-key", cmd.Flags().GetString)
cmd.Flags().BoolVar(&insecureRedis, opt.FlagPrefix+"redis-insecure-skip-tls-verify", false, "Skip Redis server certificate validation.")
insecureRedisSrc := getFlagVal(cmd, opt, "redis-insecure-skip-tls-verify", cmd.Flags().GetBool)
cmd.Flags().StringVar(&redisCACertificate, opt.FlagPrefix+"redis-ca-certificate", "", "Path to Redis server CA certificate (e.g. /etc/certs/redis/ca.crt). If not specified, system trusted CAs will be used for server certificate validation.")
redisCACertificateSrc := getFlagVal(cmd, opt, "redis-ca-certificate", cmd.Flags().GetString)
cmd.Flags().StringVar(&compressionStr, opt.FlagPrefix+CLIFlagRedisCompress, env.StringFromEnv(opt.getEnvPrefix()+"REDIS_COMPRESSION", string(RedisCompressionGZip)), "Enable compression for data sent to Redis with the required compression algorithm. (possible values: gzip, none)")
compressionStrSrc := getFlagVal(cmd, opt, CLIFlagRedisCompress, cmd.Flags().GetString)
return func() (*Cache, error) {
redisAddress := redisAddressSrc()
redisDB := redisDBSrc()
sentinelAddresses := sentinelAddressesSrc()
sentinelMaster := sentinelMasterSrc()
defaultCacheExpiration := defaultCacheExpirationSrc()
redisUseTLS := redisUseTLSSrc()
redisClientCertificate := redisClientCertificateSrc()
redisClientKey := redisClientKeySrc()
insecureRedis := insecureRedisSrc()
redisCACertificate := redisCACertificateSrc()
compressionStr := compressionStrSrc()
var tlsConfig *tls.Config
if redisUseTLS {
tlsConfig = &tls.Config{}
if redisClientCertificate != "" {
clientCert, err := tls.LoadX509KeyPair(redisClientCertificate, redisClientKey)
if err != nil {
return nil, err
}
tlsConfig.Certificates = []tls.Certificate{clientCert}
}
switch {
case insecureRedis:
tlsConfig.InsecureSkipVerify = true
case redisCACertificate != "":
redisCA, err := certutil.ParseTLSCertificatesFromPath(redisCACertificate)
if err != nil {
return nil, err
}
tlsConfig.RootCAs = certutil.GetCertPoolFromPEMData(redisCA)
default:
var err error
tlsConfig.RootCAs, err = x509.SystemCertPool()
if err != nil {
return nil, err
}
}
}
var password, username, sentinelUsername, sentinelPassword string
credsDirPath := os.Getenv(envRedisCredsDirPath)
if opt.FlagPrefix != "" {
if val := os.Getenv(opt.getEnvPrefix() + envRedisCredsDirPath); val != "" {
credsDirPath = val
}
}
username, password, sentinelUsername, sentinelPassword, err := loadRedisCreds(credsDirPath, opt)
if err != nil {
return nil, err
}
maxRetries := env.ParseNumFromEnv(envRedisRetryCount, defaultRedisRetryCount, 0, math.MaxInt32)
compression, err := CompressionTypeFromString(compressionStr)
if err != nil {
return nil, err
}
if len(sentinelAddresses) > 0 {
client := buildFailoverRedisClient(sentinelMaster, sentinelUsername, sentinelPassword, password, username, redisDB, maxRetries, tlsConfig, sentinelAddresses)
opt.callOnClientCreated(client)
return NewCache(NewRedisCache(client, defaultCacheExpiration, compression)), nil
}
if redisAddress == "" {
redisAddress = common.DefaultRedisAddr
}
client := buildRedisClient(redisAddress, password, username, redisDB, maxRetries, tlsConfig)
opt.callOnClientCreated(client)
return NewCache(NewRedisCache(client, defaultCacheExpiration, compression)), nil
}
}
// Cache provides strongly types methods to store and retrieve values from shared cache
type Cache struct {
client CacheClient
}
func (c *Cache) GetClient() CacheClient {
return c.client
}
func (c *Cache) SetClient(client CacheClient) {
c.client = client
}
func (c *Cache) RenameItem(oldKey string, newKey string, expiration time.Duration) error {
return c.client.Rename(fmt.Sprintf("%s|%s", oldKey, common.CacheVersion), fmt.Sprintf("%s|%s", newKey, common.CacheVersion), expiration)
}
func (c *Cache) generateFullKey(key string) string {
if key == "" {
log.Debug("Cache key is empty, this will result in key collisions if there is more than one empty key")
}
return fmt.Sprintf("%s|%s", key, common.CacheVersion)
}
// Sets or deletes an item in cache
func (c *Cache) SetItem(key string, item any, opts *CacheActionOpts) error {
if item == nil {
return errors.New("cannot set nil item in cache")
}
if opts == nil {
opts = &CacheActionOpts{}
}
fullKey := c.generateFullKey(key)
client := c.GetClient()
if opts.Delete {
return client.Delete(fullKey)
}
return client.Set(&Item{Key: fullKey, Object: item, CacheActionOpts: *opts})
}
func (c *Cache) GetItem(key string, item any) error {
key = c.generateFullKey(key)
if item == nil {
return fmt.Errorf("cannot get item into a nil for key %s", key)
}
client := c.GetClient()
return client.Get(key, item)
}
func (c *Cache) OnUpdated(ctx context.Context, key string, callback func() error) error {
return c.client.OnUpdated(ctx, c.generateFullKey(key), callback)
}
func (c *Cache) NotifyUpdated(key string) error {
return c.client.NotifyUpdated(c.generateFullKey(key))
}