diff --git a/CHANGELOG.md b/CHANGELOG.md index 542be63ead..04e0563c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - helm.v3.ChartOpts: Add KubeVersion field that can be passed to avoid asking the kubernetes API server for the version (https://github.com/pulumi/pulumi-kubernetes/pull/2593) - Fix for Helm Import regression (https://github.com/pulumi/pulumi-kubernetes/pull/2605) - Improved search functionality for Helm Import (https://github.com/pulumi/pulumi-kubernetes/pull/2610) +- Fix SSA dry-run previews when a Pulumi program uses Apply on the status subresource (https://github.com/pulumi/pulumi-kubernetes/pull/2615) ## 4.4.0 (October 12, 2023) diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index 4d5dcd7f8e..0a86b375a0 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -1911,6 +1911,13 @@ func (k *kubeProvider) Create( initialized = partialErr.Object() } + // We need to delete the empty status field returned from the API server if we are in + // preview mode. Having the status field set will cause a panic during preview if the Pulumi + // program attempts to read the status field. + if req.GetPreview() { + unstructured.RemoveNestedField(initialized.Object, "status") + } + obj := checkpointObject(newInputs, initialized, newResInputs, initialAPIVersion, fieldManager) inputsAndComputed, err := plugin.MarshalProperties( obj, plugin.MarshalOptions{ diff --git a/tests/sdk/nodejs/preview-apply/Pulumi.yaml b/tests/sdk/nodejs/preview-apply/Pulumi.yaml new file mode 100644 index 0000000000..243a180f41 --- /dev/null +++ b/tests/sdk/nodejs/preview-apply/Pulumi.yaml @@ -0,0 +1,3 @@ +name: preview-apply-tests +description: Tests SSA previews with apply on status subresource +runtime: nodejs diff --git a/tests/sdk/nodejs/preview-apply/index.ts b/tests/sdk/nodejs/preview-apply/index.ts new file mode 100644 index 0000000000..f1ea004d04 --- /dev/null +++ b/tests/sdk/nodejs/preview-apply/index.ts @@ -0,0 +1,92 @@ +// Copyright 2016-2023, Pulumi Corporation. +// +// 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. + +import * as k8s from "@pulumi/kubernetes"; + +// Create provider with SSA enabled. +const provider = new k8s.Provider("k8s", { enableServerSideApply: false }); + +const ns = new k8s.core.v1.Namespace("test-preview-apply", undefined, { + provider, +}); + +const dep = new k8s.apps.v1.Deployment( + "nginx-dep", + { + metadata: { + namespace: ns.metadata.name, + labels: { + app: "nginx", + }, + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + app: "nginx", + }, + }, + template: { + metadata: { + labels: { + app: "nginx", + }, + }, + spec: { + containers: [ + { + name: "nginx", + image: "nginx:latest", + ports: [ + { + containerPort: 80, + }, + ], + }, + ], + }, + }, + }, + }, + { provider } +); + +const svc = new k8s.core.v1.Service( + "nginx-svc", + { + metadata: { + namespace: ns.metadata.name, + labels: { + app: "nginx", + }, + }, + spec: { + type: "LoadBalancer", + ports: [ + { + port: 80, + targetPort: 80, + }, + ], + selector: { + app: "nginx", + }, + }, + }, + { provider } +); + +export const ip = svc.status.apply((s) => s.loadBalancer.ingress[0].ip); +export const nsName = ns.metadata.name; +export const svcName = svc.metadata.name; diff --git a/tests/sdk/nodejs/preview-apply/package.json b/tests/sdk/nodejs/preview-apply/package.json new file mode 100644 index 0000000000..a334f94bae --- /dev/null +++ b/tests/sdk/nodejs/preview-apply/package.json @@ -0,0 +1,11 @@ +{ + "name": "preview-auth", + "version": "0.1.0", + "dependencies": { + "@pulumi/pulumi": "latest", + "@pulumi/random": "latest" + }, + "peerDependencies": { + "@pulumi/kubernetes": "latest" + } +} diff --git a/tests/sdk/nodejs/preview-apply/tsconfig.json b/tests/sdk/nodejs/preview-apply/tsconfig.json new file mode 100644 index 0000000000..5dacccbd42 --- /dev/null +++ b/tests/sdk/nodejs/preview-apply/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "outDir": "bin", + "target": "es6", + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "sourceMap": true, + "stripInternal": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, + "strictNullChecks": true + }, + "files": [ + "index.ts" + ] +} + diff --git a/tests/sdk/nodejs/preview_test.go b/tests/sdk/nodejs/preview_test.go index 4c491f1bf6..453032932f 100644 --- a/tests/sdk/nodejs/preview_test.go +++ b/tests/sdk/nodejs/preview_test.go @@ -25,6 +25,7 @@ import ( "github.com/pulumi/pulumi-kubernetes/tests/v4" "github.com/pulumi/pulumi/pkg/v3/testing/integration" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) @@ -143,3 +144,63 @@ func createSAKubeconfig(t *testing.T, saName string) (string, error) { return kubeconfigPath, err } + +// TestPreviewWithApply tests the `pulumi preview` CUJ where the user Pulumi program contains an Apply call on status subresoruces. +// This is to ensure we don't fail preview, since status fields are only populated after the resource is created on cluster. +func TestPreviewWithApply(t *testing.T) { + var externalIP, nsName, svcName string + test := baseOptions.With(integration.ProgramTestOptions{ + Dir: "preview-apply", + ExpectRefreshChanges: false, + // Enable destroy-on-cleanup so we can shell out to kubectl to make external changes to the resource and reuse the same stack. + DestroyOnCleanup: true, + Quick: true, + ExtraRuntimeValidation: func(t *testing.T, stackInfo integration.RuntimeValidationStackInfo) { + var ok bool + externalIP, ok = stackInfo.Outputs["ip"].(string) + require.True(t, ok) + nsName, ok = stackInfo.Outputs["nsName"].(string) + require.True(t, ok) + svcName, ok = stackInfo.Outputs["svcName"].(string) + require.True(t, ok) + }, + OrderedConfig: []integration.ConfigValue{ + { + Key: "pulumi:disable-default-providers[0]", + Value: "kubernetes", + Path: true, + }, + }, + }) + + // Initialize and the test project. + pt := integration.ProgramTestManualLifeCycle(t, &test) + err := pt.TestLifeCyclePrepare() + if err != nil { + t.Fatalf("unable to create temp dir: %s", err) + } + t.Cleanup(pt.TestCleanUp) + + err = pt.TestLifeCycleInitialize() + if err != nil { + t.Fatalf("unable to init test project: %s", err) + } + t.Cleanup(func() { + destroyErr := pt.TestLifeCycleDestroy() + assert.NoError(t, destroyErr) + }) + + // Run a preview and assert no error. + err = pt.RunPulumiCommand("preview", "--non-interactive", "--diff", "--refresh", "--show-config") + assert.NoError(t, err) + assert.Equal(t, "", externalIP) + + // Run pulumi up and assert no error creating the resources. + err = pt.TestPreviewUpdateAndEdits() + require.NoError(t, err) + + // Ensure that the ip output is the same as the external ip of the service via kubectl. + out, err := tests.Kubectl("get service", svcName, "-n", nsName, "-o jsonpath={.status.loadBalancer.ingress[0].ip}") + require.NoError(t, err) + assert.Equal(t, externalIP, string(out)) +}