Skip to content

Commit 189e745

Browse files
porridgeclaude
andauthored
Add support for operator environment variables (#215)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e528172 commit 189e745

7 files changed

Lines changed: 280 additions & 14 deletions

File tree

cmd/deploy.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ this flag can be used to tell roxie how to pre-load images for the current clust
184184
}),
185185
)
186186

187+
registerFlag(cmd, settings, "operator-env", "Operator environment variables (e.g., RELATED_IMAGE_MAIN=quay.io/...)",
188+
withApplyFn("env-var", func(config *deployer.Config, envExpr string) error {
189+
key, value, err := deployer.ParseOperatorEnvVar(envExpr)
190+
if err != nil {
191+
return fmt.Errorf("parsing operator env var: %w", err)
192+
}
193+
if config.Operator.EnvVars == nil {
194+
config.Operator.EnvVars = make(map[string]string)
195+
}
196+
config.Operator.EnvVars[key] = value
197+
return nil
198+
}),
199+
)
200+
187201
registerFlag(cmd, settings, "features", "Feature flag settings (e.g., +ROX_FOO,-ROX_BAR,ROX_BAZ=true)",
188202
withApplyFn("feature-flags", func(config *deployer.Config, featureFlagExpr string) error {
189203
featureFlags, err := deployer.ParseFeatureFlags([]string{featureFlagExpr})

cmd/deploy_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,32 @@ func TestNewDeployCmd_Flags(t *testing.T) {
144144
assert.Equal(t, types.ResourceProfileSmall, cfg.Central.ResourceProfile, "Central.ResourceProfile mismatch")
145145
},
146146
},
147+
{
148+
name: "operator-env single",
149+
args: []string{"--operator-env", "RELATED_IMAGE_MAIN=quay.io/main:4.7.0"},
150+
assert: func(t *testing.T, cfg deployer.Config) {
151+
require.NotNil(t, cfg.Operator.EnvVars, "Operator.EnvVars should be set")
152+
assert.Equal(t, "quay.io/main:4.7.0", cfg.Operator.EnvVars["RELATED_IMAGE_MAIN"])
153+
},
154+
},
155+
{
156+
name: "operator-env containing commas",
157+
args: []string{"--operator-env", "FOO=bar,BAZ=qux,quux"},
158+
assert: func(t *testing.T, cfg deployer.Config) {
159+
require.NotNil(t, cfg.Operator.EnvVars, "Operator.EnvVars should be set")
160+
assert.Equal(t, "bar,BAZ=qux,quux", cfg.Operator.EnvVars["FOO"])
161+
assert.NotContains(t, cfg.Operator.EnvVars, "BAZ")
162+
},
163+
},
164+
{
165+
name: "operator-env multiple flags",
166+
args: []string{"--operator-env", "FOO=bar", "--operator-env", "BAZ=qux"},
167+
assert: func(t *testing.T, cfg deployer.Config) {
168+
require.NotNil(t, cfg.Operator.EnvVars, "Operator.EnvVars should be set")
169+
assert.Equal(t, "bar", cfg.Operator.EnvVars["FOO"])
170+
assert.Equal(t, "qux", cfg.Operator.EnvVars["BAZ"])
171+
},
172+
},
147173
{
148174
name: "config file can be used",
149175
config: `
@@ -166,6 +192,22 @@ securedCluster:
166192
},
167193
},
168194

195+
{
196+
name: "config file with operator env vars",
197+
config: `
198+
operator:
199+
envVars:
200+
RELATED_IMAGE_MAIN: quay.io/rhacs-eng/main:4.7.0
201+
RELATED_IMAGE_SCANNER: quay.io/rhacs-eng/scanner:4.7.0
202+
`,
203+
args: []string{"--config", configFilePath},
204+
assert: func(t *testing.T, cfg deployer.Config) {
205+
require.NotNil(t, cfg.Operator.EnvVars, "Operator.EnvVars should be set")
206+
assert.Equal(t, "quay.io/rhacs-eng/main:4.7.0", cfg.Operator.EnvVars["RELATED_IMAGE_MAIN"])
207+
assert.Equal(t, "quay.io/rhacs-eng/scanner:4.7.0", cfg.Operator.EnvVars["RELATED_IMAGE_SCANNER"])
208+
},
209+
},
210+
169211
{
170212
name: "config file can disable early-readiness",
171213
config: `

internal/deployer/config.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,10 @@ func NewRoxieConfig() RoxieConfig {
7676

7777
// OperatorConfig controls how the ACS operator is deployed.
7878
type OperatorConfig struct {
79-
SkipDeployment *bool `yaml:"skipDeployment,omitempty"`
80-
DeployViaOlm *bool `yaml:"deployViaOlm,omitempty"`
81-
Version string `yaml:"version,omitempty"`
79+
SkipDeployment *bool `yaml:"skipDeployment,omitempty"`
80+
DeployViaOlm *bool `yaml:"deployViaOlm,omitempty"`
81+
Version string `yaml:"version,omitempty"`
82+
EnvVars map[string]string `yaml:"envVars,omitempty"`
8283
}
8384

8485
func (c *OperatorConfig) SkipDeploymentSet() bool {

internal/deployer/operator.go

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,16 @@ func (d *Deployer) deployOperatorFromCSV(ctx context.Context, bundleDir string)
311311
d.useOperatorPullSecrets = d.config.Roxie.KonfluxImagesEnabled() && d.config.Roxie.ClusterType.NeedsPullSecrets()
312312

313313
d.logger.Info("📋 Operator deployment plan:")
314-
d.logger.Dim(fmt.Sprintf(" • Namespace: %s", operatorNamespace))
315-
d.logger.Dim(fmt.Sprintf(" • ServiceAccount: %s", serviceAccountName))
316-
d.logger.Dim(fmt.Sprintf(" • Setting up pull secrets: %v", d.useOperatorPullSecrets))
314+
d.logger.Dimf(" • Namespace: %s", operatorNamespace)
315+
d.logger.Dimf(" • ServiceAccount: %s", serviceAccountName)
316+
d.logger.Dimf(" • Setting up pull secrets: %v", d.useOperatorPullSecrets)
317+
if len(d.config.Operator.EnvVars) > 0 {
318+
d.logger.Dimf(" • Custom operator env vars: %d", len(d.config.Operator.EnvVars))
319+
for _, envVar := range envVarsToSortedList(d.config.Operator.EnvVars) {
320+
ev := envVar.(map[string]interface{})
321+
d.logger.Dimf(" %s=%s", ev["name"], ev["value"])
322+
}
323+
}
317324

318325
if err := d.prepareNamespace(ctx, operatorNamespace, d.useOperatorPullSecrets); err != nil {
319326
return err
@@ -520,6 +527,16 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string
520527
if template, ok := spec["template"].(map[string]interface{}); ok {
521528
if podSpec, ok := template["spec"].(map[string]interface{}); ok {
522529
podSpec["serviceAccountName"] = deploymentSpec["service_account"]
530+
531+
if len(d.config.Operator.EnvVars) > 0 {
532+
containers, ok := podSpec["containers"].([]interface{})
533+
if !ok {
534+
return errors.New("no containers found in deployment pod spec")
535+
}
536+
if err := d.injectEnvVarsIntoManagerContainer(containers); err != nil {
537+
return fmt.Errorf("failed to inject operator env vars: %w", err)
538+
}
539+
}
523540
}
524541
}
525542

@@ -538,6 +555,45 @@ func (d *Deployer) createDeploymentFromCSV(ctx context.Context, namespace string
538555
return nil
539556
}
540557

558+
const managerContainerName = "manager"
559+
560+
// injectEnvVarsIntoManagerContainer merges configured operator env vars into
561+
// the manager container, overriding any existing env vars with the same name.
562+
func (d *Deployer) injectEnvVarsIntoManagerContainer(containers []interface{}) error {
563+
for _, c := range containers {
564+
container, ok := c.(map[string]interface{})
565+
if !ok {
566+
continue
567+
}
568+
if container["name"] != managerContainerName {
569+
continue
570+
}
571+
572+
existing := make(map[string]int)
573+
envList, _ := container["env"].([]interface{})
574+
for i, item := range envList {
575+
if envVar, ok := item.(map[string]interface{}); ok {
576+
if name, ok := envVar["name"].(string); ok {
577+
existing[name] = i
578+
}
579+
}
580+
}
581+
582+
for _, envVar := range envVarsToSortedList(d.config.Operator.EnvVars) {
583+
name := envVar.(map[string]interface{})["name"].(string)
584+
if idx, found := existing[name]; found {
585+
envList[idx] = envVar
586+
} else {
587+
envList = append(envList, envVar)
588+
}
589+
}
590+
591+
container["env"] = envList
592+
return nil
593+
}
594+
return fmt.Errorf("container %q not found in deployment", managerContainerName)
595+
}
596+
541597
func (d *Deployer) applyBundleServiceResources(ctx context.Context, bundleDir, namespace string) error {
542598
serviceFile := filepath.Join(bundleDir, "rhacs-operator-controller-manager-metrics-service_v1_service.yaml")
543599
if _, err := os.Stat(serviceFile); err == nil {

internal/deployer/operator_env.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package deployer
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
)
8+
9+
// ParseOperatorEnvVar parses a single KEY=VALUE environment variable string.
10+
// Values may contain '=' characters (only the first '=' is used as the separator).
11+
func ParseOperatorEnvVar(envExpr string) (string, string, error) {
12+
key, value, found := strings.Cut(envExpr, "=")
13+
key = strings.TrimSpace(key)
14+
if !found {
15+
return "", "", fmt.Errorf("invalid operator env var %q: expected KEY=VALUE format", envExpr)
16+
}
17+
if key == "" {
18+
return "", "", fmt.Errorf("invalid operator env var %q: empty key", envExpr)
19+
}
20+
return key, value, nil
21+
}
22+
23+
// envVarsToSortedList converts a map of env vars to a sorted list of
24+
// {name, value} maps suitable for use in Kubernetes container or OLM Subscription specs.
25+
func envVarsToSortedList(envVars map[string]string) []interface{} {
26+
result := make([]interface{}, 0, len(envVars))
27+
for name, value := range envVars {
28+
result = append(result, map[string]interface{}{
29+
"name": name,
30+
"value": value,
31+
})
32+
}
33+
sort.Slice(result, func(i, j int) bool {
34+
return result[i].(map[string]interface{})["name"].(string) <
35+
result[j].(map[string]interface{})["name"].(string)
36+
})
37+
return result
38+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package deployer
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestParseOperatorEnvVar(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
input string
14+
expectedKey string
15+
expectedVal string
16+
expectError bool
17+
}{
18+
{
19+
name: "simple KEY=VALUE",
20+
input: "RELATED_IMAGE_MAIN=quay.io/rhacs-eng/main:4.7.0",
21+
expectedKey: "RELATED_IMAGE_MAIN",
22+
expectedVal: "quay.io/rhacs-eng/main:4.7.0",
23+
},
24+
{
25+
name: "value containing equals sign",
26+
input: "MY_VAR=a=b=c",
27+
expectedKey: "MY_VAR",
28+
expectedVal: "a=b=c",
29+
},
30+
{
31+
name: "empty value",
32+
input: "MY_VAR=",
33+
expectedKey: "MY_VAR",
34+
expectedVal: "",
35+
},
36+
{
37+
name: "key with spaces",
38+
input: " MY_VAR =foo",
39+
expectedKey: "MY_VAR",
40+
expectedVal: "foo",
41+
},
42+
{
43+
name: "value with spaces",
44+
input: "MY_VAR= hello world ",
45+
expectedKey: "MY_VAR",
46+
expectedVal: " hello world ",
47+
},
48+
{
49+
name: "value with commas",
50+
input: "MY_VAR=a,b,c",
51+
expectedKey: "MY_VAR",
52+
expectedVal: "a,b,c",
53+
},
54+
{
55+
name: "missing equals sign",
56+
input: "NO_VALUE",
57+
expectError: true,
58+
},
59+
{
60+
name: "empty key",
61+
input: "=value",
62+
expectError: true,
63+
},
64+
{
65+
name: "whitespace key",
66+
input: " =value",
67+
expectError: true,
68+
},
69+
}
70+
71+
for _, tt := range tests {
72+
t.Run(tt.name, func(t *testing.T) {
73+
key, value, err := ParseOperatorEnvVar(tt.input)
74+
if tt.expectError {
75+
require.Error(t, err)
76+
return
77+
}
78+
require.NoError(t, err)
79+
assert.Equal(t, tt.expectedKey, key)
80+
assert.Equal(t, tt.expectedVal, value)
81+
})
82+
}
83+
}
84+
85+
func TestEnvVarsToSortedList(t *testing.T) {
86+
input := map[string]string{
87+
"ZZZ": "z",
88+
"AAA": "a",
89+
"MMM": "m",
90+
}
91+
result := envVarsToSortedList(input)
92+
93+
require.Len(t, result, 3)
94+
95+
expectedOrder := []string{"AAA", "MMM", "ZZZ"}
96+
for i, item := range result {
97+
name := item.(map[string]interface{})["name"].(string)
98+
assert.Equal(t, expectedOrder[i], name, "index %d", i)
99+
}
100+
}

internal/deployer/operator_olm.go

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ const (
3333
func (d *Deployer) deployOperatorViaOLM(ctx context.Context) error {
3434
d.logger.Info("🚀 Deploying operator via OLM...")
3535
d.logger.Infof("Operator tag: %s", d.config.Operator.Version)
36+
if len(d.config.Operator.EnvVars) > 0 {
37+
d.logger.Infof("Custom operator env vars: %d", len(d.config.Operator.EnvVars))
38+
for _, envVar := range envVarsToSortedList(d.config.Operator.EnvVars) {
39+
ev := envVar.(map[string]interface{})
40+
d.logger.Dimf(" %s=%s", ev["name"], ev["value"])
41+
}
42+
}
3643

3744
if err := d.checkOLMInstalled(ctx); err != nil {
3845
return err
@@ -200,21 +207,29 @@ func (d *Deployer) createSubscription(ctx context.Context) error {
200207

201208
startingCSV := fmt.Sprintf("rhacs-operator.v%s", d.config.Operator.Version)
202209

210+
subscriptionSpec := map[string]interface{}{
211+
"channel": operatorChannel,
212+
"name": "rhacs-operator",
213+
"source": catalogSourceName,
214+
"sourceNamespace": operatorNamespace,
215+
"installPlanApproval": "Manual",
216+
"startingCSV": startingCSV,
217+
}
218+
219+
if len(d.config.Operator.EnvVars) > 0 {
220+
subscriptionSpec["config"] = map[string]interface{}{
221+
"env": envVarsToSortedList(d.config.Operator.EnvVars),
222+
}
223+
}
224+
203225
subscription := map[string]interface{}{
204226
"apiVersion": "operators.coreos.com/v1alpha1",
205227
"kind": "Subscription",
206228
"metadata": map[string]interface{}{
207229
"name": subscriptionName,
208230
"namespace": operatorNamespace,
209231
},
210-
"spec": map[string]interface{}{
211-
"channel": operatorChannel,
212-
"name": "rhacs-operator",
213-
"source": catalogSourceName,
214-
"sourceNamespace": operatorNamespace,
215-
"installPlanApproval": "Manual",
216-
"startingCSV": startingCSV,
217-
},
232+
"spec": subscriptionSpec,
218233
}
219234

220235
yamlData, err := yaml.Marshal(subscription)

0 commit comments

Comments
 (0)