Skip to content

Commit

Permalink
Fix SSA dry-run when an Apply is called on outputs (#2615)
Browse files Browse the repository at this point in the history
### Proposed changes

This PR unsets the empty initialized status subresource from a SSA
dry-run to prevent panics during `pulumi preview` when the Pulumi
program contains an Apply on a status field. By removing the field
completely, the Pulumi engine will be able to defer returning until
after the resource is created, which is when the status subresource is
populated.

### Related issues (optional)

Fixes: #2557 
Fixes: #2315
  • Loading branch information
rquitales authored Oct 20, 2023
1 parent 8494869 commit e850a81
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions provider/pkg/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
3 changes: 3 additions & 0 deletions tests/sdk/nodejs/preview-apply/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: preview-apply-tests
description: Tests SSA previews with apply on status subresource
runtime: nodejs
92 changes: 92 additions & 0 deletions tests/sdk/nodejs/preview-apply/index.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions tests/sdk/nodejs/preview-apply/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "preview-auth",
"version": "0.1.0",
"dependencies": {
"@pulumi/pulumi": "latest",
"@pulumi/random": "latest"
},
"peerDependencies": {
"@pulumi/kubernetes": "latest"
}
}
22 changes: 22 additions & 0 deletions tests/sdk/nodejs/preview-apply/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
]
}

61 changes: 61 additions & 0 deletions tests/sdk/nodejs/preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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))
}

0 comments on commit e850a81

Please sign in to comment.