Skip to content

Owner optional#459

Open
thetechnick wants to merge 11 commits intopackage-operator:mainfrom
thetechnick:owner-optional
Open

Owner optional#459
thetechnick wants to merge 11 commits intopackage-operator:mainfrom
thetechnick:owner-optional

Conversation

@thetechnick
Copy link
Contributor

@thetechnick thetechnick commented Feb 17, 2026

Make Owner Optional in Boxcutter Machinery

Summary

  • Refactors the entire boxcutter stack so that the owner object is optional — previously it was a mandatory parameter at every layer (ObjectEngine, PhaseEngine, RevisionEngine, Comparator, and validation)
  • Introduces a WithOwner(obj, strategy) option that can be attached at the revision, phase, or object level, replacing the hard-coded owner parameter
  • Changes Phase and Revision from concrete structs to interfaces with builder constructors (NewPhase, NewPhaseWithOwner, NewRevision, NewRevisionWithOwner)
  • Includes bug fixes for nil-owner panics in teardown, revision number handling, and controller-situation detection without an owner
  • Adds comprehensive unit tests covering all new branches, with most tests parameterized to run both with and without an owner

Motivation

The boxcutter library previously required an owner object (a Kubernetes resource with a UID) at every level of the API. The owner was used to set ownerReferences on managed objects for garbage collection, detect controller ownership conflicts, and enforce namespace restrictions during validation.

This hard requirement prevented the library from being used in scenarios where no owner object exists, such as:

  • Managing standalone objects not tied to a parent resource
  • Cross-cluster deployments where owner references are irrelevant
  • Simpler use cases that don't need ownership tracking

API Changes

New Types and Constructors

API Description
NewPhase(name, objects) Creates a phase without an owner
NewPhaseWithOwner(name, objects, owner, strategy) Creates a phase with an owner attached as an option
NewRevision(name, rev, phases) Creates a revision without an owner
NewRevisionWithOwner(name, rev, phases, owner, strategy) Creates a revision with an owner attached as an option
WithOwner(obj, strategy) Option that implements ComparatorOption, ObjectReconcileOption, and ObjectTeardownOption
PhaseBuilder / RevisionBuilder Builder interfaces with WithReconcileOptions() and WithTeardownOptions() methods

Signature Changes

Component Before After
NewComparator (ownerStrategy, discoveryClient, scheme, fieldOwner) (discoveryClient, scheme, fieldOwner)
Comparator.Compare (owner, desired, actual) (desired, actual, opts...)
NewObjectEngine (ownerStrategy, ...) (...) — owner strategy removed
ObjectEngine.Reconcile (ctx, owner, revision, obj, opts...) (ctx, revision, obj, opts...)
ObjectEngine.Teardown (ctx, owner, revision, obj, opts...) (ctx, revision, obj, opts...)
PhaseEngine.Reconcile/Teardown (ctx, owner, phase, opts...) (ctx, phase, opts...)
ObjectValidator.Validate (ctx, owner, obj) (ctx, obj, opts...)
PhaseValidator.Validate (ctx, owner, phase) (ctx, phase, opts...)

Other Breaking Changes

  • Phase and Revision are now interfaces, not structs — use the New* constructors instead of struct literals
  • Phase.Objects changed from []unstructured.Unstructured to []client.Object
  • Revision.Owner field and GetOwner() method removed — use WithOwner option or RevisionReconcileOptions.GetOwner() instead
  • RevisionEngineOptions.OwnerStrategy field removed — owner strategy is now provided per-revision/phase via WithOwner

Internal Machinery Changes

  • ObjectEngine: Owner-related logic (setting controller references, detecting ownership, handling handover) is now conditional on options.Owner != nil. A new checkSituation method consolidates controller-situation detection: when no owner is present, the boxcutter-managed label is used to determine control status instead of ownerReferences.
  • PhaseEngine / RevisionEngine: Options are now merged from both explicit parameters and phase.GetReconcileOptions() / rev.GetReconcileOptions(), allowing owner to flow through the option hierarchy.
  • Comparator: Owner reference setting on desiredObject is now conditional. The ownerStrategy field was removed from the struct; it is passed in via ComparatorOption when needed.
  • Validation: Namespace escalation checks are skipped when no owner is present. Object metadata validation now accepts client.Object instead of *unstructured.Unstructured.

Bug Fixes

  • Fix revision not set: Proper error handling when the revision number is not found in ConfigMap data in the reference implementation
  • Fix Teardown WithOrphan when there is no owner: Guard against nil-pointer panic when checking controllerutil.ContainsFinalizer(owner, "orphan") — now short-circuits on options.Orphan first
  • Fix and consolidate checkSituation when there is no owner: Extracted and consolidated controller-situation detection into a single checkSituation method that handles both owner and no-owner cases. Fixed teardown revision-mismatch logic to return true (gone) when there is no owner instead of attempting to remove a non-existent owner reference
  • Typo fix: Corrected "owner must be persistet" to "owner must be persisted"

Test Changes

  • Refactored ObjectEngine tests using an ownerMode abstraction with two modes (withNativeOwnerMode and withoutOwnerMode), so most tests run in both modes by default
  • Introduced a declarative builder pattern (buildObj, objBuilder) for constructing test objects
  • Added comprehensive tests for previously uncovered branches: error paths, paused states, revision mismatches, collision protection variants, and orphan finalizer handling
  • Added new RevisionEngine tests covering success, waiting on incomplete phases, validation errors, and option forwarding
  • Added tests for new option types (WithOwner, WithOrphan, WithTeardownWriter) and builder methods
  • Updated all mock interfaces to match new signatures

Migration Guide

The reference implementation in cmd/reference/internal/ serves as a migration example:

  1. Store ownerStrategy on your reconciler struct instead of passing it to engine constructors
  2. Use NewRevisionWithOwner(name, rev, phases, owner, strategy) or NewRevision(name, rev, phases) instead of Revision{...} struct literals
  3. Use NewPhase(name, objects) instead of Phase{...} struct literals
  4. Remove owner from all Reconcile/Teardown calls
  5. If you need the owner from a revision, use RevisionReconcileOptions.GetOwner()

Test plan

  • All existing unit tests pass (refactored to new API)
  • All machinery tests run in both with-owner and without-owner modes
  • New unit tests cover previously uncovered branches
  • Integration tests updated for new signatures

🤖 Generated with Claude Code

@thetechnick thetechnick requested a review from a team as a code owner February 17, 2026 14:26
@coderabbitai
Copy link

coderabbitai bot commented Feb 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Ownership moved from positional parameters into option-based APIs; Phase and Revision became interface-backed builders; objects use controller-runtime client.Object; comparator, object, phase, and revision engines accept variadic options (e.g., WithOwner); Reconciler gained ownerStrategy and revisions are built via NewRevision/NewRevisionWithOwner.

