Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gracefully handle ignoreChanges without user needing to refresh a stack #2566

Merged
merged 10 commits into from
Sep 22, 2023
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Unreleased

- Handle fields specified in ignoreChanges gracefully without needing a refresh when drift has occurred (https://github.com/pulumi/pulumi-kubernetes/pull/2566)

## 4.2.0 (September 14, 2023)
- Reintroduce switching builds to pyproject.toml; when publishing the package to PyPI both
source-based and wheel distributions are now published. For most users the installs will now favor
Expand Down
2 changes: 1 addition & 1 deletion provider/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ require (
k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 // indirect
oras.land/oras-go v1.2.2 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3
sourcegraph.com/sourcegraph/appdash v0.0.0-20211028080628-e2786a622600 // indirect
)

Expand Down
192 changes: 143 additions & 49 deletions provider/pkg/await/await.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"

fluxssa "github.com/fluxcd/pkg/ssa"
"github.com/pulumi/pulumi-kubernetes/provider/v4/pkg/clients"
Expand All @@ -43,6 +44,7 @@ import (
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
k8sopenapi "k8s.io/kubectl/pkg/util/openapi"
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
"sigs.k8s.io/yaml"
)

Expand Down Expand Up @@ -92,6 +94,8 @@ type UpdateConfig struct {
Inputs *unstructured.Unstructured
Timeout float64
Preview bool
// IgnoreChanges is a list of fields to ignore when diffing the old and new objects.
IgnoreChanges []string
}

type DeleteConfig struct {
Expand Down Expand Up @@ -459,11 +463,14 @@ func csaUpdate(c *UpdateConfig, liveOldObj *unstructured.Unstructured, client dy

// ssaUpdate handles the logic for updating a resource using server-side apply.
func ssaUpdate(c *UpdateConfig, liveOldObj *unstructured.Unstructured, client dynamic.ResourceInterface) (*unstructured.Unstructured, error) {
if !c.Preview {
err := fixCSAFieldManagers(c, liveOldObj, client)
if err != nil {
return nil, err
}
liveOldObj, err := fixCSAFieldManagers(c, liveOldObj, client)
if err != nil {
return nil, err
}

err = handleSSAIgnoreFields(c, liveOldObj)
if err != nil {
return nil, err
}

objYAML, err := yaml.Marshal(c.Inputs.Object)
Expand Down Expand Up @@ -491,60 +498,147 @@ func ssaUpdate(c *UpdateConfig, liveOldObj *unstructured.Unstructured, client dy
return currentOutputs, nil
}

// fixCSAFieldManagers patches the field managers for an existing resource that was managed using client-side apply.
// The new server-side apply field manager takes ownership of all these fields to avoid conflicts.
func fixCSAFieldManagers(c *UpdateConfig, live *unstructured.Unstructured, client dynamic.ResourceInterface) error {
if managedFields := live.GetManagedFields(); len(managedFields) > 0 {
patches, err := fluxssa.PatchReplaceFieldsManagers(live, []fluxssa.FieldManager{
{
// take ownership of changes made with 'kubectl apply --server-side --force-conflicts'
Name: "kubectl",
OperationType: metav1.ManagedFieldsOperationApply,
},
{
// take ownership of changes made with 'kubectl apply'
Name: "kubectl",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
{
// take ownership of changes made with 'kubectl apply'
Name: "before-first-apply",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
// The following are possible field manager values for resources that were created using this provider under
// CSA mode. Note the "Update" operation type, which Kubernetes treats as a separate field manager even if
// the name is identical. See https://github.com/kubernetes/kubernetes/issues/99003
{
// take ownership of changes made with pulumi-kubernetes CSA
Name: "pulumi-kubernetes",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
{
// take ownership of changes made with pulumi-kubernetes CSA
Name: "pulumi-kubernetes.exe",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
{
// take ownership of changes made with pulumi-kubernetes CSA
Name: "pulumi-resource-kubernetes",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
}, c.FieldManager)
// handleSSAIgnoreFields handles updating the inputs to either drop fields that are present on the cluster and not managed
// by the current field manager, or to set the value of the field to the last known value applied to the cluster.
func handleSSAIgnoreFields(c *UpdateConfig, liveOldObj *unstructured.Unstructured) error {
managedFields := liveOldObj.GetManagedFields()
// Keep track of fields that are managed by the current field manager, and fields that are managed by other field managers.
managedFieldsSet := fieldpath.NewSet()
currManagerFieldsSet := fieldpath.NewSet()
rquitales marked this conversation as resolved.
Show resolved Hide resolved

for _, f := range managedFields {
s, err := fluxssa.FieldsToSet(*f.FieldsV1)
if err != nil {
return err
return fmt.Errorf("unable to parse managed fields from resource %q into fieldpath.Set: %w", c.Inputs.GetName(), err)
}
patch, err := json.Marshal(patches)

switch f.Manager {
case c.FieldManager:
currManagerFieldsSet = currManagerFieldsSet.Union(&s)
default:
managedFieldsSet = managedFieldsSet.Union(&s)
}
}

for _, ignorePath := range c.IgnoreChanges {
ipParsed, err := resource.ParsePropertyPath(ignorePath)
if err != nil {
return err
// NB: This shouldn't really happen since we already validated the ignoreChanges paths in the parent Diff function.
return fmt.Errorf("unable to parse ignoreField path %q: %w", ignorePath, err)
}
_, err = client.Patch(c.Context, live.GetName(), types.JSONPatchType, patch, metav1.PatchOptions{})

pathComponents := strings.Split(ipParsed.String(), ".")
pe, err := fieldpath.MakePath(makeInterfaceSlice(pathComponents)...)
if err != nil {
return err
return fmt.Errorf("unable to normalize ignoreField path %q: %w", ignorePath, err)
}

// Drop the field from the inputs if it is present on the cluster and managed by another manager, and is not shared with current manager. This ensures
// that we don't get any conflict errors, or mistakenly setting the current field manager as a shared manager of that field.
rquitales marked this conversation as resolved.
Show resolved Hide resolved
if managedFieldsSet.Has(pe) && !currManagerFieldsSet.Has(pe) {
unstructured.RemoveNestedField(c.Inputs.Object, pathComponents...)
continue
}

// We didn't find another field manager that is managing this field, so we need to use the last known value applied to
// the cluster so we don't unset it or change it to a different value that is not the last known value. This case handles 2 posibilities:
//
// 1. The field is managed by the current field manager, or is a shared manager, in this case the field needs to be in the request sent to
// the server, otherwise it will be unset.
// 2. The field is set/exists on the cluster, but for some reason is not listed in the managed fields, in this case we need to set the field to the last
// known value applied to the cluster, otherwise it will be unset. This would cause the current field manager to take ownership of the field, but this edge
// case probably shouldn't be hit in practice.
//
// NOTE: If the field has been reverted to its default value, ignoreChanges will still not update this field to what is supplied
// by the user in their Pulumi program.
lastVal, found, err := unstructured.NestedFieldCopy(liveOldObj.Object, pathComponents...)
rquitales marked this conversation as resolved.
Show resolved Hide resolved
if found && err == nil {
// We only care if the field is found, as not found indicates that the field does not exist in the live state so we don't have to worry about changing the inputs to match
// the live state. If this occurs, then Pulumi will set the field back to the declared value. Or should we also ensure that the field is never touch again by Pulumi?
rquitales marked this conversation as resolved.
Show resolved Hide resolved
err := unstructured.SetNestedField(c.Inputs.Object, lastVal, pathComponents...)
if err != nil {
return fmt.Errorf("unable to set field %q with last used value %q: %w", ignorePath, lastVal, err)
}
}
if err != nil {
// A type error occurred when attempting to get the nested field from the live object.
return fmt.Errorf("unable to get field %q from live object: %w", ignorePath, err)
}
}

return nil
}

// makeInterfaceSlice converts a slice of any type to a slice of explicit interface{}. This
// enables slice unpacking to variadic functions that take interface{}.
func makeInterfaceSlice[T any](inputs []T) []interface{} {
s := make([]interface{}, len(inputs))
for i, v := range inputs {
s[i] = v
}
return s
}

// fixCSAFieldManagers patches the field managers for an existing resource that was managed using client-side apply.
// The new server-side apply field manager takes ownership of all these fields to avoid conflicts.
func fixCSAFieldManagers(c *UpdateConfig, liveOldObj *unstructured.Unstructured, client dynamic.ResourceInterface) (*unstructured.Unstructured, error) {
managedFields := liveOldObj.GetManagedFields()
if c.Preview || len(managedFields) == 0 {
return liveOldObj, nil
}

patches, err := fluxssa.PatchReplaceFieldsManagers(liveOldObj, []fluxssa.FieldManager{
{
// take ownership of changes made with 'kubectl apply --server-side --force-conflicts'
Name: "kubectl",
OperationType: metav1.ManagedFieldsOperationApply,
},
{
// take ownership of changes made with 'kubectl apply'
Name: "kubectl",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
{
// take ownership of changes made with 'kubectl apply'
Name: "before-first-apply",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
// The following are possible field manager values for resources that were created using this provider under
// CSA mode. Note the "Update" operation type, which Kubernetes treats as a separate field manager even if
// the name is identical. See https://github.com/kubernetes/kubernetes/issues/99003
{
// take ownership of changes made with pulumi-kubernetes CSA
Name: "pulumi-kubernetes",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
{
// take ownership of changes made with pulumi-kubernetes CSA
Name: "pulumi-kubernetes.exe",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
{
// take ownership of changes made with pulumi-kubernetes CSA
Name: "pulumi-resource-kubernetes",
OperationType: metav1.ManagedFieldsOperationUpdate,
},
}, c.FieldManager)
if err != nil {
return nil, err
}

patch, err := json.Marshal(patches)
if err != nil {
return nil, err
}

live, err := client.Patch(c.Context, liveOldObj.GetName(), types.JSONPatchType, patch, metav1.PatchOptions{})
if err != nil {
return nil, err
}

return live, nil
}

// Deletion (as the usage, `await.Deletion`, implies) will block until one of the following is true:
// (1) the Kubernetes resource is reported to be deleted; (2) the initialization timeout has
// occurred; or (3) an error has occurred while the resource was being deleted.
Expand Down
9 changes: 5 additions & 4 deletions provider/pkg/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2319,10 +2319,11 @@ func (k *kubeProvider) Update(
Resources: resources,
ServerSideApply: k.serverSideApplyMode,
},
Previous: oldLivePruned,
Inputs: newInputs,
Timeout: req.Timeout,
Preview: req.GetPreview(),
Previous: oldLivePruned,
Inputs: newInputs,
Timeout: req.Timeout,
Preview: req.GetPreview(),
IgnoreChanges: req.IgnoreChanges,
}
// Apply update.
initialized, awaitErr := await.Update(config)
Expand Down
3 changes: 3 additions & 0 deletions tests/sdk/nodejs/ignore-changes/deployment-patch-2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# file used for basis of kubectl patch ...
spec:
replicas: 4
3 changes: 3 additions & 0 deletions tests/sdk/nodejs/ignore-changes/deployment-patch.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# file used for basis of kubectl patch ...
spec:
replicas: 3
3 changes: 3 additions & 0 deletions tests/sdk/nodejs/ignore-changes/step1/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: skip-update-unreachable-tests
description: Tests skipUpdateUnreachable flag
runtime: nodejs
50 changes: 50 additions & 0 deletions tests/sdk/nodejs/ignore-changes/step1/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// 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: true});

const ns = new k8s.core.v1.Namespace("test-ignore-changes", undefined, { provider });

const deployment = new k8s.apps.v1.Deployment(
"test-ignore-changes",
{
metadata: {
namespace: ns.metadata.name,
},
spec: {
selector: { matchLabels: { app: "test-ignore-changes" } },
replicas: 2,
template: {
metadata: {
labels: { app: "test-ignore-changes" },
},
spec: {
containers: [
{
name: "nginx",
image: "nginx:1.25.2",
},
],
},
},
},
},
{ provider: provider, ignoreChanges: ["spec.replicas"] }
);

export const deploymentName = deployment.metadata.name;
export const deploymentNamespace = deployment.metadata.namespace;
11 changes: 11 additions & 0 deletions tests/sdk/nodejs/ignore-changes/step1/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "skip-update-unreachable",
"version": "0.1.0",
"dependencies": {
"@pulumi/pulumi": "latest",
"@pulumi/random": "latest"
},
"peerDependencies": {
"@pulumi/kubernetes": "latest"
}
}
22 changes: 22 additions & 0 deletions tests/sdk/nodejs/ignore-changes/step1/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"
]
}

Loading
Loading