Skip to content

fix(engine): treat $patch:delete on absent paths as no-op#146

Merged
lexfrei merged 2 commits intomainfrom
fix/patch-delete-absent-path-noop
May 7, 2026
Merged

fix(engine): treat $patch:delete on absent paths as no-op#146
lexfrei merged 2 commits intomainfrom
fix/patch-delete-absent-path-noop

Conversation

@lexfrei
Copy link
Copy Markdown
Contributor

@lexfrei lexfrei commented May 7, 2026

What changed

MergeFileAsPatch now strips body-side $patch:delete directives whose path does not resolve to a key in the cleaned rendered template. Previously these directives reached configpatcher.Apply and tripped its Selector-based deleteForPath with failed to delete path '...': lookup failed.

Why

Kubernetes strategic merge patch treats delete-of-absent as a no-op. The chart pattern that triggered this depends on that semantic: a controlplane node-body restating machine.nodeLabels.<label>: $patch: delete failed on every fresh apply because the freshly generated Talos config does not contain machine.nodeLabels at all — there is nothing to delete, but the apply errored anyway.

How

A new helper stripPatchDeleteDirectivesAbsentInTarget runs after the chart-side strip pass. It pairs body and target documents by identity tuple (apiVersion+kind+name, or the legacy-root sentinel) and strips any directive whose path does not resolve in the matching target. Three supporting helpers carry the logic: collectDeleteDirectivePaths enumerates body directives, pathExistsInDoc walks mappings to check for a key, jsonPointerUnescape reverses the existing escape so keys with literal / (FQDN-style label names like node.kubernetes.io/exclude-from-external-load-balancers) round-trip correctly.

Tests

Three new MergeFileAsPatch subtests:

  • body $patch:delete on absent path is a no-op — top-level absent path.
  • body $patch:delete on partially-present path is a no-op when leaf is absent — parent map present, leaf absent; sibling keys must survive.
  • body $patch:delete on present path still removes the key — regression-safety probe that the no-op-on-absent fix does not over-trigger.

Closes #144.

lexfrei added 2 commits May 7, 2026 20:44
Add three MergeFileAsPatch subtests covering body-side $patch:delete
directives whose target path does not exist in the rendered template:

- absent at the top level (machine.nodeLabels missing entirely from
  rendered): MergeFileAsPatch must accept the directive as a no-op.
- partially present (parent map present in rendered but the targeted
  leaf key absent): same no-op contract; sibling keys under the parent
  must survive untouched.
- present (sanity probe): the user-intent delete on a path the
  rendered template does populate must still land as a Selector and
  remove the key from the merged config.

The first two currently fail with `failed to delete path ... lookup
failed` from configpatcher.Apply: its Selector-based deleteForPath
walks the parsed v1alpha1.Config struct and errors when any path
segment does not resolve. Kubernetes strategic merge patch treats
delete-of-absent as a no-op; the fix must match that semantic so a
chart-emitted directive (e.g. machine.nodeLabels.<label>: $patch:
delete) does not break a fresh apply where the target struct has not
yet acquired the key.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
…mantics)

Add stripPatchDeleteDirectivesAbsentInTarget — runs after the chart-side
strip pass and removes any body $patch:delete directive whose path does
not resolve to a key in the cleaned rendered template. configpatcher.Apply
otherwise errors with `failed to delete path '...': lookup failed`
because its Selector-based deleteForPath walks the parsed v1alpha1.Config
struct and rejects any path segment that does not resolve.

Kubernetes strategic merge patch treats delete-of-absent as a no-op; the
fix restores that semantic so the chart's own pattern (a body that
re-states a chart-emitted directive after `talm template -I`) does not
break a fresh apply where the targeted key has not yet been populated on
the node. The user-facing failure mode: a controlplane node-body restating
machine.nodeLabels.node.kubernetes.io/exclude-from-external-load-balancers:
$patch: delete failed on every fresh apply against a freshly generated
config because the freshly generated config does not contain
machine.nodeLabels at all.

The new helper pairs body and target documents by identity tuple
(apiVersion+kind+name, or the legacy-root sentinel) so a body re-ordering
its typed documents relative to rendered still resolves directive paths
against the right target document. A body document with no matching
target document gets every directive stripped — matching the upstream
contract: there is nothing to delete.

