diff --git a/pkg/action/install.go b/pkg/action/install.go index 6fc919366..436a972c3 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -87,6 +87,9 @@ type Install struct { // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ ServerSideApply bool CreateNamespace bool + // NamespaceLabels are extra labels applied to the namespace when it is created via CreateNamespace. + // When nil the default label {"name": } is used. When set, it replaces the default entirely. + NamespaceLabels map[string]string // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster DryRunStrategy DryRunStrategy // HideSecret can be set to true when DryRun is enabled in order to hide @@ -426,16 +429,18 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st } if i.CreateNamespace { + nsLabels := i.NamespaceLabels + if nsLabels == nil { + nsLabels = map[string]string{"name": i.Namespace} + } ns := &v1.Namespace{ TypeMeta: metav1.TypeMeta{ APIVersion: "v1", Kind: "Namespace", }, ObjectMeta: metav1.ObjectMeta{ - Name: i.Namespace, - Labels: map[string]string{ - "name": i.Namespace, - }, + Name: i.Namespace, + Labels: nsLabels, }, } buf, err := yaml.Marshal(ns) diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 2d83abe27..4c021f15c 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -57,6 +57,17 @@ import ( "helm.sh/helm/v4/pkg/storage/driver" ) +// findNamespaceBuildInput returns the first Build input that contains a Namespace kind, +// or empty string if none is found. +func findNamespaceBuildInput(inputs [][]byte) string { + for _, b := range inputs { + if strings.Contains(string(b), "kind: Namespace") { + return string(b) + } + } + return "" +} + type nameTemplateTestCase struct { tpl string expected string @@ -218,6 +229,80 @@ func TestInstallRelease(t *testing.T) { is.Equal(rcommon.StatusDeployed, lrel.Info.Status) } +func TestInstallRelease_CreateNamespace_DefaultLabels(t *testing.T) { + req := require.New(t) + is := assert.New(t) + + config := actionConfigFixture(t) + instAction := NewInstall(config) + instAction.Namespace = "mynamespace" + instAction.ReleaseName = "test-ns-labels" + instAction.CreateNamespace = true + + vals := map[string]any{} + ctx, done := context.WithCancel(t.Context()) + _, err := instAction.RunWithContext(ctx, buildChart(), vals) + done() + req.NoError(err) + + fakeClient := config.KubeClient.(*kubefake.FailingKubeClient) + nsYAML := findNamespaceBuildInput(fakeClient.RecordedBuildInputs) + req.NotEmpty(nsYAML, "expected Build to be called for namespace creation") + is.Contains(nsYAML, "name: mynamespace", "expected default label name=") + is.NotContains(nsYAML, "custom", "expected no custom labels") +} + +func TestInstallRelease_CreateNamespace_CustomLabels(t *testing.T) { + req := require.New(t) + is := assert.New(t) + + config := actionConfigFixture(t) + instAction := NewInstall(config) + instAction.Namespace = "mynamespace" + instAction.ReleaseName = "test-ns-custom-labels" + instAction.CreateNamespace = true + instAction.NamespaceLabels = map[string]string{ + "env": "production", + "team": "platform", + } + + vals := map[string]any{} + ctx, done := context.WithCancel(t.Context()) + _, err := instAction.RunWithContext(ctx, buildChart(), vals) + done() + req.NoError(err) + + fakeClient := config.KubeClient.(*kubefake.FailingKubeClient) + nsYAML := findNamespaceBuildInput(fakeClient.RecordedBuildInputs) + req.NotEmpty(nsYAML, "expected Build to be called for namespace creation") + is.Contains(nsYAML, "env: production") + is.Contains(nsYAML, "team: platform") + // The default label key "name" must not appear indented under labels (4 spaces). + is.NotContains(nsYAML, " name: mynamespace", "custom labels should replace the default, not add to it") +} + +func TestInstallRelease_CreateNamespace_LabelsIgnoredWithoutCreateNamespace(t *testing.T) { + req := require.New(t) + + config := actionConfigFixture(t) + instAction := NewInstall(config) + instAction.Namespace = "mynamespace" + instAction.ReleaseName = "test-ns-labels-ignored" + instAction.CreateNamespace = false + instAction.NamespaceLabels = map[string]string{"env": "production"} + + vals := map[string]any{} + ctx, done := context.WithCancel(t.Context()) + _, err := instAction.RunWithContext(ctx, buildChart(), vals) + done() + req.NoError(err) + + fakeClient := config.KubeClient.(*kubefake.FailingKubeClient) + for _, input := range fakeClient.RecordedBuildInputs { + req.NotContains(string(input), "kind: Namespace", "Build should not be called for namespace when CreateNamespace is false") + } +} + func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) { // This test will test checking ownership of a resource // returned by the fake client. If the resource is not diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 182b4a7ff..bed66beb6 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -186,6 +186,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Install, valueOpts *values.Options) { f.BoolVar(&client.CreateNamespace, "create-namespace", false, "create the release namespace if not present") + f.StringToStringVar(&client.NamespaceLabels, "namespace-labels", nil, "labels to apply to the namespace when --create-namespace is set. Comma-separated key=value pairs. When not set the default label name= is applied.") f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 688842141..9e89720c8 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -88,6 +88,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { valueOpts := &values.Options{} var outfmt output.Format var createNamespace bool + var namespaceLabels map[string]string cmd := &cobra.Command{ Use: "upgrade [RELEASE] [CHART]", @@ -133,6 +134,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } instClient := action.NewInstall(cfg) instClient.CreateNamespace = createNamespace + instClient.NamespaceLabels = namespaceLabels instClient.ChartPathOptions = client.ChartPathOptions instClient.ForceReplace = client.ForceReplace instClient.DryRunStrategy = client.DryRunStrategy @@ -278,6 +280,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() f.BoolVar(&createNamespace, "create-namespace", false, "if --install is set, create the release namespace if not present") + f.StringToStringVar(&namespaceLabels, "namespace-labels", nil, "labels to apply to the namespace when --create-namespace is set. Comma-separated key=value pairs. When not set the default label name= is applied.") f.BoolVarP(&client.Install, "install", "i", false, "if a release by this name doesn't already exist, run an install") f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") diff --git a/pkg/kube/fake/failing_kube_client.go b/pkg/kube/fake/failing_kube_client.go index 75d0c8de1..15656bc33 100644 --- a/pkg/kube/fake/failing_kube_client.go +++ b/pkg/kube/fake/failing_kube_client.go @@ -18,6 +18,7 @@ limitations under the License. package fake import ( + "bytes" "io" "sync" "time" @@ -50,6 +51,8 @@ type FailingKubeClient struct { WaitDuration time.Duration // RecordedWaitOptions stores the WaitOptions passed to GetWaiter for testing RecordedWaitOptions []kube.WaitOption + // RecordedBuildInputs stores a copy of every reader passed to Build, in order. + RecordedBuildInputs [][]byte mu sync.Mutex } @@ -132,7 +135,11 @@ func (f *FailingKubeClient) Update(r, modified kube.ResourceList, options ...kub } // Build returns the configured error if set or prints -func (f *FailingKubeClient) Build(r io.Reader, _ bool) (kube.ResourceList, error) { +func (f *FailingKubeClient) Build(r io.Reader, validate bool) (kube.ResourceList, error) { + data, _ := io.ReadAll(r) + f.mu.Lock() + f.RecordedBuildInputs = append(f.RecordedBuildInputs, data) + f.mu.Unlock() if f.BuildError != nil { return []*resource.Info{}, f.BuildError } @@ -142,7 +149,7 @@ func (f *FailingKubeClient) Build(r io.Reader, _ bool) (kube.ResourceList, error if f.BuildDummy { return createDummyResourceList(), nil } - return f.PrintingKubeClient.Build(r, false) + return f.PrintingKubeClient.Build(bytes.NewReader(data), validate) } // BuildTable returns the configured error if set or prints