diff --git a/cmd/deploy.go b/cmd/deploy.go index d1216b02..d6ef36c4 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -45,11 +45,21 @@ Examples: cmd.Flags().StringVar(&envrc, "envrc", "", "Write environment to file instead of spawning sub-shell") cmd.Flags().BoolVar(&singleNamespace, "single-namespace", false, "Deploy all components in a single namespace ('stackrox' by default)") cmd.Flags().StringVarP(&tag, "tag", "t", "", "Main image tag to use for deployment (takes precedence over MAIN_IMAGE_TAG environment variable)") + cmd.Flags().StringSliceVar(&featureFlags, "features", []string{}, "Feature flag settings (e.g., +ROX_FOO,-ROX_BAR,ROX_BAZ=true)") return cmd } func runDeploy(cmd *cobra.Command, args []string) error { + // Validate flag combinations early, before env initialization + if helm && olm { + return errors.New("cannot use both --helm and --olm flags together") + } + + if helm && len(featureFlags) > 0 { + return errors.New("--features flag is not supported with --helm (feature flags only work with operator-based deployments)") + } + log := logger.New() if err := env.Initialize(log); err != nil { return err @@ -110,11 +120,6 @@ func runDeploy(cmd *cobra.Command, args []string) error { os.Setenv("KUBECONFIG", "/kubeconfig") } - // Validate flag combinations early - if helm && olm { - return errors.New("cannot use both --helm and --olm flags together") - } - if konflux { if helm { return errors.New("cannot use both --helm and --konflux flags together (Konflux requires operator-based deployment)") @@ -221,6 +226,11 @@ func runDeploy(cmd *cobra.Command, args []string) error { } d.SetMainImageTag(mainImageTag) + // Parse and set feature flags (these will have highest precedence) + if err := d.SetFeatureFlags(featureFlags); err != nil { + return fmt.Errorf("failed to set feature flags: %w", err) + } + // Resolve "auto" resources based on cluster type resolvedResources := resources if resources == "auto" { diff --git a/cmd/main.go b/cmd/main.go index f357bc56..b4a59ab9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -25,6 +25,7 @@ var ( envrc string singleNamespace bool tag string + featureFlags []string ) func main() { diff --git a/go.mod b/go.mod index 8c7d7883..66adbafe 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,34 @@ module github.com/stackrox/roxie go 1.25.0 require ( - github.com/fatih/color v1.19.0 + github.com/fatih/color v1.16.0 github.com/moby/sys/mountinfo v0.7.2 - github.com/spf13/cobra v1.10.2 - golang.org/x/term v0.41.0 + github.com/spf13/cobra v1.8.0 + golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/apimachinery v0.35.3 ) require ( + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.31.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) diff --git a/go.sum b/go.sum index 87aadbe6..8a417fb5 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,78 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/deployer/deploy_via_operator.go b/internal/deployer/deploy_via_operator.go index 2151adbf..44b75984 100644 --- a/internal/deployer/deploy_via_operator.go +++ b/internal/deployer/deploy_via_operator.go @@ -265,6 +265,11 @@ func (d *Deployer) createCentralCR(resources, exposure string) (map[string]inter merged := helpers.MergeMaps(base, resourcesOverlay, d.centralOverrides) + // Apply feature flag overrides last with smart envVars merging + if d.featureFlagOverrides != nil { + merged = mergeWithEnvVarSupport(merged, d.featureFlagOverrides) + } + return merged, nil } @@ -664,6 +669,11 @@ func (d *Deployer) createSecuredClusterCR(clusterName, resources string) (map[st merged := helpers.MergeMaps(base, resourcesOverlay, d.securedClusterOverrides) + // Apply feature flag overrides last with smart envVars merging + if d.featureFlagOverrides != nil { + merged = mergeWithEnvVarSupport(merged, d.featureFlagOverrides) + } + return merged, nil } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index adece06e..0643366e 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -111,6 +111,7 @@ type Deployer struct { exposure string centralOverrides map[string]interface{} securedClusterOverrides map[string]interface{} + featureFlagOverrides map[string]interface{} envrcFile string useHelm bool useOLM bool @@ -439,6 +440,31 @@ func (d *Deployer) SetSecuredClusterOverrideSetExpressions(overrideSetExpression return nil } +// SetFeatureFlags parses feature flags and stores them as overrides. +// Feature flags are applied last (after file-based overrides and --set) to ensure highest precedence. +func (d *Deployer) SetFeatureFlags(featureFlags []string) error { + if len(featureFlags) == 0 { + return nil + } + + flags, err := parseFeatureFlags(featureFlags) + if err != nil { + return fmt.Errorf("failed to parse feature flags: %w", err) + } + + if len(flags) == 0 { + return nil + } + + for name, value := range flags { + d.logger.Dimf("Feature flag: %s=%t", name, value) + } + + d.featureFlagOverrides = featureFlagsToOverrides(flags) + + return nil +} + func New(log *logger.Logger) (*Deployer, error) { // Check required tools first if err := checkRequiredTools(); err != nil { diff --git a/internal/deployer/feature_flags.go b/internal/deployer/feature_flags.go new file mode 100644 index 00000000..d9299be4 --- /dev/null +++ b/internal/deployer/feature_flags.go @@ -0,0 +1,157 @@ +package deployer + +import ( + "fmt" + "slices" + "sort" + "strconv" + "strings" + + "github.com/stackrox/roxie/internal/helpers" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// parseFlagWithEquals parses a feature flag in the format "ROX_FOO=true" or "ROX_FOO=false" +func parseFlagWithEquals(part string) (name string, value bool, err error) { + idx := strings.Index(part, "=") + if idx == -1 { + return "", false, fmt.Errorf("internal error: no equals sign found") + } + + name = strings.TrimSpace(part[:idx]) + valueStr := strings.TrimSpace(part[idx+1:]) + + if !strings.HasPrefix(name, "ROX_") || len(name) <= 4 { + return "", false, fmt.Errorf("invalid feature flag name %q: must be in format ROX_", name) + } + + value, err = strconv.ParseBool(valueStr) + if err != nil { + return "", false, fmt.Errorf("invalid boolean value %q for feature flag %s", valueStr, name) + } + + return name, value, nil +} + +// parseFlagWithPrefix parses a feature flag in the format "+ROX_FOO", "-ROX_FOO", or "ROX_FOO" +func parseFlagWithPrefix(part string) (name string, value bool, err error) { + value = true // default + + if strings.HasPrefix(part, "+") { + name = strings.TrimSpace(part[1:]) + } else if strings.HasPrefix(part, "-") { + name = strings.TrimSpace(part[1:]) + value = false + } else { + name = strings.TrimSpace(part) + } + + if !strings.HasPrefix(name, "ROX_") || len(name) <= 4 { + return "", false, fmt.Errorf("invalid feature flag name %q: must be in format ROX_", name) + } + + return name, value, nil +} + +// parseFeatureFlags parses a slice of feature flag strings and returns a map of flag names to boolean values. +// Supports formats: +ROX_FOO (enable), -ROX_FOO (disable), ROX_FOO=true, ROX_FOO=false, ROX_FOO (enable) +func parseFeatureFlags(flags []string) (map[string]bool, error) { + result := make(map[string]bool) + + for _, flagStr := range flags { + parts := strings.Split(flagStr, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + var name string + var value bool + var err error + + if strings.Contains(part, "=") { + name, value, err = parseFlagWithEquals(part) + } else { + name, value, err = parseFlagWithPrefix(part) + } + + if err != nil { + return nil, err + } + + result[name] = value + } + } + + return result, nil +} + +// featureFlagsToEnvVars converts feature flags to envVar maps +func featureFlagsToEnvVars(flags map[string]bool) []interface{} { + envVars := make([]interface{}, 0, len(flags)) + for name, value := range flags { + envVars = append(envVars, map[string]interface{}{ + "name": name, + "value": strconv.FormatBool(value), + }) + } + return envVars +} + +// featureFlagsToOverrides converts feature flags to a CR override structure +func featureFlagsToOverrides(flags map[string]bool) map[string]interface{} { + if len(flags) == 0 { + return nil + } + + return map[string]interface{}{ + "spec": map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": featureFlagsToEnvVars(flags), + }, + }, + } +} + +// mergeEnvVars smartly merges envVars arrays, with newer values overriding older ones by name. +// This allows feature flags to override specific env vars without wiping the entire array. +func mergeEnvVars(base, overlay []interface{}) []interface{} { + envVarMap := make(map[string]interface{}) + + for _, item := range slices.Concat(base, overlay) { + if envVar, ok := item.(map[string]interface{}); ok { + if name, ok := envVar["name"].(string); ok { + envVarMap[name] = envVar + } + } + } + + result := make([]interface{}, 0, len(envVarMap)) + for _, envVar := range envVarMap { + result = append(result, envVar) + } + + 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 +} + +// mergeWithEnvVarSupport merges two maps, with special handling for spec.customize.envVars arrays. +// Instead of replacing the entire envVars array, it merges individual env vars by name, +// allowing overlay to override specific env vars while preserving others from base. +func mergeWithEnvVarSupport(base, overlay map[string]interface{}) map[string]interface{} { + result := helpers.MergeMaps(base, overlay) + + baseEnvVars, baseFound, _ := unstructured.NestedSlice(base, "spec", "customize", "envVars") + overlayEnvVars, overlayFound, _ := unstructured.NestedSlice(overlay, "spec", "customize", "envVars") + + if baseFound && overlayFound { + _ = unstructured.SetNestedSlice(result, mergeEnvVars(baseEnvVars, overlayEnvVars), "spec", "customize", "envVars") + } + + return result +} diff --git a/internal/deployer/feature_flags_test.go b/internal/deployer/feature_flags_test.go new file mode 100644 index 00000000..d316ec71 --- /dev/null +++ b/internal/deployer/feature_flags_test.go @@ -0,0 +1,486 @@ +package deployer + +import ( + "reflect" + "testing" +) + +func TestParseFeatureFlags(t *testing.T) { + tests := []struct { + name string + input []string + expected map[string]bool + expectError bool + }{ + { + name: "plus prefix", + input: []string{"+ROX_FEATURE_1"}, + expected: map[string]bool{ + "ROX_FEATURE_1": true, + }, + }, + { + name: "minus prefix", + input: []string{"-ROX_FEATURE_1"}, + expected: map[string]bool{ + "ROX_FEATURE_1": false, + }, + }, + { + name: "explicit true", + input: []string{"ROX_FEATURE_1=true"}, + expected: map[string]bool{ + "ROX_FEATURE_1": true, + }, + }, + { + name: "explicit false", + input: []string{"ROX_FEATURE_1=false"}, + expected: map[string]bool{ + "ROX_FEATURE_1": false, + }, + }, + { + name: "no prefix defaults to true", + input: []string{"ROX_FEATURE_1"}, + expected: map[string]bool{ + "ROX_FEATURE_1": true, + }, + }, + { + name: "comma separated", + input: []string{"+ROX_F1,-ROX_F2,ROX_F3=true"}, + expected: map[string]bool{ + "ROX_F1": true, + "ROX_F2": false, + "ROX_F3": true, + }, + }, + { + name: "multiple array elements", + input: []string{"+ROX_F1", "-ROX_F2", "ROX_F3=true"}, + expected: map[string]bool{ + "ROX_F1": true, + "ROX_F2": false, + "ROX_F3": true, + }, + }, + { + name: "mixed formats with spaces", + input: []string{" +ROX_F1 , -ROX_F2 ", "ROX_F3=true"}, + expected: map[string]bool{ + "ROX_F1": true, + "ROX_F2": false, + "ROX_F3": true, + }, + }, + { + name: "override previous value", + input: []string{"ROX_F1=true", "ROX_F1=false"}, + expected: map[string]bool{ + "ROX_F1": false, + }, + }, + { + name: "invalid prefix", + input: []string{"INVALID_FLAG=true"}, + expectError: true, + }, + { + name: "invalid prefix with plus", + input: []string{"+INVALID_FLAG"}, + expectError: true, + }, + { + name: "invalid boolean value", + input: []string{"ROX_F1=notabool"}, + expectError: true, + }, + { + name: "empty flag name after ROX_", + input: []string{"+ROX_"}, + expectError: true, + }, + { + name: "empty flag name with equals", + input: []string{"ROX_=true"}, + expectError: true, + }, + { + name: "empty string", + input: []string{""}, + expected: map[string]bool{}, + }, + { + name: "empty elements in comma list", + input: []string{"+ROX_F1,,,-ROX_F2"}, + expected: map[string]bool{ + "ROX_F1": true, + "ROX_F2": false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseFeatureFlags(tt.input) + if tt.expectError { + if err == nil { + t.Errorf("expected error but got nil") + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("got %v, want %v", result, tt.expected) + } + } + }) + } +} + +func TestMergeEnvVars(t *testing.T) { + tests := []struct { + name string + base []interface{} + overlay []interface{} + expected map[string]string + checkOrdering bool + expectedOrder []string + }{ + { + name: "overlapping names - overlay wins", + base: []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "old"}, + map[string]interface{}{"name": "ROX_BAR", "value": "keep"}, + }, + overlay: []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "new"}, + }, + expected: map[string]string{ + "ROX_FOO": "new", + "ROX_BAR": "keep", + }, + }, + { + name: "no overlapping names", + base: []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, + }, + overlay: []interface{}{ + map[string]interface{}{"name": "ROX_BAR", "value": "bar"}, + }, + expected: map[string]string{ + "ROX_FOO": "foo", + "ROX_BAR": "bar", + }, + }, + { + name: "empty base", + base: []interface{}{}, + overlay: []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, + }, + expected: map[string]string{ + "ROX_FOO": "foo", + }, + }, + { + name: "empty overlay", + base: []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, + }, + overlay: []interface{}{}, + expected: map[string]string{ + "ROX_FOO": "foo", + }, + }, + { + name: "malformed envVar without name field", + base: []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, + }, + overlay: []interface{}{ + map[string]interface{}{"value": "no-name"}, // missing "name" field + map[string]interface{}{"name": "ROX_BAR", "value": "bar"}, + }, + expected: map[string]string{ + "ROX_FOO": "foo", + "ROX_BAR": "bar", + }, + }, + { + name: "result is sorted alphabetically", + base: []interface{}{ + map[string]interface{}{"name": "ROX_ZZZ", "value": "z"}, + map[string]interface{}{"name": "ROX_AAA", "value": "a"}, + }, + overlay: []interface{}{ + map[string]interface{}{"name": "ROX_MMM", "value": "m"}, + }, + expected: map[string]string{ + "ROX_AAA": "a", + "ROX_MMM": "m", + "ROX_ZZZ": "z", + }, + checkOrdering: true, + expectedOrder: []string{"ROX_AAA", "ROX_MMM", "ROX_ZZZ"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mergeEnvVars(tt.base, tt.overlay) + + if len(result) != len(tt.expected) { + t.Fatalf("expected %d env vars, got %d", len(tt.expected), len(result)) + } + + resultMap := make(map[string]string) + for _, item := range result { + envVar := item.(map[string]interface{}) + if name, ok := envVar["name"].(string); ok { + resultMap[name] = envVar["value"].(string) + } + } + + if !reflect.DeepEqual(resultMap, tt.expected) { + t.Errorf("got %v, want %v", resultMap, tt.expected) + } + + if tt.checkOrdering { + actualOrder := make([]string, len(result)) + for i, item := range result { + envVar := item.(map[string]interface{}) + actualOrder[i] = envVar["name"].(string) + } + if !reflect.DeepEqual(actualOrder, tt.expectedOrder) { + t.Errorf("ordering: got %v, want %v", actualOrder, tt.expectedOrder) + } + } + }) + } +} + +func TestMergeWithEnvVarSupport(t *testing.T) { + tests := []struct { + name string + base map[string]interface{} + overlay map[string]interface{} + expectedEnvVars map[string]string + checkOtherFields bool + }{ + { + name: "both have envVars with overlap", + base: map[string]interface{}{ + "spec": map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "old"}, + map[string]interface{}{"name": "ROX_KEEP", "value": "me"}, + }, + }, + }, + }, + overlay: map[string]interface{}{ + "spec": map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "new"}, + }, + }, + }, + }, + expectedEnvVars: map[string]string{ + "ROX_FOO": "new", + "ROX_KEEP": "me", + }, + }, + { + name: "base has envVars, overlay has none", + base: map[string]interface{}{ + "spec": map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, + }, + }, + }, + }, + overlay: map[string]interface{}{ + "spec": map[string]interface{}{ + "other": "field", + }, + }, + expectedEnvVars: map[string]string{ + "ROX_FOO": "foo", + }, + }, + { + name: "overlay has envVars, base has none", + base: map[string]interface{}{ + "spec": map[string]interface{}{ + "other": "field", + }, + }, + overlay: map[string]interface{}{ + "spec": map[string]interface{}{ + "customize": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"name": "ROX_FOO", "value": "foo"}, + }, + }, + }, + }, + expectedEnvVars: map[string]string{ + "ROX_FOO": "foo", + }, + }, + { + name: "neither has envVars", + base: map[string]interface{}{ + "spec": map[string]interface{}{ + "other": "field", + }, + }, + overlay: map[string]interface{}{ + "spec": map[string]interface{}{ + "another": "field", + }, + }, + expectedEnvVars: nil, + checkOtherFields: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mergeWithEnvVarSupport(tt.base, tt.overlay) + + spec, ok := result["spec"].(map[string]interface{}) + if !ok { + t.Fatal("spec should be a map") + } + + if tt.expectedEnvVars != nil { + customize, ok := spec["customize"].(map[string]interface{}) + if !ok { + t.Fatal("customize should be a map") + } + + envVars, ok := customize["envVars"].([]interface{}) + if !ok { + t.Fatal("envVars should be a slice") + } + + if len(envVars) != len(tt.expectedEnvVars) { + t.Fatalf("expected %d env vars, got %d", len(tt.expectedEnvVars), len(envVars)) + } + + resultMap := make(map[string]string) + for _, item := range envVars { + envVar := item.(map[string]interface{}) + resultMap[envVar["name"].(string)] = envVar["value"].(string) + } + + if !reflect.DeepEqual(resultMap, tt.expectedEnvVars) { + t.Errorf("got %v, want %v", resultMap, tt.expectedEnvVars) + } + } + + if tt.checkOtherFields { + if spec["other"] != "field" { + t.Errorf("expected base field to be preserved") + } + if spec["another"] != "field" { + t.Errorf("expected overlay field to be added") + } + } + }) + } +} + +func TestFeatureFlagsConversion(t *testing.T) { + tests := []struct { + name string + input map[string]bool + expectNil bool + expectedVars map[string]string + }{ + { + name: "to env vars", + input: map[string]bool{ + "ROX_F1": true, + "ROX_F2": false, + }, + expectedVars: map[string]string{ + "ROX_F1": "true", + "ROX_F2": "false", + }, + }, + { + name: "single flag to overrides", + input: map[string]bool{ + "ROX_F1": true, + }, + expectedVars: map[string]string{ + "ROX_F1": "true", + }, + }, + { + name: "empty flags", + input: map[string]bool{}, + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := featureFlagsToOverrides(tt.input) + + if tt.expectNil { + if result != nil { + t.Errorf("expected nil for empty flags, got %v", result) + } + return + } + + if result == nil { + t.Fatal("result should not be nil") + } + + spec, ok := result["spec"].(map[string]interface{}) + if !ok { + t.Fatal("spec should be a map") + } + + customize, ok := spec["customize"].(map[string]interface{}) + if !ok { + t.Fatal("customize should be a map") + } + + envVars, ok := customize["envVars"].([]interface{}) + if !ok { + t.Fatal("envVars should be a slice of interface{}") + } + + if len(envVars) != len(tt.expectedVars) { + t.Fatalf("expected %d env var(s), got %d", len(tt.expectedVars), len(envVars)) + } + + resultMap := make(map[string]string) + for _, item := range envVars { + envVar, ok := item.(map[string]interface{}) + if !ok { + t.Fatal("envVar should be a map") + } + resultMap[envVar["name"].(string)] = envVar["value"].(string) + } + + if !reflect.DeepEqual(resultMap, tt.expectedVars) { + t.Errorf("got %v, want %v", resultMap, tt.expectedVars) + } + }) + } +}