diff --git a/pkg/action/install.go b/pkg/action/install.go index fcfae7214..1e9e538b0 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -447,11 +447,18 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return nil, err } - if _, err := i.cfg.KubeClient.Create( - resourceList, - kube.ClientCreateOptionServerSideApply(i.ServerSideApply, false)); err != nil && !apierrors.IsAlreadyExists(err) { + exists, err := namespaceExists(resourceList) + if err != nil { return nil, err } + + if !exists { + if _, err := i.cfg.KubeClient.Create( + resourceList, + kube.ClientCreateOptionServerSideApply(i.ServerSideApply, false)); err != nil && !apierrors.IsAlreadyExists(err) { + return nil, err + } + } } // If Replace is true, we need to supersede the last release. @@ -476,6 +483,29 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return rel, err } +func namespaceExists(resourceList kube.ResourceList) (bool, error) { + exists := false + + err := resourceList.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + _, err = resource.NewHelper(info.Client, info.Mapping).Get(info.Namespace, info.Name) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return err + } + + exists = true + return nil + }) + + return exists, err +} + func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, toBeAdopted kube.ResourceList, resources kube.ResourceList) (*release.Release, error) { type Msg struct { r *release.Release diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 705a8e11d..10622987c 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -35,6 +35,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -1294,3 +1295,155 @@ func TestInstallRelease_WaitOptionsPassedDownstream(t *testing.T) { // Verify that WaitOptions were passed to GetWaiter is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") } + +func fakeNamespaceResourceList(name string, statusCode int) kube.ResourceList { + body := kuberuntime.Object(&v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "custom-label": "keep-me", + }, + Annotations: map[string]string{ + "custom-annotation": "keep-me", + }, + }, + }) + + if statusCode == http.StatusNotFound { + body = &metav1.Status{ + Status: metav1.StatusFailure, + Reason: metav1.StatusReasonNotFound, + Code: http.StatusNotFound, + } + } + + restClient := &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) { + return newNamespaceResponse(statusCode, body), nil + }), + } + + return kube.ResourceList{ + &resource.Info{ + Client: restClient, + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{ + Version: "v1", + Resource: "namespaces", + }, + GroupVersionKind: schema.GroupVersionKind{ + Version: "v1", + Kind: "Namespace", + }, + Scope: meta.RESTScopeRoot, + }, + Name: name, + Object: &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + }, + }, + } +} + +func newNamespaceResponse(code int, obj kuberuntime.Object) *http.Response { + body := kuberuntime.EncodeOrDie( + scheme.Codecs.LegacyCodec(schema.GroupVersion{Version: "v1"}), + obj, + ) + + return &http.Response{ + StatusCode: code, + Header: map[string][]string{ + "Content-Type": {"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +type namespaceInstallKubeClient struct { + *kubefake.FailingKubeClient + + namespaceStatusCode int + namespaceCreateCalled bool +} + +func (c *namespaceInstallKubeClient) Build(reader io.Reader, _ bool) (kube.ResourceList, error) { + b, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + if strings.Contains(string(b), "kind: Namespace") { + return fakeNamespaceResourceList("spaced", c.namespaceStatusCode), nil + } + + return kube.ResourceList{}, nil +} + +func (c *namespaceInstallKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) { + for _, info := range resources { + if info.Name == "spaced" && info.Mapping != nil && info.Mapping.GroupVersionKind.Kind == "Namespace" { + c.namespaceCreateCalled = true + } + } + + return &kube.Result{}, nil +} + +func TestInstall_CreateNamespaceAlreadyExistsSkipsCreate(t *testing.T) { + is := assert.New(t) + + config := actionConfigFixture(t) + client := &namespaceInstallKubeClient{ + FailingKubeClient: &kubefake.FailingKubeClient{ + PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, + }, + namespaceStatusCode: http.StatusOK, + } + config.KubeClient = client + + instAction := installActionWithConfig(config) + instAction.CreateNamespace = true + instAction.Namespace = "spaced" + instAction.ServerSideApply = true + + resi, err := instAction.Run(buildChart(), nil) + is.NoError(err) + + res, err := releaserToV1Release(resi) + is.NoError(err) + is.Equal("spaced", res.Namespace) + is.Equal("Install complete", res.Info.Description) + + is.False(client.namespaceCreateCalled, "existing namespace should not be created/applied again") +} + +func TestInstall_CreateNamespaceNotExistsCreatesNamespace(t *testing.T) { + is := assert.New(t) + + config := actionConfigFixture(t) + client := &namespaceInstallKubeClient{ + FailingKubeClient: &kubefake.FailingKubeClient{ + PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, + }, + namespaceStatusCode: http.StatusNotFound, + } + config.KubeClient = client + + instAction := installActionWithConfig(config) + instAction.CreateNamespace = true + instAction.Namespace = "spaced" + instAction.ServerSideApply = true + + resi, err := instAction.Run(buildChart(), nil) + is.NoError(err) + + res, err := releaserToV1Release(resi) + is.NoError(err) + is.Equal("spaced", res.Namespace) + is.Equal("Install complete", res.Info.Description) + + is.True(client.namespaceCreateCalled, "missing namespace should be created") +}