Changes

Cohort / File(s) Summary
Public facade / exports
boxcutter.go
Expose builder aliases and constructors (RevisionBuilder, NewRevision, NewRevisionWithOwner, PhaseBuilder, NewPhase, NewPhaseWithOwner, WithOwner); remove OwnerStrategy from RevisionEngineOptions and stop internal defaulting.
Core types & options
machinery/types/types.go, machinery/types/options.go, machinery/types/*_test.go
Introduce Phase/Revision interfaces + builder constructors and chaining methods; change phase objects to []client.Object; add ComparatorOptions/ComparatorOption, unexported ownerStrategy abstraction, combinedOpts and WithOwner option to carry ownership into comparator/reconcile/teardown flows.
Comparator
machinery/comparator.go, machinery/comparator_test.go, test/comparator_test.go
Remove owner/ownerStrategy positional args from NewComparator and Compare; Compare now accepts opts ...types.ComparatorOption and applies ownership wiring from options (e.g., WithOwner). Tests updated to option-driven usage.
Object engine & comparator interface
machinery/objects.go, machinery/objects_test.go, test/objects_test.go
Remove ownerStrategy param from NewObjectEngine; object engine methods drop explicit owner argument and consume Owner/OwnerStrategy via options; comparator interface updated to variadic options; reconcile/teardown and internal ownership logic refactored to use options.
Phase & revision engines
machinery/phases.go, machinery/phases_test.go, machinery/revision.go, machinery/revision_test.go
Phase/Revision engine public signatures drop explicit owner args; Reconcile/Teardown now use revision number + phase + variadic options and forward phase/object-level options (e.g., options.ForObject(obj)). Tests use NewPhase/NewRevision and options for ownership.
Validation layer
validation/*.go, validation/*_test.go
Validators switched from *unstructured.Unstructured to client.Object; Validate signatures accept options (e.g., types.ObjectReconcileOption / WithOwner) instead of owner param; metadata/namespace/dry-run helpers updated to use client.Object.
Reconciler & CLI integration
cmd/reference/internal/deploy.go, cmd/reference/internal/util.go
Reconciler gains ownerStrategy field (initialized via ownerhandling.NewNative(scheme)); toRevision now returns a value boxcutter.Revision built via boxcutter.NewRevisionWithOwner/NewPhase; owner extraction unified via getOwnerFromRev.
Tests & utilities
test/*, machinery/..._test.go, test/test_util.go
Widespread test updates: constructor-based Phase/Revision usage, client.Object slices, comparator/object-engine mocks adjusted for options-based ownership, toUns now returns *unstructured.Unstructured, and many mocks updated for new argument orders.
Misc (validation metadata)
validation/metadata.go
Metadata validation now accepts client.Object and reads version/kind via GetObjectKind().GroupVersionKind() instead of unstructured helpers.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Caller
    participant RevEngine as RevisionEngine
    participant PhaseEngine as PhaseEngine
    participant ObjEngine as ObjectEngine
    participant Comparator as Comparator
    participant K8s as KubernetesAPI

    Caller->>RevEngine: Reconcile(ctx, revision, opts...)
    RevEngine->>PhaseEngine: Reconcile(revisionNumber, phase, opts...)
    PhaseEngine->>ObjEngine: Reconcile(revisionNumber, desiredObj, options.ForObject(obj)...)
    ObjEngine->>Comparator: Compare(desiredObj, actualObj, WithOwner(owner, strat))
    Comparator->>ObjEngine: CompareResult
    ObjEngine->>K8s: Apply/Update/Delete (use fieldOwner, ownerRef from options)
    K8s-->>ObjEngine: API response
    ObjEngine-->>PhaseEngine: ObjectResult
    PhaseEngine-->>RevEngine: PhaseResult
    RevEngine-->>Caller: Aggregate result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The PR description is empty; it contains only the template structure with placeholders and no concrete change information. Add a detailed description of changes, motivation, and impacts. Fill in the Summary section, select a Change Type, verify the checklist items, and document any breaking changes or migration steps.
Title check ❓ Inconclusive The title 'Owner optional' is vague and generic, using non-descriptive terms that don't clearly convey the specific technical changes made in the changeset. Provide a more specific title that summarizes the main architectural change, such as 'Make owner strategy optional in reconciliation paths' or 'Refactor owner handling to option-based pattern'.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
cmd/reference/internal/util.go (1)

37-43: Consider adding nil check for owner object.

If getOwnerFromRev(rev) returns nil (e.g., when a revision has no owner option set), the subsequent calls to refObj.GetObjectKind(), GetName(), etc. will panic.

🛡️ Proposed defensive check
 	for _, rev := range prevRevisions {
 		refObj := getOwnerFromRev(rev)
+		if refObj == nil {
+			continue // skip revisions without owners
+		}
 		ref := unstructured.Unstructured{}
 		ref.SetGroupVersionKind(refObj.GetObjectKind().GroupVersionKind())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/reference/internal/util.go` around lines 37 - 43, getOwnerFromRev(rev)
can return nil, causing a panic when calling
refObj.GetObjectKind()/GetName()/GetNamespace()/GetUID(); add a nil check after
calling getOwnerFromRev(rev) in the block that builds ref (the refObj variable)
and handle the nil case (e.g., skip appending to data or return an error) so you
only call refObj methods when refObj != nil before creating and appending ref to
data.
machinery/types/types_test.go (1)

218-251: Consider using NewPhase constructor for consistency.

The test constructs phases using the internal &phase{} struct directly (lines 219, 233) while other tests in this file use the public NewPhase constructor. Using the constructor would improve consistency and decouple tests from internal implementation details.

♻️ Suggested refactor
 	phases := []Phase{
-		&phase{
-			Name: "phase1",
-			Objects: []client.Object{
-				&unstructured.Unstructured{
+		NewPhase("phase1", []client.Object{
+			&unstructured.Unstructured{
 				Object: map[string]interface{}{
 					"apiVersion": "v1",
 					"kind":       "ConfigMap",
 					"metadata": map[string]interface{}{
 						"name": "test-cm",
 					},
 				},
-				},
 			},
-		},
-		&phase{
-			Name: "phase2",
-			Objects: []client.Object{
-				&unstructured.Unstructured{
+		}),
+		NewPhase("phase2", []client.Object{
+			&unstructured.Unstructured{
 				Object: map[string]interface{}{
 					"apiVersion": "v1",
 					"kind":       "Secret",
 					"metadata": map[string]interface{}{
 						"name": "test-secret",
 					},
 				},
-				},
 			},
-		},
+		}),
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@machinery/types/types_test.go` around lines 218 - 251, The test directly
instantiates the unexported phase struct (&phase{...}) instead of using the
public NewPhase constructor; update the two phase creations to call
NewPhase(...) (preserving Name and Objects) so the test uses Phase via the
NewPhase constructor and no longer depends on the internal type, then pass the
resulting Phase slices into NewRevision("test", 2, phases) as before.
machinery/types/options.go (1)

380-410: Minor: Parameter name start appears to be a typo.

The parameter start ownerStrategy would be clearer as strat or strategy to match its purpose.

✏️ Suggested parameter rename
-func WithOwner(obj client.Object, start ownerStrategy) interface {
+func WithOwner(obj client.Object, strat ownerStrategy) interface {
 	ComperatorOption
 	ObjectReconcileOption
 	ObjectTeardownOption
 } {
 	if len(obj.GetUID()) == 0 {
 		panic("owner must be persisted to cluster, empty UID")
 	}

 	return &combinedOpts{
 		fn: func(opts *ComperatorOptions) {
 			opts.Owner = obj
-			opts.OwnerStrategy = start
+			opts.OwnerStrategy = strat
 		},
 		optionFn: optionFn{
 			fn: func(opts *ObjectReconcileOptions) {
 				opts.Owner = obj
-				opts.OwnerStrategy = start
+				opts.OwnerStrategy = strat
 			},
 		},
 		teardownOptionFn: teardownOptionFn{
 			fn: func(opts *ObjectTeardownOptions) {
 				opts.Owner = obj
-				opts.OwnerStrategy = start
+				opts.OwnerStrategy = strat
 			},
 		},
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@machinery/types/options.go` around lines 380 - 410, Rename the misleading
parameter name "start" in the WithOwner function to a clearer name like
"strategy" (signature: WithOwner(obj client.Object, strategy ownerStrategy)) and
update all references inside the function (e.g., opts.OwnerStrategy = strategy)
as well as any call sites that pass that argument to use the new parameter name;
ensure the function still returns the same combinedOpts and assigns the
OwnerStrategy field in ComperatorOptions, ObjectReconcileOptions, and
ObjectTeardownOptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/reference/internal/util.go`:
- Around line 64-71: The owner object returned by getOwnerFromRev (which calls
RevisionReconcileOptions.GetOwner) may be nil, so before using refObj (and
calling refObj.GetObjectKind().GroupVersionKind()) add a nil check and handle
the missing owner case (e.g., skip this revision with continue or return a clear
error); update the code that assigns refObj to verify refObj != nil and branch
accordingly to avoid a panic.

In `@machinery/objects.go`:
- Around line 270-299: When options.Owner is non-nil you must validate
options.OwnerStrategy is also non-nil before calling detectOwner or
types.WithOwner to avoid panics; add a guard in the reconcile path (the block
checking if options.Owner == nil) or in ObjectReconcileOptions.Default() that
returns an error if Owner is set but OwnerStrategy is nil, referencing the
detectOwner(...) call and the types.WithOwner(...) usage so callers are
prevented from passing a nil OwnerStrategy.

In `@machinery/types/options.go`:
- Around line 422-429: Rename the misspelled exported types ComperatorOptions
and ComperatorOption to ComparatorOptions and ComparatorOption respectively,
updating their type declarations and the method ApplyToComperatorOptions to
ApplyToComparatorOptions (and any receiver names) and all usages in
machinery/comparator.go, machinery/objects.go and test files to use the new
names; ensure you also update any functions, constructors, interfaces, or
variables that reference ComperatorOptions/ComperatorOption (including calls
that accept or return these types) so the public API matches the correctly
spelled existing Comparator type while preserving existing fields (Owner,
ownerStrategy) and behavior.

In `@machinery/types/types.go`:
- Around line 115-116: The doc comment above the method WithTeardownOptions is
incorrect: it reads "WithReconcileOptions" but should describe
WithTeardownOptions; update the comment to accurately state that
WithTeardownOptions sets PhaseTeardownOptions on this phase (or similar accurate
wording). Locate the comment immediately preceding the phase.WithTeardownOptions
method in types.go and replace the incorrect text so the comment and the method
name match.
- Around line 51-52: Update the doc comment above the WithTeardownOptions method
to correctly reference the method name and behavior: change the text from
"WithReconcileOptions sets PhaseReconcileOptions on this phase." to a concise
description like "WithTeardownOptions sets PhaseTeardownOptions on this phase."
Ensure the comment references WithTeardownOptions, PhaseTeardownOption, and
PhaseBuilder so the documentation matches the signature.

In `@validation/phase_test.go`:
- Around line 289-290: The test calls realValidator.Validate(t.Context(),
test.phase, types.WithOwner(test.owner, nil)) but WithOwner panics if the owner
UID is empty; guard by ensuring test.owner has a non-empty UID before passing it
to types.WithOwner (e.g., set a dummy UID when test.owner.UID is empty) so the
path using realValidator (controlled by useRealValidator) won't panic—update the
test setup around realValidator.Validate to normalize test.owner.UID when empty.

---

Nitpick comments:
In `@cmd/reference/internal/util.go`:
- Around line 37-43: getOwnerFromRev(rev) can return nil, causing a panic when
calling refObj.GetObjectKind()/GetName()/GetNamespace()/GetUID(); add a nil
check after calling getOwnerFromRev(rev) in the block that builds ref (the
refObj variable) and handle the nil case (e.g., skip appending to data or return
an error) so you only call refObj methods when refObj != nil before creating and
appending ref to data.

In `@machinery/types/options.go`:
- Around line 380-410: Rename the misleading parameter name "start" in the
WithOwner function to a clearer name like "strategy" (signature: WithOwner(obj
client.Object, strategy ownerStrategy)) and update all references inside the
function (e.g., opts.OwnerStrategy = strategy) as well as any call sites that
pass that argument to use the new parameter name; ensure the function still
returns the same combinedOpts and assigns the OwnerStrategy field in
ComperatorOptions, ObjectReconcileOptions, and ObjectTeardownOptions.

In `@machinery/types/types_test.go`:
- Around line 218-251: The test directly instantiates the unexported phase
struct (&phase{...}) instead of using the public NewPhase constructor; update
the two phase creations to call NewPhase(...) (preserving Name and Objects) so
the test uses Phase via the NewPhase constructor and no longer depends on the
internal type, then pass the resulting Phase slices into NewRevision("test", 2,
phases) as before.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
validation/object.go (1)

64-86: ⚠️ Potential issue | 🟠 Major

Avoid skipping namespace/scope validation when owner is omitted.

In namespaced mode (allowNamespaceEscalation=false), namespace validation now only runs when options.Owner != nil. If a caller forgets WithOwner, cluster-scoped or cross-namespace objects can pass preflight validation. Consider failing fast when Owner is missing (or require an explicit “allow escalation” option) to avoid silently weakening guarantees.

🔒 Possible guard
-	if options.Owner != nil && !d.allowNamespaceEscalation {
-		// Ensure we are not leaving the namespace we are operating in.
-		if err := validateNamespace(
-			d.restMapper, options.Owner.GetNamespace(), obj,
-		); err != nil {
-			errs = append(errs, err)
-			// we don't want to do a dry-run when this already fails.
-			return NewObjectValidationError(bctypes.ToObjectRef(obj), errs...)
-		}
-	}
+	if !d.allowNamespaceEscalation {
+		if options.Owner == nil {
+			errs = append(errs, errors.New("owner required for namespaced validation"))
+			return NewObjectValidationError(bctypes.ToObjectRef(obj), errs...)
+		}
+		// Ensure we are not leaving the namespace we are operating in.
+		if err := validateNamespace(
+			d.restMapper, options.Owner.GetNamespace(), obj,
+		); err != nil {
+			errs = append(errs, err)
+			// we don't want to do a dry-run when this already fails.
+			return NewObjectValidationError(bctypes.ToObjectRef(obj), errs...)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@validation/object.go` around lines 64 - 86, The Validate method currently
skips namespace/scope checks when options.Owner is nil, allowing unintended
cross-namespace/cluster objects in namespaced mode; update
ObjectValidator.Validate to treat a missing Owner as a validation failure when
d.allowNamespaceEscalation == false: if options.Owner == nil &&
!d.allowNamespaceEscalation, append an error (e.g., "missing owner prevents
namespace validation" or similar) to errs and return
NewObjectValidationError(bctypes.ToObjectRef(obj), errs...) before proceeding to
validateNamespace; keep the existing validateNamespace call for the non-nil
Owner path.
🧹 Nitpick comments (1)
machinery/phases_test.go (1)

97-98: Stale mock expectation may cause test issues.

The mock at line 97 still uses the old signature with owner parameter:

oe.On("Reconcile", mock.Anything, owner, revision, obj, mock.Anything)

But the actual objectEngineMock.Reconcile method (lines 159-168) has the new signature without owner. This mock expectation will never match, though the test passes because AssertNotCalled is used. Consider updating to:

oe.On("Reconcile", mock.Anything, revision, obj, mock.Anything)
♻️ Suggested fix
-	oe.On("Reconcile", mock.Anything, owner, revision, obj, mock.Anything).
+	oe.On("Reconcile", mock.Anything, revision, obj, mock.Anything).
 		Return(newObjectResultCreated(obj, types.ObjectReconcileOptions{}), nil)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@machinery/phases_test.go` around lines 97 - 98, The mock expectation for
oe.On("Reconcile", ...) is using the old signature that includes the owner
parameter and thus never matches the actual objectEngineMock.Reconcile
signature; update the expectation to match the new signature used by
objectEngineMock.Reconcile by removing the owner argument so the call becomes
oe.On("Reconcile", mock.Anything, revision, obj, mock.Anything) (ensure you
reference the oe.On mock setup and the objectEngineMock.Reconcile method to
verify the parameter list).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@machinery/types/types.go`:
- Around line 210-211: Fix the typo in the doc comment for the method
revision.GetTeardownOptions: change "tearding" to "tearing" so the comment reads
"returns options for tearing down this revision" (update the comment immediately
above the GetTeardownOptions function declaration in the revision type).
- Around line 103-104: The doc comment for method GetTeardownOptions on type
phase has a typo ("tearding"); update the comment to "tearing" so it reads:
"GetTeardownOptions returns options for tearing down this phase." Ensure you
edit the comment immediately above the GetTeardownOptions method (and leave the
function signature and return type PhaseTeardownOption unchanged).
- Around line 132-133: Update the doc comment for the GetTeardownOptions method
to fix the typo "tearding" to "tearing" so it reads "GetTeardownOptions returns
options for tearing down this revision."; locate the comment immediately above
the GetTeardownOptions() []RevisionTeardownOption declaration and replace the
misspelled word while leaving the method signature and return type unchanged.
- Around line 42-43: Doc comment for GetTeardownOptions contains a typo
("tearding") — update the comment to read "tearing" so it correctly documents
GetTeardownOptions() []PhaseTeardownOption; locate the comment immediately above
the GetTeardownOptions method signature and replace "tearding down this phase"
with "tearing down this phase".

---

Outside diff comments:
In `@validation/object.go`:
- Around line 64-86: The Validate method currently skips namespace/scope checks
when options.Owner is nil, allowing unintended cross-namespace/cluster objects
in namespaced mode; update ObjectValidator.Validate to treat a missing Owner as
a validation failure when d.allowNamespaceEscalation == false: if options.Owner
== nil && !d.allowNamespaceEscalation, append an error (e.g., "missing owner
prevents namespace validation" or similar) to errs and return
NewObjectValidationError(bctypes.ToObjectRef(obj), errs...) before proceeding to
validateNamespace; keep the existing validateNamespace call for the non-nil
Owner path.

---

Duplicate comments:
In `@cmd/reference/internal/util.go`:
- Around line 36-42: prevJSON currently dereferences the result of
getOwnerFromRev(rev) into refObj without checking for nil, which can panic when
no owner is set; update prevJSON to check if refObj == nil after calling
getOwnerFromRev and handle it (either skip that revision with a clear
log/continue or return an explicit error) before calling methods on refObj
(GetObjectKind, GetName, GetNamespace, GetUID); apply the same nil-guard pattern
to the similar block around lines 64-70 so both uses of getOwnerFromRev are
protected.

In `@machinery/objects.go`:
- Around line 270-299: The code can panic if callers set
ObjectReconcileOptions.Owner directly without setting OwnerStrategy; add a
validation in ObjectReconcileOptions.Default() to detect when Owner != nil and
OwnerStrategy == nil and either assign a sensible default OwnerStrategy or
return an error, so callers who bypass types.WithOwner() don't cause panics in
detectOwner or comparator.Compare; update Default() to enforce or normalize
options.Owner and options.OwnerStrategy consistently (reference
ObjectReconcileOptions.Default, WithOwner, detectOwner, comparator.Compare, and
the fields Owner/OwnerStrategy).

In `@machinery/types/options.go`:
- Around line 426-433: The exported types are misspelled: rename
ComperatorOptions -> ComparatorOptions and ComperatorOption -> ComparatorOption
across the file and update their methods/signatures (e.g.,
ApplyToComperatorOptions -> ApplyToComparatorOptions) so they match the
correctly-spelled Comparator in machinery/comparator.go; update all references
to the fields and types (Owner, ownerStrategy, any implementations of
ComperatorOption) to use the new names to restore consistency with Comparator
types and avoid breaking imports.

In `@validation/phase_test.go`:
- Around line 288-289: The test calls types.WithOwner(test.owner, nil) which
panics if the owner's UID is empty; to fix, ensure the test owner has a
non-empty UID before constructing the WithOwner option: before creating
realValidator/ calling Validate, add a small guard that if test.owner != nil and
test.owner.GetUID() is empty, set a deterministic UID (e.g. "test-owner-uid") on
test.owner (use the owner object's SetUID method); this prevents WithOwner from
panicking when NewClusterPhaseValidator.Validate is exercised.

---

Nitpick comments:
In `@machinery/phases_test.go`:
- Around line 97-98: The mock expectation for oe.On("Reconcile", ...) is using
the old signature that includes the owner parameter and thus never matches the
actual objectEngineMock.Reconcile signature; update the expectation to match the
new signature used by objectEngineMock.Reconcile by removing the owner argument
so the call becomes oe.On("Reconcile", mock.Anything, revision, obj,
mock.Anything) (ensure you reference the oe.On mock setup and the
objectEngineMock.Reconcile method to verify the parameter list).

Comment on lines 132 to 133
// GetTeardownOptions returns options for tearding down this revision.
GetTeardownOptions() []RevisionTeardownOption
Copy link

@coderabbitai coderabbitai bot Feb 17, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Typo in doc comment: "tearding" → "tearing".

📝 Proposed fix
-	// GetTeardownOptions returns options for tearding down this revision.
+	// GetTeardownOptions returns options for tearing down this revision.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// GetTeardownOptions returns options for tearding down this revision.
GetTeardownOptions() []RevisionTeardownOption
// GetTeardownOptions returns options for tearing down this revision.
GetTeardownOptions() []RevisionTeardownOption
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@machinery/types/types.go` around lines 132 - 133, Update the doc comment for
the GetTeardownOptions method to fix the typo "tearding" to "tearing" so it
reads "GetTeardownOptions returns options for tearing down this revision.";
locate the comment immediately above the GetTeardownOptions()
[]RevisionTeardownOption declaration and replace the misspelled word while
leaving the method signature and return type unchanged.

✅ Addressed in commit 06bcb93

@codecov
Copy link

codecov bot commented Feb 17, 2026

Codecov Report

❌ Patch coverage is 78.73303% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.73%. Comparing base (0198a39) to head (526ba9b).

Files with missing lines Patch % Lines
cmd/reference/internal/deploy.go 0.00% 23 Missing ⚠️
machinery/objects.go 80.82% 9 Missing and 5 partials ⚠️
cmd/reference/internal/util.go 0.00% 6 Missing ⚠️
boxcutter.go 50.00% 2 Missing ⚠️
machinery/comparator.go 75.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #459      +/-   ##
==========================================
+ Coverage   69.05%   71.73%   +2.67%     
==========================================
  Files          35       35              
  Lines        2870     2986     +116     
==========================================
+ Hits         1982     2142     +160     
+ Misses        750      715      -35     
+ Partials      138      129       -9     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
validation/phase_test.go (1)

22-52: Align test helper with option-based validator signatures.
The mock still models the old owner-parameter API, so option wiring isn’t exercised in these tests.

♻️ Suggested update
-func (m *mockObjectValidator) Validate(ctx context.Context, owner client.Object, obj client.Object) error {
-	args := m.Called(ctx, owner, obj)
+func (m *mockObjectValidator) Validate(ctx context.Context, obj client.Object, opts ...types.ObjectReconcileOption) error {
+	args := m.Called(ctx, obj, opts)
 
 	return args.Error(0)
 }
-func (v *testablePhaseValidator) Validate(ctx context.Context, owner client.Object, phase types.Phase) error {
-	phaseError := validatePhaseName(phase)
+func (v *testablePhaseValidator) Validate(ctx context.Context, phase types.Phase, opts ...types.PhaseReconcileOption) error {
+	var options types.PhaseReconcileOptions
+	for _, opt := range opts {
+		opt.ApplyToPhaseReconcileOptions(&options)
+	}
+	phaseError := validatePhaseName(phase)
 
 	var (
 		objectErrors []ObjectValidationError
 		errs         []error
 	)
 
 	for _, obj := range phase.GetObjects() {
-		err := v.mockObjValidator.Validate(ctx, owner, obj)
+		err := v.mockObjValidator.Validate(ctx, obj, options.ForObject(obj)...)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@validation/phase_test.go` around lines 22 - 52, Tests use a
mockObjectValidator whose Validate still takes an explicit owner parameter, so
option-based wiring isn’t tested; change mockObjectValidator.Validate to match
the new signature by accepting ctx, obj client.Object, opts
...PhaseValidationOption and update testablePhaseValidator.Validate to call
v.mockObjValidator.Validate(ctx, obj, WithOwner(owner)) (or your project's
equivalent owner PhaseValidationOption) for each obj instead of passing owner as
a separate arg, keeping the existing handling of returned errors
(ObjectValidationError, errs) and leaving validatePhaseName/PhaseValidator logic
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@machinery/phases_test.go`:
- Line 62: The test is calling types.WithOwner(owner, nil) which leaves
OwnerStrategy nil and will panic when ObjectReconcileOptions.Default() is
invoked; update the test to provide a non-nil OwnerStrategy (either a
lightweight real implementation or a test/mock implementation) when calling
types.WithOwner so Owner and OwnerStrategy are consistent, ensuring calls to
ObjectReconcileOptions.Default() do not panic; locate usages of types.WithOwner
in the test (and any helper that constructs reconcile options) and replace the
nil strategy with a suitable concrete or mock OwnerStrategy instance.

---

Duplicate comments:
In `@cmd/reference/internal/util.go`:
- Around line 37-44: The code dereferences refObj returned by
getOwnerFromRev(rev) without a nil check and may panic; update the loop handling
to first check if refObj == nil and skip creating/refilling the
unstructured.Unstructured (ref) when nil (i.e., continue), otherwise proceed to
call ref.SetGroupVersionKind(...), ref.SetName(...), ref.SetNamespace(...),
ref.SetUID(...), and append to data; ensure getOwnerFromRev is referenced and
ref is only used when refObj is non-nil.

In `@machinery/types/types.go`:
- Around line 210-211: Doc comment for GetTeardownOptions contains a typo
"tearding" — update the comment(s) to read "tearing" instead; locate the
GetTeardownOptions declaration and the revision.GetTeardownOptions
implementation and fix the doc string in both places so they use "tearing down"
rather than "tearding".
- Around line 42-43: The doc comment for GetTeardownOptions contains a typo
("tearding"); update the comment to read "tearing down this phase" so the method
signature GetTeardownOptions() []PhaseTeardownOption has a correct description
and matches project docs/conventions.
- Around line 103-104: Doc comment for GetTeardownOptions contains a typo
"tearding" — change it to "tearing"; update the comment above the method on type
phase (GetTeardownOptions) and the matching comment in the Phase interface to
use "tearing" instead of "tearding" so both declarations/docs stay consistent;
verify the comments immediately above the GetTeardownOptions func and the Phase
interface method signature are corrected to "tearing down this phase."
- Around line 132-133: Fix the typo in the doc comments: change "tearding down
this revision" to "tearing down this revision" for the GetTeardownOptions method
comment and the same doc comment on the Revision interface so both references
use "tearing" instead of "tearding".

In `@validation/phase_test.go`:
- Around line 288-290: The test calls types.WithOwner(test.owner, nil) which
will panic if test.owner.UID is empty; update the real validator path around
NewClusterPhaseValidator/realValidator.Validate to guard against an empty owner
UID by checking test.owner != nil && test.owner.UID != "" before calling
types.WithOwner, and if empty either pass nil as the owner option or skip
WithOwner and call Validate without that option so the code no longer panics
when owner UID is missing.

---

Nitpick comments:
In `@validation/phase_test.go`:
- Around line 22-52: Tests use a mockObjectValidator whose Validate still takes
an explicit owner parameter, so option-based wiring isn’t tested; change
mockObjectValidator.Validate to match the new signature by accepting ctx, obj
client.Object, opts ...PhaseValidationOption and update
testablePhaseValidator.Validate to call v.mockObjValidator.Validate(ctx, obj,
WithOwner(owner)) (or your project's equivalent owner PhaseValidationOption) for
each obj instead of passing owner as a separate arg, keeping the existing
handling of returned errors (ObjectValidationError, errs) and leaving
validatePhaseName/PhaseValidator logic unchanged.

obj,
},
})
), types.WithOwner(owner, nil))
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Passing nil ownerStrategy will panic if Default() is called.

types.WithOwner(owner, nil) sets Owner to a non-nil value while OwnerStrategy remains nil. When ObjectReconcileOptions.Default() is called (line 128-130 in options.go), this combination triggers a panic. The tests likely pass because the mock doesn't actually call Default(), but this creates inconsistency with production behavior.

Consider using a real or mock OwnerStrategy in tests:

🧪 Proposed fix
+	ownerStrat := ownerhandling.NewNative(scheme.Scheme)
+
 	_, err := pe.Reconcile(t.Context(), revision, types.NewPhase(
 		"test",
 		[]client.Object{
 			obj,
 		},
-	), types.WithOwner(owner, nil))
+	), types.WithOwner(owner, ownerStrat))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@machinery/phases_test.go` at line 62, The test is calling
types.WithOwner(owner, nil) which leaves OwnerStrategy nil and will panic when
ObjectReconcileOptions.Default() is invoked; update the test to provide a
non-nil OwnerStrategy (either a lightweight real implementation or a test/mock
implementation) when calling types.WithOwner so Owner and OwnerStrategy are
consistent, ensuring calls to ObjectReconcileOptions.Default() do not panic;
locate usages of types.WithOwner in the test (and any helper that constructs
reconcile options) and replace the nil strategy with a suitable concrete or mock
OwnerStrategy instance.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
machinery/types/types.go (1)

109-120: Builder methods replace options rather than append.

Both WithReconcileOptions and WithTeardownOptions replace existing options entirely rather than appending to them. This is a valid design choice for a builder pattern, but be aware that calling these methods multiple times will overwrite previous options.

If accumulating options is needed, consider documenting this behavior or providing AddReconcileOptions/AddTeardownOptions variants.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@machinery/types/types.go` around lines 109 - 120, The current builder methods
WithReconcileOptions and WithTeardownOptions overwrite existing slices
(p.ReconcileOptions and p.TeardownOptions) instead of appending, so repeated
calls clobber previous options; to fix, either change each method to append new
opts to the existing slice (e.g., p.ReconcileOptions =
append(p.ReconcileOptions, opts...)) and likewise for p.TeardownOptions, or add
new methods AddReconcileOptions/AddTeardownOptions that perform the append and
update the PhaseBuilder interface and docs to expose the new behavior; update
comments on With* to document that they replace options if you keep the current
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@machinery/types/types.go`:
- Around line 63-75: The exported function NewPhaseWithOwner currently accepts
the unexported ownerStrategy type which prevents external callers from passing a
strategy; change the parameter type from ownerStrategy to the exported
OwnerStrategy interface (use the OwnerStrategy defined in the boxcutter package)
and update any references where ownerStrat is passed (e.g. the call to
WithOwner(owner, ownerStrat)) so
WithOwner/WithReconcileOptions/WithTeardownOptions receive the exported
OwnerStrategy; keep the rest of the function (creation of phase and chaining of
WithReconcileOptions/WithTeardownOptions) unchanged and ensure imports include
the package that defines OwnerStrategy.

---

Duplicate comments:
In `@machinery/types/types.go`:
- Around line 103-104: Fix the doc comment on the GetTeardownOptions method:
change the misspelled word "tearding" to "tearing" in the comment above func (p
*phase) GetTeardownOptions() []PhaseTeardownOption; ensure the comment reads
"GetTeardownOptions returns options for tearing down this phase." and keep the
rest of the comment format intact.
- Around line 132-133: The doc comment for GetTeardownOptions contains a typo
("tearding"); update the comment above the GetTeardownOptions() method to read
"GetTeardownOptions returns options for tearing down this revision." and ensure
the signature GetTeardownOptions() []RevisionTeardownOption and the referenced
type RevisionTeardownOption remain unchanged.
- Around line 42-43: Fix the typo in the doc comment for the GetTeardownOptions
method: change "tearding" to "tearing" in the comment above GetTeardownOptions()
[]PhaseTeardownOption so the comment reads "GetTeardownOptions returns options
for tearing down this phase." and ensure the comment stays associated with the
GetTeardownOptions declaration.

---

Nitpick comments:
In `@machinery/types/types.go`:
- Around line 109-120: The current builder methods WithReconcileOptions and
WithTeardownOptions overwrite existing slices (p.ReconcileOptions and
p.TeardownOptions) instead of appending, so repeated calls clobber previous
options; to fix, either change each method to append new opts to the existing
slice (e.g., p.ReconcileOptions = append(p.ReconcileOptions, opts...)) and
likewise for p.TeardownOptions, or add new methods
AddReconcileOptions/AddTeardownOptions that perform the append and update the
PhaseBuilder interface and docs to expose the new behavior; update comments on
With* to document that they replace options if you keep the current behavior.

Comment on lines +63 to +75
// NewPhaseWithOwner creates a new PhaseBuilder with the given name, objects and owner.
func NewPhaseWithOwner(
name string, objects []client.Object,
owner client.Object, ownerStrat ownerStrategy,
) PhaseBuilder {
oo := WithOwner(owner, ownerStrat)
p := &phase{
Name: name,
Objects: objects,
}

return p.WithReconcileOptions(oo).WithTeardownOptions(oo)
}
Copy link

@coderabbitai coderabbitai bot Feb 17, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find ownerStrategy type definition and WithOwner function
echo "=== ownerStrategy type definition ==="
rg -n "type ownerStrategy" --type go

echo -e "\n=== WithOwner function definition ==="
rg -n "func WithOwner" --type go -A 5

echo -e "\n=== All ownerStrategy references in types.go ==="
rg -n "ownerStrategy" machinery/types/types.go

echo -e "\n=== Check for exported OwnerStrategy type ==="
rg -n "type OwnerStrategy" --type go

Repository: package-operator/boxcutter

Length of output: 993


🏁 Script executed:

#!/bin/bash
# Check the exported OwnerStrategy interface in boxcutter.go
echo "=== Exported OwnerStrategy in boxcutter.go ==="
sed -n '115,130p' boxcutter.go

# Check the unexported ownerStrategy interface in options.go
echo -e "\n=== Unexported ownerStrategy in machinery/types/options.go ==="
sed -n '373,385p' machinery/types/options.go

# Check if there's a conversion or relationship between them
echo -e "\n=== Check package of ownerStrategy definitions ==="
head -20 machinery/types/options.go | grep "^package"
head -20 boxcutter.go | grep "^package"

# Search for how external code is supposed to use NewPhaseWithOwner
echo -e "\n=== Check for usage examples or tests of NewPhaseWithOwner ==="
rg -n "NewPhaseWithOwner" --type go -B 2 -A 2 | head -40

Repository: package-operator/boxcutter

Length of output: 2704


Use exported OwnerStrategy interface instead of unexported ownerStrategy in function signature.

The NewPhaseWithOwner function is exported (via boxcutter.go), but its ownerStrat parameter accepts the unexported ownerStrategy interface from machinery/types/options.go. An exported OwnerStrategy interface with identical method signatures already exists in the boxcutter package. External consumers cannot call this public function because they cannot create values of the unexported ownerStrategy type. The function signature should accept the exported OwnerStrategy type to make the API usable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@machinery/types/types.go` around lines 63 - 75, The exported function
NewPhaseWithOwner currently accepts the unexported ownerStrategy type which
prevents external callers from passing a strategy; change the parameter type
from ownerStrategy to the exported OwnerStrategy interface (use the
OwnerStrategy defined in the boxcutter package) and update any references where
ownerStrat is passed (e.g. the call to WithOwner(owner, ownerStrat)) so
WithOwner/WithReconcileOptions/WithTeardownOptions receive the exported
OwnerStrategy; keep the rest of the function (creation of phase and chaining of
WithReconcileOptions/WithTeardownOptions) unchanged and ensure imports include
the package that defines OwnerStrategy.

Copy link
Member

Choose a reason for hiding this comment

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

Valid point

Copy link

Choose a reason for hiding this comment

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

@erdii, thank you for confirming! Let me know if you'd like any assistance with the refactoring.

Copy link

@mdbooth mdbooth left a comment

Choose a reason for hiding this comment

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

I think there are some bugs to work out in the PR, but I very much like this approach. I haven't reviewed the new tests, but I wonder if the cases I've highlighted can be covered somewhere?

I think this would work for what I want to do in the CAPI operator. Thanks for doing this 🙏

Name string
// Owner object will be added as OwnerReference
// to all objects managed by this revision.
Owner client.Object
Copy link

Choose a reason for hiding this comment

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

👌

}

// GetOwner returns the owning object.
func (r *Revision) GetOwner() client.Object {
Copy link

Choose a reason for hiding this comment

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

👌

Comment on lines 429 to 436
options.OwnerStrategy.CopyOwnerReferences(actualObject, desiredObject)
options.OwnerStrategy.ReleaseController(desiredObject)

if err := e.ownerStrategy.SetControllerReference(
owner, desiredObject,
if err := options.OwnerStrategy.SetControllerReference(
options.Owner, desiredObject,
); err != nil {
return nil, fmt.Errorf("set controller reference: %w", err)
}
Copy link

Choose a reason for hiding this comment

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

Needs a guard (everything except setObjectRevision)

err error
)
if options.Owner == nil {
ctrlSit = ctrlSituationIsController
Copy link

Choose a reason for hiding this comment

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

I think this is not quite right because we lose conflict detection with unmanaged objects.

How about if we inspect isBoxcutterManager() and return ctrlSituationUnknownController if it's not? Or the revision annotation.

// Remove us from owners list:
patch := actualObject.DeepCopyObject().(Object)
e.ownerStrategy.RemoveOwner(owner, patch)
var ctrlSit ctrlSituation
Copy link

Choose a reason for hiding this comment

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

nit: I don't think ctrlSituation is used outside of the block below. You could move it in there so the reader doesn't have to check for use of an uninitialised value.

Comment on lines 143 to 152
if options.Owner != nil {
ctrlSit, _ = e.detectOwner(options.Owner, options.OwnerStrategy, actualObject, nil)
if actualRevision != revision || ctrlSit != ctrlSituationIsController {
// Remove us from owners list:
patch := actualObject.DeepCopyObject().(Object)
options.OwnerStrategy.RemoveOwner(options.Owner, patch)

return true, e.writer.Patch(ctx, patch, client.MergeFrom(actualObject))
return true, e.writer.Patch(ctx, patch, client.MergeFrom(actualObject))
}
}
Copy link

Choose a reason for hiding this comment

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

I think this is wrong. I think we still need the revision check even if we don't have an owner, otherwise we're going to delete objects which are still in use by a newer revision. Is the owner check actually doing anything for us, here? Could we simplify this to just the revision index check:

Suggested change
if options.Owner != nil {
ctrlSit, _ = e.detectOwner(options.Owner, options.OwnerStrategy, actualObject, nil)
if actualRevision != revision || ctrlSit != ctrlSituationIsController {
// Remove us from owners list:
patch := actualObject.DeepCopyObject().(Object)
options.OwnerStrategy.RemoveOwner(options.Owner, patch)
return true, e.writer.Patch(ctx, patch, client.MergeFrom(actualObject))
return true, e.writer.Patch(ctx, patch, client.MergeFrom(actualObject))
}
}
// Object is not owned by this revision
if actualRevision != revision {
if options.Owner == nil {
return true, nil
} else {
// Remove us from owners list:
patch := actualObject.DeepCopyObject().(Object)
options.OwnerStrategy.RemoveOwner(options.Owner, patch)
return true, e.writer.Patch(ctx, patch, client.MergeFrom(actualObject))
}
}

Comment on lines 101 to 112
if options.Owner != nil {
// Shortcut when Owner is orphaning its dependents.
// If we don't check this, we might be too quick and start deleting
// dependents that should be kept on the cluster!
if controllerutil.ContainsFinalizer(options.Owner, "orphan") || options.Orphan {
err := removeBoxcutterManagedLabel(ctx, e.writer, desiredObject.(*unstructured.Unstructured))
if err != nil {
return false, err
}

// Shortcut when Owner is orphaning its dependents.
// If we don't check this, we might be too quick and start deleting
// dependents that should be kept on the cluster!
if controllerutil.ContainsFinalizer(owner, "orphan") || options.Orphan {
err := removeBoxcutterManagedLabel(ctx, e.writer, desiredObject.(*unstructured.Unstructured))
if err != nil {
return false, err
return true, nil
}
Copy link

Choose a reason for hiding this comment

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

We still need to be able to orphan objects when not using an Owner.

Copy link

Choose a reason for hiding this comment

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

I don't want to lose track of this one, btw. We have a specific use case in our installer where another actor can designate a CRD 'unmanaged'. If a CRD is added to the unmanaged set we will create a new revision which excludes it. However, we MUST NOT delete unmanaged CRDs, even though they are no longer in the current revision.

I'm anticipating that when calling Teardown we will pass a WithOrphan() option specific to each individual unmanaged CRD object.

Copy link

@mdbooth mdbooth left a comment

Choose a reason for hiding this comment

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

Thanks. I'll look into writing some test cases.

Comment on lines 101 to 112
if options.Owner != nil {
// Shortcut when Owner is orphaning its dependents.
// If we don't check this, we might be too quick and start deleting
// dependents that should be kept on the cluster!
if controllerutil.ContainsFinalizer(options.Owner, "orphan") || options.Orphan {
err := removeBoxcutterManagedLabel(ctx, e.writer, desiredObject.(*unstructured.Unstructured))
if err != nil {
return false, err
}

// Shortcut when Owner is orphaning its dependents.
// If we don't check this, we might be too quick and start deleting
// dependents that should be kept on the cluster!
if controllerutil.ContainsFinalizer(owner, "orphan") || options.Orphan {
err := removeBoxcutterManagedLabel(ctx, e.writer, desiredObject.(*unstructured.Unstructured))
if err != nil {
return false, err
return true, nil
}
Copy link

Choose a reason for hiding this comment

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

I don't want to lose track of this one, btw. We have a specific use case in our installer where another actor can designate a CRD 'unmanaged'. If a CRD is added to the unmanaged set we will create a new revision which excludes it. However, we MUST NOT delete unmanaged CRDs, even though they are no longer in the current revision.

I'm anticipating that when calling Teardown we will pass a WithOrphan() option specific to each individual unmanaged CRD object.

options.OwnerStrategy.CopyOwnerReferences(actualObject, desiredObject)
options.OwnerStrategy.ReleaseController(desiredObject)
if options.Owner != nil {
e.setObjectRevision(desiredObject, revision)
Copy link

Choose a reason for hiding this comment

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

setObjectRevision() needs to move outside the guard.

thetechnick and others added 2 commits February 18, 2026 14:07
Improves test coverage from 84.1% to 86.9% by adding tests for:
- Reconcile without owner (boxcutter-managed and unmanaged cases)
- Paused mode behavior (create/apply skipped, recovery scenarios)
- CollisionProtectionNone edge cases
- Error handling paths (cache errors, revision parsing, migration)
- Teardown scenarios (NoMatchError, orphan finalizers)

Notable coverage improvements:
- checkSituation: 33.3% → 86.7%
- objectUpdateHandling: 82.9% → 90.2%
- Teardown: 82.5% → 87.5%
- Reconcile: 80.6% → 83.9%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
opt.ApplyToObjectReconcileOptions(&objectOptions)
}

return objectOptions.Owner
Copy link
Member

Choose a reason for hiding this comment

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

While I support the general API changes of this PR, that's a very ineffective way to get the owner.
I don't have anything better in mind for now though.

Comment on lines +128 to +130
if opts.Owner != nil && opts.OwnerStrategy == nil {
panic("Owner without ownerStrategy set")
}
Copy link
Member

Choose a reason for hiding this comment

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

Since Owner and OwnerStrategy belong together, they should be colocated in a new struct (eg type OwnerAndStrategy struct{} under a shared key of opts.

mdbooth and others added 4 commits February 24, 2026 12:25
* Consolidates Reconcile and Teardown tests into table-driven
  TestObjectEngine and TestObjectEngine_Teardown where possible
* Moves to a builder pattern for test fixtures
* Executes all Reconcile and Teardown tests in both 'owner' and 'without
  owner' modes where this is meaningful

In general, tests where:
* Ownership is not relevant
* differentiate only 'managed' vs 'no managed'
run in both modes. This is most tests.

Tests which exercise the concept of having a different (as opposed to
no) controller run only in 'owner' mode, because they have no
equivalent. This is specified via the optional 'modes' field on the
tests.

Notable coverage improvements:
- Teardown: 87.2% → 94.9%

No coverage has been lost.
This should return NoController rathern than UnknownController.
Refactor Reconcile tests to run with and without owner

// Preflight
err := e.phaseValidator.Validate(ctx, owner, phase)
err := e.phaseValidator.Validate(ctx, phase, opts...)
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want just opts here or opts + phase.GetReconciliationOptions() ?

Objects: objects,
}

return p.WithReconcileOptions(oo).WithTeardownOptions(oo)
Copy link
Contributor

Choose a reason for hiding this comment

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

We should be careful here, probably. If I do:

p := NewPhaseWithOwner(...).WithReconcileOptions(...)

We might override the WithOwner options, since:

func (p *phase) WithReconcileOptions(opts ...PhaseReconcileOption) PhaseBuilder {
	p.ReconcileOptions = opts
	return p
}

right?

func (opts *ObjectReconcileOptions) Default() {
if opts.Owner != nil && opts.OwnerStrategy == nil {
panic("Owner without ownerStrategy set")
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Also, should we do the same check in ObjectTeardownOptions Default()?

ObjectReconcileOption
ObjectTeardownOption
} {
if len(obj.GetUID()) == 0 {
Copy link
Contributor

@perdasilva perdasilva Feb 25, 2026

Choose a reason for hiding this comment

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

is it worth doing a nil check on obj?


func (e *ObjectEngine) isBoxcutterManaged(obj client.Object) bool {
for k := range obj.GetLabels() {
if strings.HasPrefix(k, boxcutterManagedLabel) {
Copy link
Contributor

@perdasilva perdasilva Feb 25, 2026

Choose a reason for hiding this comment

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

is HasPrefix the right choice here? We could be matching things we don't mean to maybe a full comparison?

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.

4 participants