Three supporting helpers carry the bulk of the logic:

  - collectDeleteDirectivePaths walks a YAML tree and returns the
    JSON-pointer-escaped paths of every $patch:delete directive,
    relative to the document root.
  - pathExistsInDoc resolves a relative path against a document by
    walking mappings segment by segment, deliberately mapping-only to
    mirror upstream's deleteForPath predicate.
  - jsonPointerUnescape reverses jsonPointerEscape per RFC 6901 so the
    walk treats keys with literal `/` or `~` characters
    (machine.nodeLabels uses such keys for FQDN-style label names)
    consistently with the strip pass that emitted the path.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Warning

Rate limit exceeded

@lexfrei has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 29 minutes and 1 second before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b0d772d5-7137-4c52-80ec-f3be7054ac12

📥 Commits

Reviewing files that changed from the base of the PR and between 741f895 and e37f13c.

📒 Files selected for processing (2)
  • pkg/engine/engine.go
  • pkg/engine/render_test.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/patch-delete-absent-path-noop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lexfrei lexfrei marked this pull request as ready for review May 7, 2026 17:53
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces logic to strip $patch:delete directives from YAML patches when the target path is absent in the base document, preventing lookup errors during the apply phase and aligning behavior with Kubernetes strategic merge patches. Feedback focuses on the need to support SequenceNode traversal in both collectDeleteDirectivePaths and pathExistsInDoc to ensure directives nested within lists or paths containing sequence indices are correctly handled.

Comment thread pkg/engine/engine.go
Comment on lines +466 to +487
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
found = append(found, collectDeleteDirectivePaths(child, parentRel)...)
}
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
continue
}
childRel := joinYAMLPath(parentRel, jsonPointerEscape(keyNode.Value))
if isPatchDeleteDirective(valueNode) {
found = append(found, childRel)
continue
}
if valueNode.Kind == yaml.MappingNode {
found = append(found, collectDeleteDirectivePaths(valueNode, childRel)...)
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The collectDeleteDirectivePaths function does not traverse SequenceNodes. If a $patch:delete directive is nested within a sequence (e.g., in a list of mappings), it will be missed, potentially causing the directive to be incorrectly left in the patch. Please add support for SequenceNode traversal.

	switch node.Kind {
	case yaml.DocumentNode:
		for _, child := range node.Content {
			found = append(found, collectDeleteDirectivePaths(child, parentRel)...)
		}
	case yaml.SequenceNode:
		for i, child := range node.Content {
			found = append(found, collectDeleteDirectivePaths(child, fmt.Sprintf("%s/%d", parentRel, i))...)
		}
	case yaml.MappingNode:
		for i := 0; i+1 < len(node.Content); i += 2 {
			keyNode := node.Content[i]
			valueNode := node.Content[i+1]
			if keyNode.Kind != yaml.ScalarNode {
				continue
			}
			childRel := joinYAMLPath(parentRel, jsonPointerEscape(keyNode.Value))
			if isPatchDeleteDirective(valueNode) {
				found = append(found, childRel)
				continue
			}
			if valueNode.Kind == yaml.MappingNode || valueNode.Kind == yaml.SequenceNode {
				found = append(found, collectDeleteDirectivePaths(valueNode, childRel)...)
			}
		}
	}

Comment thread pkg/engine/engine.go
Comment on lines +518 to +534
for _, escaped := range strings.Split(path, "/") {
seg := jsonPointerUnescape(escaped)
if cur.Kind != yaml.MappingNode {
return false
}
found := false
for i := 0; i+1 < len(cur.Content); i += 2 {
if cur.Content[i].Value == seg {
cur = cur.Content[i+1]
found = true
break
}
}
if !found {
return false
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The pathExistsInDoc function assumes all path segments refer to MappingNode keys. If a path includes a sequence index (e.g., list/0/key), this function will return false prematurely. Please add support for SequenceNode traversal to correctly resolve paths that include sequence indices.

	for _, escaped := range strings.Split(path, "/") {
		seg := jsonPointerUnescape(escaped)
		if cur.Kind == yaml.MappingNode {
			found := false
			for i := 0; i+1 < len(cur.Content); i += 2 {
				if cur.Content[i].Value == seg {
					cur = cur.Content[i+1]
					found = true
					break
				}
			}
			if !found {
				return false
			}
		} else if cur.Kind == yaml.SequenceNode {
			var idx int
			if n, err := fmt.Sscanf(seg, "%d", &idx); n != 1 || err != nil || idx < 0 || idx >= len(cur.Content) {
				return false
			}
			cur = cur.Content[idx]
		} else {
			return false
		}
	}

@lexfrei lexfrei merged commit 4b6d47f into main May 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

talm apply breaks $patch: delete on paths absent in freshly generated config (regression after #139)

3 participants