diff --git a/cmd/deploy.go b/cmd/deploy.go index 5d14635..a2f631d 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -197,6 +197,20 @@ Examples: }), ) + registerFlag(cmd, settings, "operator-env", "Operator environment variables (e.g., RELATED_IMAGE_MAIN=quay.io/...)", + withApplyFn("env-var", func(config *deployer.Config, envExpr string) error { + key, value, err := deployer.ParseOperatorEnvVar(envExpr) + if err != nil { + return fmt.Errorf("parsing operator env var: %w", err) + } + if config.Operator.EnvVars == nil { + config.Operator.EnvVars = make(map[string]string) + } + config.Operator.EnvVars[key] = value + return nil + }), + ) + registerFlag(cmd, settings, "features", "Feature flag settings (e.g., +ROX_FOO,-ROX_BAR,ROX_BAZ=true)", withApplyFn("feature-flags", func(config *deployer.Config, featureFlagExpr string) error { featureFlags, err := deployer.ParseFeatureFlags([]string{featureFlagExpr}) diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 6443b02..ccf6749 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -144,6 +144,32 @@ func TestNewDeployCmd_Flags(t *testing.T) { assert.Equal(t, types.ResourceProfileSmall, cfg.Central.ResourceProfile, "Central.ResourceProfile mismatch") }, }, + { + name: "operator-env single", + args: []string{"--operator-env", "RELATED_IMAGE_MAIN=quay.io/main:4.7.0"}, + assert: func(t *testing.T, cfg deployer.Config) { + require.NotNil(t, cfg.Operator.EnvVars, "Operator.EnvVars should be set") + assert.Equal(t, "quay.io/main:4.7.0", cfg.Operator.EnvVars["RELATED_IMAGE_MAIN"]) + }, + }, + { + name: "operator-env containing commas", + args: []string{"--operator-env", "FOO=bar,BAZ=qux,quux"}, + assert: func(t *testing.T, cfg deployer.Config) { + require.NotNil(t, cfg.Operator.EnvVars, "Operator.EnvVars should be set") + assert.Equal(t, "bar,BAZ=qux,quux", cfg.Operator.EnvVars["FOO"]) + assert.NotContains(t, cfg.Operator.EnvVars, "BAZ") + }, + }, + { + name: "operator-env multiple flags", + args: []string{"--operator-env", "FOO=bar", "--operator-env", "BAZ=qux"}, + assert: func(t *testing.T, cfg deployer.Config) { + require.NotNil(t, cfg.Operator.EnvVars, "Operator.EnvVars should be set") + assert.Equal(t, "bar", cfg.Operator.EnvVars["FOO"]) + assert.Equal(t, "qux", cfg.Operator.EnvVars["BAZ"]) + }, + }, { name: "config file can be used", config: ` @@ -166,6 +192,22 @@ securedCluster: }, }, + { + name: "config file with operator env vars", + config: ` +operator: + envVars: + RELATED_IMAGE_MAIN: quay.io/rhacs-eng/main:4.7.0 + RELATED_IMAGE_SCANNER: quay.io/rhacs-eng/scanner:4.7.0 +`, + args: []string{"--config", configFilePath}, + assert: func(t *testing.T, cfg deployer.Config) { + require.NotNil(t, cfg.Operator.EnvVars, "Operator.EnvVars should be set") + assert.Equal(t, "quay.io/rhacs-eng/main:4.7.0", cfg.Operator.EnvVars["RELATED_IMAGE_MAIN"]) + assert.Equal(t, "quay.io/rhacs-eng/scanner:4.7.0", cfg.Operator.EnvVars["RELATED_IMAGE_SCANNER"]) + }, + }, + { name: "config file can disable early-readiness", config: ` diff --git a/internal/deployer/config.go b/internal/deployer/config.go index 01e541e..ea0bd82 100644 --- a/internal/deployer/config.go +++ b/internal/deployer/config.go @@ -76,9 +76,10 @@ func NewRoxieConfig() RoxieConfig { // OperatorConfig controls how the ACS operator is deployed. type OperatorConfig struct { - SkipDeployment *bool `yaml:"skipDeployment,omitempty"` - DeployViaOlm *bool `yaml:"deployViaOlm,omitempty"` - Version string `yaml:"version,omitempty"` + SkipDeployment *bool `yaml:"skipDeployment,omitempty"` + DeployViaOlm *bool `yaml:"deployViaOlm,omitempty"` + Version string `yaml:"version,omitempty"` + EnvVars map[string]string `yaml:"envVars,omitempty"` } func (c *OperatorConfig) SkipDeploymentSet() bool { diff --git a/internal/deployer/operator.go b/internal/deployer/operator.go index f271a4a..02e3999 100644 --- a/internal/deployer/operator.go +++ b/internal/deployer/operator.go @@ -321,9 +321,16 @@ func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string) d.useOperatorPullSecrets = d.config.Roxie.KonfluxImagesEnabled() && d.config.Roxie.ClusterType.NeedsPullSecrets() d.logger.Info("📋 Operator deployment plan:") - d.logger.Dim(fmt.Sprintf(" • Namespace: %s", operatorNamespace)) - d.logger.Dim(fmt.Sprintf(" • ServiceAccount: %s", serviceAccountName)) - d.logger.Dim(fmt.Sprintf(" • Setting up pull secrets: %v", d.useOperatorPullSecrets)) + d.logger.Dimf(" • Namespace: %s", operatorNamespace) + d.logger.Dimf(" • ServiceAccount: %s", serviceAccountName) + d.logger.Dimf(" • Setting up pull secrets: %v", d.useOperatorPullSecrets) + if len(d.config.Operator.EnvVars) > 0 { + d.logger.Dimf(" • Custom operator env vars: %d", len(d.config.Operator.EnvVars)) + for _, envVar := range envVarsToSortedList(d.config.Operator.EnvVars) { + ev := envVar.(map[string]interface{}) + d.logger.Dimf(" %s=%s", ev["name"], ev["value"]) + } + } if err := d.prepareNamespace(ctx, operatorNamespace, d.useOperatorPullSecrets); err != nil { return err @@ -530,6 +537,16 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string if template, ok := spec["template"].(map[string]interface{}); ok { if podSpec, ok := template["spec"].(map[string]interface{}); ok { podSpec["serviceAccountName"] = deploymentSpec["service_account"] + + if len(d.config.Operator.EnvVars) > 0 { + containers, ok := podSpec["containers"].([]interface{}) + if !ok { + return errors.New("no containers found in deployment pod spec") + } + if err := d.injectEnvVarsIntoManagerContainer(containers); err != nil { + return fmt.Errorf("failed to inject operator env vars: %w", err) + } + } } } @@ -548,6 +565,45 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string return nil } +const managerContainerName = "manager" + +// injectEnvVarsIntoManagerContainer merges configured operator env vars into +// the manager container, overriding any existing env vars with the same name. +func (d *Deployer) injectEnvVarsIntoManagerContainer(containers []interface{}) error { + for _, c := range containers { + container, ok := c.(map[string]interface{}) + if !ok { + continue + } + if container["name"] != managerContainerName { + continue + } + + existing := make(map[string]int) + envList, _ := container["env"].([]interface{}) + for i, item := range envList { + if envVar, ok := item.(map[string]interface{}); ok { + if name, ok := envVar["name"].(string); ok { + existing[name] = i + } + } + } + + for _, envVar := range envVarsToSortedList(d.config.Operator.EnvVars) { + name := envVar.(map[string]interface{})["name"].(string) + if idx, found := existing[name]; found { + envList[idx] = envVar + } else { + envList = append(envList, envVar) + } + } + + container["env"] = envList + return nil + } + return fmt.Errorf("container %q not found in deployment", managerContainerName) +} + func (d *Deployer) applyBundleServiceResources(ctx context.Context, bundleDir, namespace string) error { serviceFile := filepath.Join(bundleDir, "rhacs-operator-controller-manager-metrics-service_v1_service.yaml") if _, err := os.Stat(serviceFile); err == nil { diff --git a/internal/deployer/operator_env.go b/internal/deployer/operator_env.go new file mode 100644 index 0000000..0bcfd84 --- /dev/null +++ b/internal/deployer/operator_env.go @@ -0,0 +1,38 @@ +package deployer + +import ( + "fmt" + "sort" + "strings" +) + +// ParseOperatorEnvVar parses a single KEY=VALUE environment variable string. +// Values may contain '=' characters (only the first '=' is used as the separator). +func ParseOperatorEnvVar(envExpr string) (string, string, error) { + key, value, found := strings.Cut(envExpr, "=") + key = strings.TrimSpace(key) + if !found { + return "", "", fmt.Errorf("invalid operator env var %q: expected KEY=VALUE format", envExpr) + } + if key == "" { + return "", "", fmt.Errorf("invalid operator env var %q: empty key", envExpr) + } + return key, value, nil +} + +// envVarsToSortedList converts a map of env vars to a sorted list of +// {name, value} maps suitable for use in Kubernetes container or OLM Subscription specs. +func envVarsToSortedList(envVars map[string]string) []interface{} { + result := make([]interface{}, 0, len(envVars)) + for name, value := range envVars { + result = append(result, map[string]interface{}{ + "name": name, + "value": value, + }) + } + sort.Slice(result, func(i, j int) bool { + return result[i].(map[string]interface{})["name"].(string) < + result[j].(map[string]interface{})["name"].(string) + }) + return result +} diff --git a/internal/deployer/operator_env_test.go b/internal/deployer/operator_env_test.go new file mode 100644 index 0000000..3f8d901 --- /dev/null +++ b/internal/deployer/operator_env_test.go @@ -0,0 +1,100 @@ +package deployer + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseOperatorEnvVar(t *testing.T) { + tests := []struct { + name string + input string + expectedKey string + expectedVal string + expectError bool + }{ + { + name: "simple KEY=VALUE", + input: "RELATED_IMAGE_MAIN=quay.io/rhacs-eng/main:4.7.0", + expectedKey: "RELATED_IMAGE_MAIN", + expectedVal: "quay.io/rhacs-eng/main:4.7.0", + }, + { + name: "value containing equals sign", + input: "MY_VAR=a=b=c", + expectedKey: "MY_VAR", + expectedVal: "a=b=c", + }, + { + name: "empty value", + input: "MY_VAR=", + expectedKey: "MY_VAR", + expectedVal: "", + }, + { + name: "key with spaces", + input: " MY_VAR =foo", + expectedKey: "MY_VAR", + expectedVal: "foo", + }, + { + name: "value with spaces", + input: "MY_VAR= hello world ", + expectedKey: "MY_VAR", + expectedVal: " hello world ", + }, + { + name: "value with commas", + input: "MY_VAR=a,b,c", + expectedKey: "MY_VAR", + expectedVal: "a,b,c", + }, + { + name: "missing equals sign", + input: "NO_VALUE", + expectError: true, + }, + { + name: "empty key", + input: "=value", + expectError: true, + }, + { + name: "whitespace key", + input: " =value", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, value, err := ParseOperatorEnvVar(tt.input) + if tt.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expectedKey, key) + assert.Equal(t, tt.expectedVal, value) + }) + } +} + +func TestEnvVarsToSortedList(t *testing.T) { + input := map[string]string{ + "ZZZ": "z", + "AAA": "a", + "MMM": "m", + } + result := envVarsToSortedList(input) + + require.Len(t, result, 3) + + expectedOrder := []string{"AAA", "MMM", "ZZZ"} + for i, item := range result { + name := item.(map[string]interface{})["name"].(string) + assert.Equal(t, expectedOrder[i], name, "index %d", i) + } +} diff --git a/internal/deployer/operator_olm.go b/internal/deployer/operator_olm.go index 9428cac..c55c0fc 100644 --- a/internal/deployer/operator_olm.go +++ b/internal/deployer/operator_olm.go @@ -33,6 +33,13 @@ const ( func (d *Deployer) deployOperatorViaOLM(ctx context.Context) error { d.logger.Info("🚀 Deploying operator via OLM...") d.logger.Infof("Operator tag: %s", d.config.Operator.Version) + if len(d.config.Operator.EnvVars) > 0 { + d.logger.Infof("Custom operator env vars: %d", len(d.config.Operator.EnvVars)) + for _, envVar := range envVarsToSortedList(d.config.Operator.EnvVars) { + ev := envVar.(map[string]interface{}) + d.logger.Dimf(" %s=%s", ev["name"], ev["value"]) + } + } if err := d.checkOLMInstalled(ctx); err != nil { return err @@ -200,6 +207,21 @@ func (d *Deployer) createSubscription(ctx context.Context) error { startingCSV := fmt.Sprintf("rhacs-operator.v%s", d.config.Operator.Version) + subscriptionSpec := map[string]interface{}{ + "channel": operatorChannel, + "name": "rhacs-operator", + "source": catalogSourceName, + "sourceNamespace": operatorNamespace, + "installPlanApproval": "Manual", + "startingCSV": startingCSV, + } + + if len(d.config.Operator.EnvVars) > 0 { + subscriptionSpec["config"] = map[string]interface{}{ + "env": envVarsToSortedList(d.config.Operator.EnvVars), + } + } + subscription := map[string]interface{}{ "apiVersion": "operators.coreos.com/v1alpha1", "kind": "Subscription", @@ -207,14 +229,7 @@ func (d *Deployer) createSubscription(ctx context.Context) error { "name": subscriptionName, "namespace": operatorNamespace, }, - "spec": map[string]interface{}{ - "channel": operatorChannel, - "name": "rhacs-operator", - "source": catalogSourceName, - "sourceNamespace": operatorNamespace, - "installPlanApproval": "Manual", - "startingCSV": startingCSV, - }, + "spec": subscriptionSpec, } yamlData, err := yaml.Marshal(subscription)