mirror of https://github.com/helm/helm
Signed-off-by: Idryss Bourdier <thegogolplex@gmail.com>pull/32497/head
parent
a8ab76e86f
commit
132f5d4fff
@ -0,0 +1,283 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package action
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/prometheus/common/model"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
)
|
||||
|
||||
// featureGateMetricName is the Prometheus gauge every Kubernetes component
|
||||
// built on k8s.io/component-base exposes on its own /metrics endpoint,
|
||||
// recording the enablement state of every feature gate known to that
|
||||
// process. See:
|
||||
// https://kubernetes.io/docs/tasks/administer-cluster/configure-feature-gates/#check-via-metrics-endpoint
|
||||
const featureGateMetricName = "kubernetes_feature_enabled"
|
||||
|
||||
// defaultKubeletPort is used when a Node does not report a kubelet port.
|
||||
const defaultKubeletPort = 10250
|
||||
|
||||
// featureGateFetchTimeout bounds each network call in this file: rendering
|
||||
// happens outside the window helm's --timeout flag covers.
|
||||
const featureGateFetchTimeout = 10 * time.Second
|
||||
|
||||
// supportedKubeFeatureGateComponents are the components this version of Helm
|
||||
// can actively verify feature gates for. Components accepted by chart
|
||||
// validation (chart.KubeFeatureGateComponents) but not listed here are only
|
||||
// ever warned about, never enforced: checking them requires machinery Helm
|
||||
// does not have today (pod discovery plus a port-forward/exec tunnel), and
|
||||
// they are unreachable on most managed Kubernetes offerings regardless.
|
||||
var supportedKubeFeatureGateComponents = map[string]bool{
|
||||
"apiserver": true,
|
||||
"kubelet": true,
|
||||
}
|
||||
|
||||
// mergedKubeFeatureGates collects the kubeFeatureGates requirements declared
|
||||
// by chrt and all of its dependencies (recursively) into a single map, so a
|
||||
// component is only ever polled once regardless of how many chart levels
|
||||
// declare requirements for it. Conflicting requirements for the same gate
|
||||
// from different chart levels are a chart authoring error, not a cluster
|
||||
// state to warn about, so they fail immediately.
|
||||
func mergedKubeFeatureGates(chrt *chart.Chart) (map[string]map[string]bool, error) {
|
||||
type declaration struct {
|
||||
chart string
|
||||
want bool
|
||||
}
|
||||
declaredBy := map[string]map[string]declaration{}
|
||||
|
||||
var walk func(c *chart.Chart) error
|
||||
walk = func(c *chart.Chart) error {
|
||||
for component, gates := range c.Metadata.KubeFeatureGates {
|
||||
if declaredBy[component] == nil {
|
||||
declaredBy[component] = map[string]declaration{}
|
||||
}
|
||||
for gate, want := range gates {
|
||||
if prior, ok := declaredBy[component][gate]; ok && prior.want != want {
|
||||
return fmt.Errorf("chart %q requires kubeFeatureGates.%s.%s=%t but chart %q requires %t", c.Name(), component, gate, want, prior.chart, prior.want)
|
||||
}
|
||||
declaredBy[component][gate] = declaration{chart: c.Name(), want: want}
|
||||
}
|
||||
}
|
||||
for _, dep := range c.Dependencies() {
|
||||
if err := walk(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(chrt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
merged := make(map[string]map[string]bool, len(declaredBy))
|
||||
for component, gates := range declaredBy {
|
||||
g := make(map[string]bool, len(gates))
|
||||
for gate, d := range gates {
|
||||
g[gate] = d.want
|
||||
}
|
||||
merged[component] = g
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// checkKubeFeatureGates verifies that the feature gates required by the
|
||||
// chart (keyed by component name, then by gate name) match the state
|
||||
// reported by the cluster. A component missing from wanted is not checked at
|
||||
// all.
|
||||
//
|
||||
// A component this version of Helm does not yet support checking is only
|
||||
// logged as a warning and does not block installation. For a component Helm
|
||||
// does support (apiserver, kubelet), any failure to positively confirm a
|
||||
// required gate -- RBAC denied, component unreachable, timeout, a parse
|
||||
// error, or the gate simply not being reported -- blocks installation, the
|
||||
// same as a definitive mismatch.
|
||||
func (cfg *Configuration) checkKubeFeatureGates(ctx context.Context, wanted map[string]map[string]bool) error {
|
||||
components := make([]string, 0, len(wanted))
|
||||
for component := range wanted {
|
||||
components = append(components, component)
|
||||
}
|
||||
slices.Sort(components)
|
||||
|
||||
var problems []string
|
||||
for _, component := range components {
|
||||
gates := wanted[component]
|
||||
if len(gates) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !supportedKubeFeatureGateComponents[component] {
|
||||
cfg.Logger().Warn(
|
||||
"chart requires kubeFeatureGates for a component Helm cannot yet verify; skipping",
|
||||
slog.String("component", component),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
actual, err := cfg.fetchComponentFeatureGates(ctx, component)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: could not verify feature gates: %s", component, err))
|
||||
continue
|
||||
}
|
||||
|
||||
gateNames := make([]string, 0, len(gates))
|
||||
for gate := range gates {
|
||||
gateNames = append(gateNames, gate)
|
||||
}
|
||||
slices.Sort(gateNames)
|
||||
|
||||
for _, gate := range gateNames {
|
||||
want := gates[gate]
|
||||
got, known := actual[gate]
|
||||
if !known {
|
||||
problems = append(problems, fmt.Sprintf("%s.%s: not reported by the cluster", component, gate))
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
problems = append(problems, fmt.Sprintf("%s.%s=%t (cluster reports %t)", component, gate, want, got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("chart requires kubeFeatureGates that could not be satisfied or verified: %s", strings.Join(problems, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchComponentFeatureGates retrieves and parses the feature gate state
|
||||
// reported by the given component's /metrics endpoint.
|
||||
func (cfg *Configuration) fetchComponentFeatureGates(ctx context.Context, component string) (map[string]bool, error) {
|
||||
if cfg.RESTClientGetter == nil {
|
||||
return nil, errors.New("no Kubernetes cluster is configured")
|
||||
}
|
||||
|
||||
clientset, err := cfg.KubernetesClientSet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw []byte
|
||||
switch component {
|
||||
case "apiserver":
|
||||
apiserverCtx, cancel := context.WithTimeout(ctx, featureGateFetchTimeout)
|
||||
defer cancel()
|
||||
raw, err = clientset.CoreV1().RESTClient().Get().RequestURI("/metrics").Do(apiserverCtx).Raw()
|
||||
case "kubelet":
|
||||
raw, err = fetchKubeletMetrics(ctx, clientset)
|
||||
default:
|
||||
return nil, fmt.Errorf("component %q is not supported", component)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parseFeatureGateMetrics(raw)
|
||||
}
|
||||
|
||||
// fetchKubeletMetrics polls the /metrics endpoint of a single representative
|
||||
// Ready node, via the API server's node proxy subresource. kubelet feature
|
||||
// gates are technically per-node; Helm treats one Ready node as
|
||||
// representative of the cluster rather than polling every node.
|
||||
func fetchKubeletMetrics(ctx context.Context, clientset kubernetes.Interface) ([]byte, error) {
|
||||
listCtx, cancelList := context.WithTimeout(ctx, featureGateFetchTimeout)
|
||||
defer cancelList()
|
||||
nodes, err := clientset.CoreV1().Nodes().List(listCtx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing nodes: %w", err)
|
||||
}
|
||||
|
||||
node, ok := firstReadyNode(nodes.Items)
|
||||
if !ok {
|
||||
return nil, errors.New("no Ready node found to check kubelet feature gates against")
|
||||
}
|
||||
|
||||
port := node.Status.DaemonEndpoints.KubeletEndpoint.Port
|
||||
if port == 0 {
|
||||
port = defaultKubeletPort
|
||||
}
|
||||
|
||||
metricsCtx, cancelMetrics := context.WithTimeout(ctx, featureGateFetchTimeout)
|
||||
defer cancelMetrics()
|
||||
return clientset.CoreV1().RESTClient().Get().
|
||||
Resource("nodes").
|
||||
SubResource("proxy").
|
||||
Name(fmt.Sprintf("%s:%d", node.Name, port)).
|
||||
Suffix("metrics").
|
||||
Do(metricsCtx).Raw()
|
||||
}
|
||||
|
||||
// firstReadyNode returns the first node in the list reporting a True Ready
|
||||
// condition.
|
||||
func firstReadyNode(nodes []corev1.Node) (corev1.Node, bool) {
|
||||
for _, node := range nodes {
|
||||
for _, cond := range node.Status.Conditions {
|
||||
if cond.Type == corev1.NodeReady && cond.Status == corev1.ConditionTrue {
|
||||
return node, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return corev1.Node{}, false
|
||||
}
|
||||
|
||||
// parseFeatureGateMetrics parses the raw Prometheus text exposition format
|
||||
// scraped from a component's /metrics endpoint and extracts the enablement
|
||||
// state of every feature gate it reports.
|
||||
func parseFeatureGateMetrics(raw []byte) (map[string]bool, error) {
|
||||
// kubernetes_feature_enabled and its labels always use the classic
|
||||
// Prometheus name character set; expfmt.TextParser requires an explicit
|
||||
// scheme (its zero value is invalid) since it added UTF-8 name support.
|
||||
parser := expfmt.NewTextParser(model.LegacyValidation)
|
||||
families, err := parser.TextToMetricFamilies(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing metrics: %w", err)
|
||||
}
|
||||
|
||||
family, ok := families[featureGateMetricName]
|
||||
if !ok {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
gates := make(map[string]bool, len(family.Metric))
|
||||
for _, m := range family.Metric {
|
||||
var name string
|
||||
for _, label := range m.Label {
|
||||
if label.GetName() == "name" {
|
||||
name = label.GetValue()
|
||||
break
|
||||
}
|
||||
}
|
||||
if name == "" || m.Gauge == nil {
|
||||
continue
|
||||
}
|
||||
gates[name] = m.Gauge.GetValue() != 0
|
||||
}
|
||||
return gates, nil
|
||||
}
|
||||
@ -0,0 +1,257 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package action
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/client-go/discovery"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
// fakeRESTClientGetter is a minimal RESTClientGetter that only supports
|
||||
// ToRESTConfig, enough to exercise code paths that build a Kubernetes
|
||||
// clientset directly against a local httptest server.
|
||||
type fakeRESTClientGetter struct {
|
||||
restConfig *rest.Config
|
||||
}
|
||||
|
||||
func (f *fakeRESTClientGetter) ToRESTConfig() (*rest.Config, error) {
|
||||
return f.restConfig, nil
|
||||
}
|
||||
|
||||
func (f *fakeRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (f *fakeRESTClientGetter) ToRESTMapper() (meta.RESTMapper, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
// poisonRESTClientGetter fails the test if any of its methods are called,
|
||||
// used to prove a code path never contacts the cluster.
|
||||
type poisonRESTClientGetter struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (p *poisonRESTClientGetter) ToRESTConfig() (*rest.Config, error) {
|
||||
p.t.Fatal("ToRESTConfig should not be called")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *poisonRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
|
||||
p.t.Fatal("ToDiscoveryClient should not be called")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *poisonRESTClientGetter) ToRESTMapper() (meta.RESTMapper, error) {
|
||||
p.t.Fatal("ToRESTMapper should not be called")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
const sampleFeatureGateMetrics = `# HELP kubernetes_feature_enabled [BETA] This metric records the data about the stage and enablement of a k8s feature.
|
||||
# TYPE kubernetes_feature_enabled gauge
|
||||
kubernetes_feature_enabled{name="ClusterTrustBundle",stage="BETA"} 0
|
||||
kubernetes_feature_enabled{name="SidecarContainers",stage=""} 1
|
||||
`
|
||||
|
||||
func TestParseFeatureGateMetrics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want map[string]bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "parses enabled and disabled gates",
|
||||
raw: sampleFeatureGateMetrics,
|
||||
want: map[string]bool{
|
||||
"ClusterTrustBundle": false,
|
||||
"SidecarContainers": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "metric family absent",
|
||||
raw: "# HELP unrelated_metric a metric we don't care about\n# TYPE unrelated_metric gauge\nunrelated_metric 1\n",
|
||||
want: map[string]bool{},
|
||||
},
|
||||
{
|
||||
name: "malformed input",
|
||||
raw: "kubernetes_feature_enabled{name=\"X\" 1\n",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseFeatureGateMetrics([]byte(tt.raw))
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newFeatureGateTestServer returns an httptest server whose /metrics handler
|
||||
// serves sampleFeatureGateMetrics, standing in for kube-apiserver.
|
||||
func newFeatureGateTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
_, _ = w.Write([]byte(sampleFeatureGateMetrics))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func TestCheckKubeFeatureGates(t *testing.T) {
|
||||
t.Run("no component declared is a no-op", func(t *testing.T) {
|
||||
cfg := actionConfigFixture(t)
|
||||
require.NoError(t, cfg.checkKubeFeatureGates(t.Context(), nil))
|
||||
})
|
||||
|
||||
t.Run("unsupported component only warns, never blocks", func(t *testing.T) {
|
||||
cfg := actionConfigFixture(t)
|
||||
// actionConfigFixture never sets RESTClientGetter; if this component were
|
||||
// (incorrectly) treated as supported, fetching would fail loudly instead
|
||||
// of being skipped outright.
|
||||
err := cfg.checkKubeFeatureGates(t.Context(), map[string]map[string]bool{
|
||||
"scheduler": {"ComponentFlagz": false},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("no cluster configured blocks with a clear error", func(t *testing.T) {
|
||||
cfg := actionConfigFixture(t)
|
||||
err := cfg.checkKubeFeatureGates(t.Context(), map[string]map[string]bool{
|
||||
"apiserver": {"SidecarContainers": true},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apiserver: could not verify feature gates")
|
||||
})
|
||||
|
||||
server := newFeatureGateTestServer(t)
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.RESTClientGetter = &fakeRESTClientGetter{restConfig: &rest.Config{Host: server.URL}}
|
||||
|
||||
t.Run("matching gate state passes", func(t *testing.T) {
|
||||
err := cfg.checkKubeFeatureGates(t.Context(), map[string]map[string]bool{
|
||||
"apiserver": {
|
||||
"ClusterTrustBundle": false,
|
||||
"SidecarContainers": true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("mismatching gate state blocks with a clear error", func(t *testing.T) {
|
||||
err := cfg.checkKubeFeatureGates(t.Context(), map[string]map[string]bool{
|
||||
"apiserver": {"ClusterTrustBundle": true},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apiserver.ClusterTrustBundle=true (cluster reports false)")
|
||||
})
|
||||
|
||||
t.Run("gate absent from scraped output blocks with a clear error", func(t *testing.T) {
|
||||
// Covers the issue's primary scenario: an older cluster (or one
|
||||
// predating the gate) that doesn't report it at all must not be
|
||||
// treated as satisfying the requirement.
|
||||
err := cfg.checkKubeFeatureGates(t.Context(), map[string]map[string]bool{
|
||||
"apiserver": {"SomeGateThisServerDoesNotReport": true},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apiserver.SomeGateThisServerDoesNotReport: not reported by the cluster")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckKubeFeatureGates_ClusterPredatesFeatureGateMetric(t *testing.T) {
|
||||
// Simulates a Kubernetes version older than 1.26, which doesn't expose
|
||||
// kubernetes_feature_enabled at all -- the issue's primary scenario of a
|
||||
// lower K8s version must not be treated as satisfying the requirement.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
_, _ = w.Write([]byte("# HELP unrelated_metric a metric with nothing to do with feature gates\n# TYPE unrelated_metric gauge\nunrelated_metric 1\n"))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
cfg := actionConfigFixture(t)
|
||||
cfg.RESTClientGetter = &fakeRESTClientGetter{restConfig: &rest.Config{Host: server.URL}}
|
||||
|
||||
err := cfg.checkKubeFeatureGates(t.Context(), map[string]map[string]bool{
|
||||
"apiserver": {"SidecarContainers": true},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apiserver.SidecarContainers: not reported by the cluster")
|
||||
}
|
||||
|
||||
func TestMergedKubeFeatureGates(t *testing.T) {
|
||||
t.Run("no dependencies returns the root chart's own requirements", func(t *testing.T) {
|
||||
ch := buildChart(withKubeFeatureGates(map[string]map[string]bool{
|
||||
"apiserver": {"SidecarContainers": true},
|
||||
}))
|
||||
merged, err := mergedKubeFeatureGates(ch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]map[string]bool{"apiserver": {"SidecarContainers": true}}, merged)
|
||||
})
|
||||
|
||||
t.Run("combines root and dependency requirements for different gates", func(t *testing.T) {
|
||||
ch := buildChart(
|
||||
withKubeFeatureGates(map[string]map[string]bool{"apiserver": {"SidecarContainers": true}}),
|
||||
withDependency(withName("sub"), withKubeFeatureGates(map[string]map[string]bool{"kubelet": {"NodeSwap": false}})),
|
||||
)
|
||||
merged, err := mergedKubeFeatureGates(ch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]map[string]bool{
|
||||
"apiserver": {"SidecarContainers": true},
|
||||
"kubelet": {"NodeSwap": false},
|
||||
}, merged)
|
||||
})
|
||||
|
||||
t.Run("agreeing requirements for the same gate from multiple levels are fine", func(t *testing.T) {
|
||||
ch := buildChart(
|
||||
withKubeFeatureGates(map[string]map[string]bool{"apiserver": {"SidecarContainers": true}}),
|
||||
withDependency(withName("sub"), withKubeFeatureGates(map[string]map[string]bool{"apiserver": {"SidecarContainers": true}})),
|
||||
)
|
||||
merged, err := mergedKubeFeatureGates(ch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]map[string]bool{"apiserver": {"SidecarContainers": true}}, merged)
|
||||
})
|
||||
|
||||
t.Run("conflicting requirements for the same gate error out", func(t *testing.T) {
|
||||
ch := buildChart(
|
||||
withName("root"),
|
||||
withKubeFeatureGates(map[string]map[string]bool{"apiserver": {"SidecarContainers": true}}),
|
||||
withDependency(withName("sub"), withKubeFeatureGates(map[string]map[string]bool{"apiserver": {"SidecarContainers": false}})),
|
||||
)
|
||||
_, err := mergedKubeFeatureGates(ch)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apiserver.SidecarContainers")
|
||||
})
|
||||
}
|
||||
Loading…
Reference in new issue