diff --git a/commands/alpha/live/plan/command.go b/commands/alpha/live/plan/command.go index 886face464..843958831c 100644 --- a/commands/alpha/live/plan/command.go +++ b/commands/alpha/live/plan/command.go @@ -133,7 +133,7 @@ func (r *Runner) RunE(c *cobra.Command, args []string) error { } // Create and execute the planner. - planner, err := kptplanner.NewClusterPlanner(r.factory) + planner, err := kptplanner.NewClusterPlannerWithContext(r.ctx, r.factory) if err != nil { return err } diff --git a/commands/live/apply/cmdapply.go b/commands/live/apply/cmdapply.go index 2c5ea3e0c1..70ed253c77 100644 --- a/commands/live/apply/cmdapply.go +++ b/commands/live/apply/cmdapply.go @@ -228,7 +228,7 @@ func runApply(r *Runner, invInfo inventory.Info, objs []*unstructured.Unstructur if err = cmdutil.InstallResourceGroupCRD(r.ctx, f); err != nil { return err } - } else if !live.ResourceGroupCRDMatched(f) { + } else if !live.ResourceGroupCRDMatchedWithContext(r.ctx, f) { if err = cmdutil.InstallResourceGroupCRD(r.ctx, f); err != nil { return &cmdutil.ResourceGroupCRDNotLatestError{ Err: err, @@ -239,7 +239,7 @@ func runApply(r *Runner, invInfo inventory.Info, objs []*unstructured.Unstructur // Run the applier. It will return a channel where we can receive updates // to keep track of progress and any issues. - invClient, err := inventory.NewClient(r.factory, live.WrapInventoryObj, live.InvToUnstructuredFunc, r.statusPolicy, live.ResourceGroupGVK) + invClient, err := inventory.NewClient(r.factory, live.WrapInventoryObjWithContext(r.ctx), live.InvToUnstructuredFunc, r.statusPolicy, live.ResourceGroupGVK) if err != nil { return err } diff --git a/commands/live/destroy/cmddestroy.go b/commands/live/destroy/cmddestroy.go index bc274f2578..9bee3f94b4 100644 --- a/commands/live/destroy/cmddestroy.go +++ b/commands/live/destroy/cmddestroy.go @@ -168,7 +168,7 @@ func (r *Runner) runE(c *cobra.Command, args []string) error { func runDestroy(r *Runner, inv inventory.Info, dryRunStrategy common.DryRunStrategy) error { // Run the destroyer. It will return a channel where we can receive updates // to keep track of progress and any issues. - invClient, err := inventory.NewClient(r.factory, live.WrapInventoryObj, live.InvToUnstructuredFunc, r.statusPolicy, live.ResourceGroupGVK) + invClient, err := inventory.NewClient(r.factory, live.WrapInventoryObjWithContext(r.ctx), live.InvToUnstructuredFunc, r.statusPolicy, live.ResourceGroupGVK) if err != nil { return err } diff --git a/commands/live/livecmd.go b/commands/live/livecmd.go index fea734d7cb..e177668bad 100644 --- a/commands/live/livecmd.go +++ b/commands/live/livecmd.go @@ -47,7 +47,7 @@ func GetCommand(ctx context.Context, _, version string) *cobra.Command { } f := util.NewFactory(liveCmd, version) - invFactory := live.NewClusterClientFactory() + invFactory := live.NewClusterClientFactoryWithContext(ctx) loader := status.NewRGInventoryLoader(ctx, f) // Init command which updates a Kptfile for the ResourceGroup inventory object. diff --git a/commands/live/migrate/migratecmd.go b/commands/live/migrate/migratecmd.go index 616e228898..874d2c66fe 100644 --- a/commands/live/migrate/migratecmd.go +++ b/commands/live/migrate/migratecmd.go @@ -58,8 +58,8 @@ type Runner struct { name string rgFile string force bool - rgInvClientFunc func(util.Factory) (inventory.Client, error) - cmInvClientFunc func(util.Factory) (inventory.Client, error) + rgInvClientFunc func(context.Context, util.Factory) (inventory.Client, error) + cmInvClientFunc func(context.Context, util.Factory) (inventory.Client, error) cmLoader manifestreader.ManifestLoader cmNotMigrated bool // flag to determine if migration from ConfigMap has occurred } @@ -348,11 +348,11 @@ func validateParams(reader io.Reader, args []string) error { return nil } -func rgInvClient(factory util.Factory) (inventory.Client, error) { - return inventory.NewClient(factory, live.WrapInventoryObj, live.InvToUnstructuredFunc, inventory.StatusPolicyAll, live.ResourceGroupGVK) +func rgInvClient(ctx context.Context, factory util.Factory) (inventory.Client, error) { + return inventory.NewClient(factory, live.WrapInventoryObjWithContext(ctx), live.InvToUnstructuredFunc, inventory.StatusPolicyAll, live.ResourceGroupGVK) } -func cmInvClient(factory util.Factory) (inventory.Client, error) { +func cmInvClient(_ context.Context, factory util.Factory) (inventory.Client, error) { return inventory.NewClient(factory, inventory.WrapInventoryObj, inventory.InvInfoToConfigMap, inventory.StatusPolicyAll, live.ResourceGroupGVK) } @@ -412,11 +412,11 @@ func (mr *Runner) migrateKptfileToRG(args []string) error { func (mr *Runner) migrateCMToRG(stdinBytes []byte, args []string) error { // Create the inventory clients for reading inventories based on RG and // ConfigMap. - rgInvClient, err := mr.rgInvClientFunc(mr.factory) + rgInvClient, err := mr.rgInvClientFunc(mr.ctx, mr.factory) if err != nil { return err } - cmInvClient, err := mr.cmInvClientFunc(mr.factory) + cmInvClient, err := mr.cmInvClientFunc(mr.ctx, mr.factory) if err != nil { return err } diff --git a/commands/live/migrate/migratecmd_test.go b/commands/live/migrate/migratecmd_test.go index b6dbadda80..51273b01aa 100644 --- a/commands/live/migrate/migratecmd_test.go +++ b/commands/live/migrate/migratecmd_test.go @@ -15,6 +15,7 @@ package migrate import ( + "context" "os" "path/filepath" "strings" @@ -169,7 +170,7 @@ func TestKptMigrate_migrateKptfileToRG(t *testing.T) { migrateRunner := NewRunner(ctx, tf, cmLoader, ioStreams) migrateRunner.dryRun = tc.dryRun migrateRunner.rgFile = tc.rgFilename - migrateRunner.cmInvClientFunc = func(_ util.Factory) (inventory.Client, error) { + migrateRunner.cmInvClientFunc = func(_ context.Context, _ util.Factory) (inventory.Client, error) { return inventory.NewFakeClient([]object.ObjMetadata{}), nil } err = migrateRunner.migrateKptfileToRG([]string{dir}) @@ -247,7 +248,7 @@ func TestKptMigrate_retrieveConfigMapInv(t *testing.T) { // Create MigrateRunner and call "retrieveConfigMapInv" cmLoader := manifestreader.NewManifestLoader(tf) migrateRunner := NewRunner(ctx, tf, cmLoader, ioStreams) - migrateRunner.cmInvClientFunc = func(_ util.Factory) (inventory.Client, error) { + migrateRunner.cmInvClientFunc = func(_ context.Context, _ util.Factory) (inventory.Client, error) { return inventory.NewFakeClient([]object.ObjMetadata{}), nil } actual, err := migrateRunner.retrieveConfigMapInv(strings.NewReader(tc.configMap), []string{"-"}) diff --git a/pkg/live/inventory-client-factory.go b/pkg/live/inventory-client-factory.go index 74ded93db7..eaf01f9cc0 100644 --- a/pkg/live/inventory-client-factory.go +++ b/pkg/live/inventory-client-factory.go @@ -1,4 +1,4 @@ -// Copyright 2022 The kpt Authors +// Copyright 2022,2026 The kpt Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,18 +15,53 @@ package live import ( + "context" + cmdutil "k8s.io/kubectl/pkg/cmd/util" "sigs.k8s.io/cli-utils/pkg/inventory" ) -// ClusterClientFactory is a factory that creates instances of ClusterClient inventory client. +// ClusterClientFactory is a factory that creates instances of ClusterClient +// inventory client. +// +// Ctx, if set, is plumbed into the InventoryResourceGroup wrapper so +// Apply / ApplyWithPrune honor caller cancellation (Ctrl-C, timeouts). +// The upstream inventory.ClientFactory interface's NewClient signature +// does not accept a context, so we carry one on the factory instead; +// construct via NewClusterClientFactoryWithContext when you have one. type ClusterClientFactory struct { StatusPolicy inventory.StatusPolicy + Ctx context.Context } +// NewClusterClientFactory returns a ClusterClientFactory that will build +// inventory clients with no context propagation (cluster API calls use +// context.Background()). Prefer NewClusterClientFactoryWithContext. func NewClusterClientFactory() *ClusterClientFactory { return &ClusterClientFactory{StatusPolicy: inventory.StatusPolicyNone} } + +// NewClusterClientFactoryWithContext returns a ClusterClientFactory that +// threads ctx into every inventory client it produces. +// +// A nil ctx is normalized to context.Background() so the docstring's +// promise ("threads ctx into every inventory client") holds for every +// input: there is no hidden code path that silently drops propagation. +func NewClusterClientFactoryWithContext(ctx context.Context) *ClusterClientFactory { + if ctx == nil { + ctx = context.Background() + } + return &ClusterClientFactory{StatusPolicy: inventory.StatusPolicyNone, Ctx: ctx} +} + func (ccf *ClusterClientFactory) NewClient(factory cmdutil.Factory) (inventory.Client, error) { - return inventory.NewClient(factory, WrapInventoryObj, InvToUnstructuredFunc, ccf.StatusPolicy, ResourceGroupGVK) + // Defense in depth: normalize a nil Ctx here too. This covers the + // case where a caller constructed ClusterClientFactory as a struct + // literal (e.g. &ClusterClientFactory{StatusPolicy: ...}) and left + // Ctx unset — see NewClusterClientFactory, which does exactly that. + ctx := ccf.Ctx + if ctx == nil { + ctx = context.Background() + } + return inventory.NewClient(factory, WrapInventoryObjWithContext(ctx), InvToUnstructuredFunc, ccf.StatusPolicy, ResourceGroupGVK) } diff --git a/pkg/live/inventoryrg.go b/pkg/live/inventoryrg.go index 5434d9abad..db417213ac 100644 --- a/pkg/live/inventoryrg.go +++ b/pkg/live/inventoryrg.go @@ -1,4 +1,4 @@ -// Copyright 2020 The kpt Authors +// Copyright 2020,2026 The kpt Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -59,12 +59,30 @@ var ResourceGroupGVK = schema.GroupVersionKind{ // InventoryResourceGroup wraps a ResourceGroup resource and implements // the Inventory and InventoryInfo interface. This wrapper loads and stores the // object metadata (inventory) to and from the wrapped ResourceGroup. +// +// ctx, if non-nil, is the caller's context and is used for the live +// Kubernetes API calls performed by Apply / ApplyWithPrune. It exists on +// the struct (rather than as a parameter) because the upstream +// inventory.Storage interface signatures do not include a context; see +// WrapInventoryObjWithContext. type InventoryResourceGroup struct { + ctx context.Context inv *unstructured.Unstructured objMetas []object.ObjMetadata objStatus []actuation.ObjectStatus } +// contextOrBackground returns the caller-supplied context if set, or +// context.Background() otherwise. Callers going through +// WrapInventoryObjWithContext get real cancellation/timeout propagation; +// legacy callers using WrapInventoryObj continue to work unchanged. +func (icm *InventoryResourceGroup) contextOrBackground() context.Context { + if icm.ctx != nil { + return icm.ctx + } + return context.Background() +} + func (icm *InventoryResourceGroup) Strategy() inventory.Strategy { return inventory.NameStrategy } @@ -74,7 +92,12 @@ var _ inventory.Info = &InventoryResourceGroup{} // WrapInventoryObj takes a passed ResourceGroup (as a resource.Info), // wraps it with the InventoryResourceGroup and upcasts the wrapper as -// an the Inventory interface. +// the Inventory interface. +// +// The wrapped inventory will use context.Background() for cluster API +// calls. Prefer WrapInventoryObjWithContext when you have a caller +// context (e.g. cmd.Context()) so that Ctrl-C and caller-side timeouts +// actually cancel the in-flight request. func WrapInventoryObj(obj *unstructured.Unstructured) inventory.Storage { if obj != nil { klog.V(4).Infof("wrapping Inventory obj: %s/%s\n", obj.GetNamespace(), obj.GetName()) @@ -82,6 +105,27 @@ func WrapInventoryObj(obj *unstructured.Unstructured) inventory.Storage { return &InventoryResourceGroup{inv: obj} } +// WrapInventoryObjWithContext returns a wrapper function compatible with +// inventory.NewClient's WrapObjFunc parameter. The returned function +// produces an InventoryResourceGroup that carries ctx, so subsequent +// Apply / ApplyWithPrune calls honor the caller's cancellation and +// timeout. +// +// If ctx is nil it is normalized to context.Background() so callers +// cannot accidentally trigger a nil-deref inside client-go. Prefer +// passing a real ctx (e.g. cmd.Context()) to actually gain cancellation. +func WrapInventoryObjWithContext(ctx context.Context) func(*unstructured.Unstructured) inventory.Storage { + if ctx == nil { + ctx = context.Background() + } + return func(obj *unstructured.Unstructured) inventory.Storage { + if obj != nil { + klog.V(4).Infof("wrapping Inventory obj with ctx: %s/%s\n", obj.GetNamespace(), obj.GetName()) + } + return &InventoryResourceGroup{ctx: ctx, inv: obj} + } +} + func WrapInventoryInfoObj(obj *unstructured.Unstructured) inventory.Info { if obj != nil { klog.V(4).Infof("wrapping InventoryInfo obj: %s/%s\n", obj.GetNamespace(), obj.GetName()) @@ -256,9 +300,10 @@ func (icm *InventoryResourceGroup) Apply(dc dynamic.Interface, mapper meta.RESTM if err != nil { return err } + ctx := icm.contextOrBackground() - // Get cluster object, if exsists. - clusterObj, err := namespacedClient.Get(context.TODO(), invInfo.GetName(), metav1.GetOptions{}) + // Get cluster object, if exists. + clusterObj, err := namespacedClient.Get(ctx, invInfo.GetName(), metav1.GetOptions{}) if err != nil && !apierrors.IsNotFound(err) { return err } @@ -267,10 +312,10 @@ func (icm *InventoryResourceGroup) Apply(dc dynamic.Interface, mapper meta.RESTM if clusterObj == nil { // Create cluster inventory object, if it does not exist on cluster. - appliedObj, err = namespacedClient.Create(context.TODO(), invInfo, metav1.CreateOptions{}) + appliedObj, err = namespacedClient.Create(ctx, invInfo, metav1.CreateOptions{}) } else { // Update the cluster inventory object instead. - appliedObj, err = namespacedClient.Update(context.TODO(), invInfo, metav1.UpdateOptions{}) + appliedObj, err = namespacedClient.Update(ctx, invInfo, metav1.UpdateOptions{}) } if err != nil { return err @@ -279,7 +324,7 @@ func (icm *InventoryResourceGroup) Apply(dc dynamic.Interface, mapper meta.RESTM // Update status. if statusPolicy == inventory.StatusPolicyAll { invInfo.SetResourceVersion(appliedObj.GetResourceVersion()) - _, err = namespacedClient.UpdateStatus(context.TODO(), invInfo, metav1.UpdateOptions{}) + _, err = namespacedClient.UpdateStatus(ctx, invInfo, metav1.UpdateOptions{}) } return err @@ -290,11 +335,12 @@ func (icm *InventoryResourceGroup) ApplyWithPrune(dc dynamic.Interface, mapper m if err != nil { return err } + ctx := icm.contextOrBackground() // Update the cluster inventory object. // Since the ResourceGroup CRD specifies the status as a sub-resource, this // will not update the status. - appliedObj, err := namespacedClient.Update(context.TODO(), invInfo, metav1.UpdateOptions{}) + appliedObj, err := namespacedClient.Update(ctx, invInfo, metav1.UpdateOptions{}) if err != nil { return err } @@ -314,7 +360,7 @@ func (icm *InventoryResourceGroup) ApplyWithPrune(dc dynamic.Interface, mapper m if err != nil { return err } - _, err = namespacedClient.UpdateStatus(context.TODO(), appliedObj, metav1.UpdateOptions{}) + _, err = namespacedClient.UpdateStatus(ctx, appliedObj, metav1.UpdateOptions{}) if err != nil { return err } @@ -386,7 +432,23 @@ func ResourceGroupCRDApplied(factory cmdutil.Factory) bool { // ResourceGroupCRDMatched checks if the ResourceGroup CRD // in the cluster matches the CRD in the kpt binary. +// +// This signature is preserved for backward compatibility with external +// callers; it delegates to ResourceGroupCRDMatchedWithContext with +// context.Background(). Prefer ResourceGroupCRDMatchedWithContext when +// you have a caller context so Ctrl-C and timeouts can abort the check. func ResourceGroupCRDMatched(factory cmdutil.Factory) bool { + return ResourceGroupCRDMatchedWithContext(context.Background(), factory) +} + +// ResourceGroupCRDMatchedWithContext is the context-aware variant of +// ResourceGroupCRDMatched. ctx is used for the live cluster Get call; +// cancelling it (e.g. via Ctrl-C or a command-level timeout) aborts the +// check. A nil ctx is normalized to context.Background(). +func ResourceGroupCRDMatchedWithContext(ctx context.Context, factory cmdutil.Factory) bool { + if ctx == nil { + ctx = context.Background() + } mapper, err := factory.ToRESTMapper() if err != nil { klog.V(4).Infof("error retrieving RESTMapper when checking ResourceGroup CRD: %s\n", err) @@ -410,7 +472,7 @@ func ResourceGroupCRDMatched(factory cmdutil.Factory) bool { return false } - liveCRD, err := dc.Resource(mapping.Resource).Get(context.TODO(), "resourcegroups.kpt.dev", metav1.GetOptions{ + liveCRD, err := dc.Resource(mapping.Resource).Get(ctx, "resourcegroups.kpt.dev", metav1.GetOptions{ TypeMeta: metav1.TypeMeta{ APIVersion: crd.GetAPIVersion(), Kind: "CustomResourceDefinition", diff --git a/pkg/live/inventoryrg_test.go b/pkg/live/inventoryrg_test.go index 9e72668732..b7b62a88c2 100644 --- a/pkg/live/inventoryrg_test.go +++ b/pkg/live/inventoryrg_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The kpt Authors +// Copyright 2020,2026 The kpt Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ package live import ( + "context" "testing" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + cmdutil "k8s.io/kubectl/pkg/cmd/util" "sigs.k8s.io/cli-utils/pkg/apis/actuation" "sigs.k8s.io/cli-utils/pkg/common" "sigs.k8s.io/cli-utils/pkg/inventory" @@ -240,3 +242,118 @@ func TestIsResourceGroupInventory(t *testing.T) { }) } } + +// TestWrapInventoryObjWithContext_StoresContext proves the new factory +// threads the caller's context into the InventoryResourceGroup struct. +// This is the mechanism the Apply / ApplyWithPrune methods use to honor +// Ctrl-C / caller timeouts instead of the old context.TODO() behavior. +func TestWrapInventoryObjWithContext_StoresContext(t *testing.T) { + type ctxKey struct{} + ctx := context.WithValue(context.Background(), ctxKey{}, "propagated") + + storage := WrapInventoryObjWithContext(ctx)(inventoryObj) + icm, ok := storage.(*InventoryResourceGroup) + if !ok { + t.Fatalf("WrapInventoryObjWithContext produced unexpected type %T", storage) + } + if icm.ctx == nil { + t.Fatal("expected ctx on InventoryResourceGroup; got nil") + } + if got := icm.ctx.Value(ctxKey{}); got != "propagated" { + t.Fatalf("expected stored ctx to carry propagated value; got %v", got) + } +} + +// TestWrapInventoryObj_LeavesContextNil confirms the legacy wrapper keeps +// ctx nil so contextOrBackground falls back to context.Background() — +// preserving the pre-refactor behavior for callers that haven't migrated. +func TestWrapInventoryObj_LeavesContextNil(t *testing.T) { + storage := WrapInventoryObj(inventoryObj) + icm, ok := storage.(*InventoryResourceGroup) + if !ok { + t.Fatalf("WrapInventoryObj produced unexpected type %T", storage) + } + if icm.ctx != nil { + t.Fatalf("expected legacy wrapper to leave ctx nil; got %v", icm.ctx) + } +} + +// TestWrapInventoryObjWithContext_NilCtxDefaultsToBackground proves the +// factory is nil-safe: passing a nil ctx cannot produce a wrapper that +// would nil-deref inside client-go. The stored ctx is normalized to +// context.Background() at factory construction time. +func TestWrapInventoryObjWithContext_NilCtxDefaultsToBackground(t *testing.T) { + //nolint:staticcheck // SA1012: deliberately passing a nil context to exercise the nil-safety guard. + storage := WrapInventoryObjWithContext(nil)(inventoryObj) + icm, ok := storage.(*InventoryResourceGroup) + if !ok { + t.Fatalf("WrapInventoryObjWithContext(nil) produced unexpected type %T", storage) + } + if icm.ctx == nil { + t.Fatal("expected nil ctx to be normalized to Background(); got nil") + } + // Background() never cancels; Done() returns a nil channel. + if icm.ctx.Done() != nil { + t.Fatalf("expected Background()-equivalent ctx; Done() returned non-nil") + } +} + +// TestResourceGroupCRDMatched_BackCompatSignaturePreserved is a +// compile-time guard that the legacy ResourceGroupCRDMatched(factory) +// signature is still exported, alongside the new context-aware +// ResourceGroupCRDMatchedWithContext(ctx, factory). If either function +// is renamed, removed, or has its signature changed, this test stops +// compiling and the API-compat break is visible immediately. +// +// Uses typed anonymous-function parameters so the compiler verifies +// signature assignability. This pattern is deliberate — staticcheck's +// QF1011 would otherwise suggest removing a `var _ T = fn` type +// annotation, which would silently destroy the guarantee. +// +// We don't invoke the functions because both require a live +// cmdutil.Factory; their runtime behavior is exercised by the +// apply/destroy e2e tests. +func TestResourceGroupCRDMatched_BackCompatSignaturePreserved(t *testing.T) { + pinSignatures := func( + _ func(cmdutil.Factory) bool, + _ func(context.Context, cmdutil.Factory) bool, + ) { + } + pinSignatures(ResourceGroupCRDMatched, ResourceGroupCRDMatchedWithContext) +} + +// TestContextOrBackground covers both the override path (caller-supplied +// ctx is returned verbatim, including cancellation state) and the +// fallback path (nil ctx becomes context.Background()). +func TestContextOrBackground(t *testing.T) { + t.Run("returns stored ctx when set", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + icm := &InventoryResourceGroup{ctx: ctx} + + got := icm.contextOrBackground() + if got != ctx { + t.Fatalf("expected contextOrBackground to return the stored ctx") + } + // Cancellation on the original ctx must be visible through the + // returned ctx — proof the value isn't copied or unwrapped. + cancel() + select { + case <-got.Done(): + // expected + default: + t.Fatalf("returned ctx did not observe cancellation of the stored ctx") + } + }) + + t.Run("falls back to Background when nil", func(t *testing.T) { + icm := &InventoryResourceGroup{} + got := icm.contextOrBackground() + if got == nil { + t.Fatal("contextOrBackground returned nil; expected context.Background()") + } + // Background() never cancels; Done() returns a nil channel. + if got.Done() != nil { + t.Fatalf("expected Background-equivalent ctx; Done channel was not nil") + } + }) +} diff --git a/pkg/live/planner/cluster.go b/pkg/live/planner/cluster.go index 8a1760be2a..e537c2b6aa 100644 --- a/pkg/live/planner/cluster.go +++ b/pkg/live/planner/cluster.go @@ -47,13 +47,29 @@ type ClusterPlanner struct { resourceFetcher ResourceFetcher } +// NewClusterPlanner builds a ClusterPlanner using context.Background() +// for the underlying inventory-client cluster calls. +// +// This signature is preserved for backward compatibility with external +// callers; it delegates to NewClusterPlannerWithContext. Prefer the +// context-aware constructor when you have a caller context so Ctrl-C +// and command-level timeouts can cancel inventory I/O. func NewClusterPlanner(f util.Factory) (*ClusterPlanner, error) { + return NewClusterPlannerWithContext(context.Background(), f) +} + +// NewClusterPlannerWithContext is the context-aware variant of +// NewClusterPlanner. ctx is plumbed into the inventory wrapper so +// Apply / ApplyWithPrune on the underlying ResourceGroup honor caller +// cancellation (Ctrl-C, deadlines). A nil ctx is normalized to +// context.Background() by WrapInventoryObjWithContext. +func NewClusterPlannerWithContext(ctx context.Context, f util.Factory) (*ClusterPlanner, error) { fetcher, err := NewResourceFetcher(f) if err != nil { return nil, err } - invClient, err := inventory.NewClient(f, live.WrapInventoryObj, live.InvToUnstructuredFunc, inventory.StatusPolicyNone, live.ResourceGroupGVK) + invClient, err := inventory.NewClient(f, live.WrapInventoryObjWithContext(ctx), live.InvToUnstructuredFunc, inventory.StatusPolicyNone, live.ResourceGroupGVK) if err != nil { return nil, err } diff --git a/pkg/live/planner/cluster_test.go b/pkg/live/planner/cluster_test.go index 71699c38b5..bdd5f2d280 100644 --- a/pkg/live/planner/cluster_test.go +++ b/pkg/live/planner/cluster_test.go @@ -21,6 +21,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/kubectl/pkg/cmd/util" "sigs.k8s.io/cli-utils/pkg/apply" "sigs.k8s.io/cli-utils/pkg/apply/event" "sigs.k8s.io/cli-utils/pkg/inventory" @@ -166,3 +167,27 @@ func (fii *FakeInventoryInfo) ID() string { func (fii *FakeInventoryInfo) Strategy() inventory.Strategy { return inventory.NameStrategy } + +// TestNewClusterPlanner_BackCompatSignaturePreserved is a compile-time +// guard that both the legacy NewClusterPlanner(f) entry point and the +// new context-aware NewClusterPlannerWithContext(ctx, f) remain +// exported with their current signatures. If either is renamed, +// removed, or has its parameter list changed, this test stops +// compiling and the API-compat break is visible immediately. +// +// Uses typed anonymous-function parameters so the compiler verifies +// signature assignability. This pattern is deliberate — staticcheck's +// QF1011 would otherwise suggest removing a `var _ T = fn` type +// annotation, which would silently destroy the guarantee. +// +// Runtime behavior of both constructors is exercised through the +// command-level tests that instantiate the planner via the real +// factory in commands/alpha/live/plan. +func TestNewClusterPlanner_BackCompatSignaturePreserved(t *testing.T) { + pinSignatures := func( + _ func(util.Factory) (*ClusterPlanner, error), + _ func(context.Context, util.Factory) (*ClusterPlanner, error), + ) { + } + pinSignatures(NewClusterPlanner, NewClusterPlannerWithContext) +}