-
Notifications
You must be signed in to change notification settings - Fork 113
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
Changes from 2 commits
bc4a758
cc5040f
5e58aaf
aed8398
36a23d7
3690c69
0b77bd9
fb4b755
3724b04
3a915cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -445,6 +445,10 @@ func updateResource(c *UpdateConfig, liveOldObj *unstructured.Unstructured, clie | |
|
||
// csaUpdate handles the logic for updating a resource using client-side apply. | ||
func csaUpdate(c *UpdateConfig, liveOldObj *unstructured.Unstructured, client dynamic.ResourceInterface) (*unstructured.Unstructured, error) { | ||
// Handle ignoreChanges for CSA to use the last known value applied to the cluster, rather than what's in state which may be outdated. | ||
// We ignore errors here as it occurs when there is an issue traversing the field path. If this occurs, then use the last value in state | ||
// optimistically rather than failing the update. | ||
_ = handleCSAIgnoreFields(c, liveOldObj) | ||
// Create merge patch (prefer strategic merge patch, fall back to JSON merge patch). | ||
patch, patchType, _, err := openapi.PatchForResourceUpdate(c.Resources, c.Previous, c.Inputs, liveOldObj) | ||
if err != nil { | ||
|
@@ -498,13 +502,43 @@ func ssaUpdate(c *UpdateConfig, liveOldObj *unstructured.Unstructured, client dy | |
return currentOutputs, nil | ||
} | ||
|
||
// handleCSAIgnoreFields handles updating the inputs to use the last known value applied to the cluster. If the value is not present, | ||
// then we use what is declared in state as per the specs of Pulumi's ignoreChanges. | ||
func handleCSAIgnoreFields(c *UpdateConfig, liveOldObj *unstructured.Unstructured) error { | ||
for _, ignorePath := range c.IgnoreChanges { | ||
ipParsed, err := resource.ParsePropertyPath(ignorePath) | ||
if err != nil { | ||
// 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) | ||
} | ||
|
||
pathComponents := strings.Split(ipParsed.String(), ".") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we not worried here, this conversion would just work?
Does strings.Split(ipParsed.String(), ".") give you what you expect for the numbers? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will this ever receive '*' as a path fragment? Pulumi docs permit patterns like this one:
But we're lacking great SDK API to make it easy to implement. I wonder if the user specifies that will it hit this code or be resolved higher up the stack. |
||
|
||
lastLiveVal, found, err := unstructured.NestedFieldCopy(liveOldObj.Object, pathComponents...) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do paths expected by unstructured.NestedFieldCopy map 1:1 to pulumi property paths? No "foo_bar=>fooBar" renaming? No off-by-1 errors? (those may be specific to TF where pulumi paths are shorter than TF paths sometimes). |
||
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. | ||
err := unstructured.SetNestedField(c.Inputs.Object, lastLiveVal, pathComponents...) | ||
if err != nil { | ||
return fmt.Errorf("unable to set field %q with last used value %q: %w", ignorePath, lastLiveVal, err) | ||
} | ||
} | ||
if err != nil { | ||
// A type error occurred when attempting to get the nested field from the live object. | ||
return fmt.Errorf("unable to parse field to ignore %q from live object: %w", ignorePath, err) | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// 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() | ||
theirFields, ourFields := new(fieldpath.Set), new(fieldpath.Set) | ||
fieldpath.MakePathOrDie() | ||
|
||
for _, f := range managedFields { | ||
s, err := fluxssa.FieldsToSet(*f.FieldsV1) | ||
|
@@ -514,9 +548,9 @@ func handleSSAIgnoreFields(c *UpdateConfig, liveOldObj *unstructured.Unstructure | |
|
||
switch f.Manager { | ||
case c.FieldManager: | ||
currManagerFieldsSet = currManagerFieldsSet.Union(&s) | ||
ourFields = ourFields.Union(&s) | ||
default: | ||
managedFieldsSet = managedFieldsSet.Union(&s) | ||
theirFields = theirFields.Union(&s) | ||
} | ||
} | ||
|
||
|
@@ -527,6 +561,7 @@ func handleSSAIgnoreFields(c *UpdateConfig, liveOldObj *unstructured.Unstructure | |
return fmt.Errorf("unable to parse ignoreField path %q: %w", ignorePath, err) | ||
} | ||
|
||
// TODO: Enhance support for ignoreField path to support nested arrays. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah here we go again. Might be worth lifting this comment and the split logic into one helper function so it's in one place. |
||
pathComponents := strings.Split(ipParsed.String(), ".") | ||
pe, err := fieldpath.MakePath(makeInterfaceSlice(pathComponents)...) | ||
if err != nil { | ||
|
@@ -535,7 +570,7 @@ func handleSSAIgnoreFields(c *UpdateConfig, liveOldObj *unstructured.Unstructure | |
|
||
// 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) { | ||
if theirFields.Has(pe) && !ourFields.Has(pe) { | ||
unstructured.RemoveNestedField(c.Inputs.Object, pathComponents...) | ||
continue | ||
} | ||
|
@@ -551,18 +586,19 @@ func handleSSAIgnoreFields(c *UpdateConfig, liveOldObj *unstructured.Unstructure | |
// | ||
// 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...) | ||
lastLiveVal, found, err := unstructured.NestedFieldCopy(liveOldObj.Object, pathComponents...) | ||
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? | ||
err := unstructured.SetNestedField(c.Inputs.Object, lastVal, pathComponents...) | ||
// the live state. If this occurs, then Pulumi will set the field back to the declared value as ignoreChanges will use the declared value if one is not found in state as per | ||
// the intent of ignoreChanges. | ||
err := unstructured.SetNestedField(c.Inputs.Object, lastLiveVal, pathComponents...) | ||
if err != nil { | ||
return fmt.Errorf("unable to set field %q with last used value %q: %w", ignorePath, lastVal, err) | ||
return fmt.Errorf("unable to set field %q with last used value %q: %w", ignorePath, lastLiveVal, 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 fmt.Errorf("unable to parse field to ignore %q from live object: %w", ignorePath, err) | ||
} | ||
} | ||
|
||
|
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: false}); | ||
|
||
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; |
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: false}); | ||
|
||
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.1", | ||
}, | ||
], | ||
}, | ||
}, | ||
}, | ||
}, | ||
{ provider: provider, ignoreChanges: ["spec.replicas"] } | ||
); | ||
|
||
export const deploymentName = deployment.metadata.name; | ||
export const deploymentNamespace = deployment.metadata.namespace; |
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: false}); | ||
|
||
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", | ||
}, | ||
], | ||
}, | ||
}, | ||
}, | ||
}, | ||
{ provider: provider, ignoreChanges: ["spec.replicas"] } | ||
); | ||
|
||
export const deploymentName = deployment.metadata.name; | ||
export const deploymentNamespace = deployment.metadata.namespace; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# file used for basis of kubectl patch ... | ||
spec: | ||
replicas: 4 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# file used for basis of kubectl patch ... | ||
spec: | ||
replicas: 3 |
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 |
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" | ||
} | ||
} |
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" | ||
] | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've not a good intuition around liveOldObj *unstructured.Unstructur and *UpdateConfig but this looks kind of similar to what I've seen bridged providers have to do for ignoreChanges.. It makes sense with he comment 👍