diff --git a/docs/faq.md b/docs/faq.md index f46109f3e318c..091a178f7aa28 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -328,10 +328,69 @@ If for some reason authenticated Redis does not work for you and you want to use * Deployment: argocd-server * StatefulSet: argocd-application-controller +5. If you have configured file-based Redis credentials using the `REDIS_CREDS_FILE_PATH` environment variable, remove this environment variable and delete the corresponding volume and volumeMount entries that mount the credentials directory from the following manifests: + * Deployment: argocd-repo-server + * Deployment: argocd-server + * StatefulSet: argocd-application-controller + ## How do I provide my own Redis credentials? The Redis password is stored in Kubernetes secret `argocd-redis` with key `auth` in the namespace where Argo CD is installed. You can config your secret provider to generate Kubernetes secret accordingly. +### Using file-based Redis credentials via `REDIS_CREDS_FILE_PATH` + +Argo CD components support reading Redis credentials from files mounted at a specified path inside the container. + +When the environment variable `REDIS_CREDS_FILE_PATH` is set, Argo CD components can loads Redis credentials only from the files located in that directory and does not read them from environment variables. If `REDIS_CREDS_FILE_PATH` is not set, the components fall back to reading credentials from environment variables such as `REDIS_PASSWORD` and `REDIS_USERNAME` (and their Sentinel variants). + +Expected files when using `REDIS_CREDS_FILE_PATH`: + +- `auth`: Redis password +- `auth_username`: Redis username +- `sentinel_auth`: Redis Sentinel password +- `sentinel_username`: Redis Sentinel username + +You can store these keys in a Kubernetes Secret and mount it into each Argo CD component that needs Redis access. Then point `REDIS_CREDS_FILE_PATH` to the mount directory. + +Example Secret: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: + namespace: argocd +type: Opaque +stringData: + auth: "" + auth_username: "" + sentinel_auth: "" + sentinel_username: "" +``` + +Example Argo CD component spec (e.g., add to `argocd-server`, `argocd-repo-server`, `argocd-application-controller`): + +```yaml +spec: + containers: + - name: argocd-server + image: quay.io/argoproj/argocd: + env: + - name: REDIS_CREDS_FILE_PATH + value: "" + volumeMounts: + - name: redis-creds + mountPath: "" + readOnly: true + volumes: + - name: redis-creds + secret: + secretName: +``` + +> [!NOTE] +> This mechanism configures Argo CD components to authenticate to Redis. The Redis server itself should be configured independently (e.g., via `redis.conf`). + ## How do I fix `Manifest generation error (cached)`? `Manifest generation error (cached)` means that there was an error when generating manifests and that the error message has been cached to avoid runaway retries. diff --git a/util/cache/cache.go b/util/cache/cache.go index a4fb5ff153ccb..9aa57bc6137cb 100644 --- a/util/cache/cache.go +++ b/util/cache/cache.go @@ -8,6 +8,7 @@ import ( "fmt" "math" "os" + "path/filepath" "strings" "time" @@ -33,6 +34,8 @@ const ( 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 + envRedisCredsFilePath = "REDIS_CREDS_FILE_PATH" ) const ( @@ -129,6 +132,74 @@ func getFlagVal[T any](cmd *cobra.Command, o Options, name string, getVal func(n } } +// 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 +} + +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 := "" @@ -206,25 +277,17 @@ func AddCacheFlagsToCmd(cmd *cobra.Command, opts ...Options) func() (*Cache, err } } } - password := os.Getenv(envRedisPassword) - username := os.Getenv(envRedisUsername) - sentinelUsername := os.Getenv(envRedisSentinelUsername) - sentinelPassword := os.Getenv(envRedisSentinelPassword) + var password, username, sentinelUsername, sentinelPassword string + credsFilePath := os.Getenv(envRedisCredsFilePath) if opt.FlagPrefix != "" { - if val := os.Getenv(opt.getEnvPrefix() + envRedisUsername); val != "" { - username = val - } - if val := os.Getenv(opt.getEnvPrefix() + envRedisPassword); val != "" { - password = val - } - if val := os.Getenv(opt.getEnvPrefix() + envRedisSentinelUsername); val != "" { - sentinelUsername = val - } - if val := os.Getenv(opt.getEnvPrefix() + envRedisSentinelPassword); val != "" { - sentinelPassword = val + if val := os.Getenv(opt.getEnvPrefix() + envRedisCredsFilePath); val != "" { + credsFilePath = val } } - + username, password, sentinelUsername, sentinelPassword, err := loadRedisCreds(credsFilePath, opt) + if err != nil { + return nil, err + } maxRetries := env.ParseNumFromEnv(envRedisRetryCount, defaultRedisRetryCount, 0, math.MaxInt32) compression, err := CompressionTypeFromString(compressionStr) if err != nil { diff --git a/util/cache/cache_test.go b/util/cache/cache_test.go index bcd6d5136cea8..4eeefbdcb0c09 100644 --- a/util/cache/cache_test.go +++ b/util/cache/cache_test.go @@ -1,6 +1,8 @@ package cache import ( + "os" + "path/filepath" "testing" "time" @@ -87,3 +89,99 @@ func TestGenerateCacheKey(t *testing.T) { testKey := cache.generateFullKey("testkey") assert.Equal(t, "testkey|"+common.CacheVersion, testKey) } + +// Test loading Redis credentials from a file +func TestLoadRedisCreds(t *testing.T) { + dir := t.TempDir() + // Helper to write a file + writeFile := func(name, content string) { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o400)) + } + // Write all files + writeFile("auth", "mypassword\n") + writeFile("auth_username", "myuser") + writeFile("sentinel_username", "sentineluser") + writeFile("sentinel_auth", "sentinelpass") + + username, password, sentinelUsername, sentinelPassword, err := loadRedisCreds(dir, Options{}) + require.NoError(t, err) + assert.Equal(t, "mypassword", password) + assert.Equal(t, "myuser", username) + assert.Equal(t, "sentineluser", sentinelUsername) + assert.Equal(t, "sentinelpass", sentinelPassword) +} + +// Test loading Redis credentials from environment variables +func TestLoadRedisCredsFromEnv(t *testing.T) { + // Set environment variables + t.Setenv(envRedisPassword, "mypassword") + t.Setenv(envRedisUsername, "myuser") + t.Setenv(envRedisSentinelUsername, "sentineluser") + t.Setenv(envRedisSentinelPassword, "sentinelpass") + + username, password, sentinelUsername, sentinelPassword, err := loadRedisCreds("", Options{}) + require.NoError(t, err) + assert.Equal(t, "mypassword", password) + assert.Equal(t, "myuser", username) + assert.Equal(t, "sentineluser", sentinelUsername) + assert.Equal(t, "sentinelpass", sentinelPassword) +} + +// Test loading Redis credentials from both environment variables and a file +func TestLoadRedisCredsFromBothEnvAndFile(t *testing.T) { + // Set environment variables + t.Setenv(envRedisPassword, "mypassword") + t.Setenv(envRedisUsername, "myuser") + t.Setenv(envRedisSentinelUsername, "sentineluser") + t.Setenv(envRedisSentinelPassword, "sentinelpass") + + dir := t.TempDir() + // Helper to write a file + writeFile := func(name, content string) { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o400)) + } + // Write all files + writeFile("auth", "filepassword") + writeFile("auth_username", "fileuser") + writeFile("sentinel_username", "filesentineluser") + writeFile("sentinel_auth", "filesentinelpass") + + username, password, sentinelUsername, sentinelPassword, err := loadRedisCreds(dir, Options{}) + require.NoError(t, err) + assert.Equal(t, "filepassword", password) + assert.Equal(t, "fileuser", username) + assert.Equal(t, "filesentineluser", sentinelUsername) + assert.Equal(t, "filesentinelpass", sentinelPassword) +} + +func TestLoadRedisCreds_MountPathMissing(t *testing.T) { + _, _, _, _, err := loadRedisCreds("not_existing_path", Options{}) + require.Error(t, err) + require.ErrorContains(t, err, "failed to access Redis credentials") +} + +func TestCredentialFileHandling(t *testing.T) { + t.Run("ReadAuthDetailsFromFile_Missing", func(t *testing.T) { + dir := t.TempDir() + val, err := readAuthDetailsFromFile(dir, "not_existing_path") + require.NoError(t, err) + assert.Empty(t, val) + }) + + t.Run("ReadAuthDetailsFromFile_Unreadable", func(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "auth") + require.NoError(t, os.WriteFile(file, []byte("value"), 0o000)) + _, err := readAuthDetailsFromFile(dir, "auth") + require.Error(t, err) + }) + + t.Run("ReadAuthDetailsFromFile_Normal", func(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "auth") + require.NoError(t, os.WriteFile(file, []byte("value"), 0o400)) + val, err := readAuthDetailsFromFile(dir, "auth") + require.NoError(t, err) + assert.Equal(t, "value", val) + }) +}