diff --git a/cmd/export/crd.go b/cmd/export/crd.go index 245d59c..a018b81 100644 --- a/cmd/export/crd.go +++ b/cmd/export/crd.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/konveyor/crane-lib/apigroups" "github.com/sirupsen/logrus" @@ -85,7 +86,7 @@ func getOperatorManager(obj *unstructured.Unstructured) string { // API types that appear in resources (deduplicated by plural.group). Built-in API // groups are skipped. Failed GETs are returned as groupResourceError entries for // the same failures directory as list errors. -func collectRelatedCRDs(resources []*groupResource, dynamicClient dynamic.Interface, log logrus.FieldLogger, userSkipGroups, userIncludeGroups []string) ([]*groupResource, []*groupResourceError) { +func collectRelatedCRDs(requestTimeout time.Duration, resources []*groupResource, dynamicClient dynamic.Interface, log logrus.FieldLogger, userSkipGroups, userIncludeGroups []string) ([]*groupResource, []*groupResourceError) { skipSet := normalizeGroupSet(userSkipGroups) includeSet := normalizeGroupSet(userIncludeGroups) @@ -112,7 +113,16 @@ func collectRelatedCRDs(resources []*groupResource, dynamicClient dynamic.Interf out := make([]*groupResource, 0, len(seen)) var outErrs []*groupResourceError for crdName := range seen { - obj, err := crdClient.Get(context.Background(), crdName, metav1.GetOptions{}) + // Create fresh context with timeout for each CRD Get request + ctx := context.Background() + var cancel context.CancelFunc + if requestTimeout > 0 { + ctx, cancel = context.WithTimeout(ctx, requestTimeout) + } + obj, err := crdClient.Get(ctx, crdName, metav1.GetOptions{}) + if cancel != nil { + cancel() + } if err != nil { switch { case apierrors.IsForbidden(err): @@ -134,7 +144,7 @@ func collectRelatedCRDs(resources []*groupResource, dynamicClient dynamic.Interf }) continue } - + if manager := getOperatorManager(obj); manager != "" { log.Warnf("Skipping CRD %q — managed by %s; install the operator on the target cluster instead", crdName, manager) continue diff --git a/cmd/export/crd_test.go b/cmd/export/crd_test.go index 5d46b5a..b430cd4 100644 --- a/cmd/export/crd_test.go +++ b/cmd/export/crd_test.go @@ -9,6 +9,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/dynamic/fake" + kubetesting "k8s.io/client-go/testing" ) func TestShouldSkipCRDGroup_DefaultsAndOverrides(t *testing.T) { @@ -87,7 +88,7 @@ func TestCollectRelatedCRDs_customResourceOneCRD(t *testing.T) { client := fake.NewSimpleDynamicClient(scheme, crdUnstructured("widgets.example.com")) log := testLogger() - got, errs := collectRelatedCRDs([]*groupResource{widgetGroupResource()}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{widgetGroupResource()}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -120,7 +121,7 @@ func TestCollectRelatedCRDs_builtinGroupNoFetch(t *testing.T) { Items: []unstructured.Unstructured{{}}, }, } - got, errs := collectRelatedCRDs([]*groupResource{gr}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{gr}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -138,7 +139,7 @@ func TestCollectRelatedCRDs_dedupePluralGroup(t *testing.T) { w2 := widgetGroupResource() w2.objects.Items[0].SetName("w2") - got, errs := collectRelatedCRDs([]*groupResource{w1, w2}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{w1, w2}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -155,7 +156,7 @@ func TestCollectRelatedCRDs_skipsSubresourceName(t *testing.T) { gr := widgetGroupResource() gr.APIResource.Name = "widgets/status" - got, errs := collectRelatedCRDs([]*groupResource{gr}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{gr}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -186,7 +187,7 @@ func TestCollectRelatedCRDs_multipleDistinctCRDs(t *testing.T) { }, } - got, errs := collectRelatedCRDs([]*groupResource{widgetGroupResource(), gadget}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{widgetGroupResource(), gadget}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -205,7 +206,7 @@ func TestCollectRelatedCRDs_getFailureReturnsGroupResourceError(t *testing.T) { client := fake.NewSimpleDynamicClient(scheme) log := testLogger() - got, errs := collectRelatedCRDs([]*groupResource{widgetGroupResource()}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{widgetGroupResource()}, client, log, nil, nil) if len(got) != 0 { t.Fatalf("expected no CRD rows, got %d", len(got)) } @@ -242,7 +243,7 @@ func TestCollectRelatedCRDs_IncludeOverridesBuiltinGroup(t *testing.T) { }, } - got, errs := collectRelatedCRDs([]*groupResource{gr}, client, log, nil, []string{"route.openshift.io"}) + got, errs := collectRelatedCRDs(0, []*groupResource{gr}, client, log, nil, []string{"route.openshift.io"}) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -260,7 +261,7 @@ func TestCollectRelatedCRDs_skipsOLMManagedCRD(t *testing.T) { client := fake.NewSimpleDynamicClient(scheme, crd) log := testLogger() - got, errs := collectRelatedCRDs([]*groupResource{widgetGroupResource()}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{widgetGroupResource()}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -275,7 +276,7 @@ func TestCollectRelatedCRDs_skipsManagedByLabel(t *testing.T) { client := fake.NewSimpleDynamicClient(scheme, crd) log := testLogger() - got, errs := collectRelatedCRDs([]*groupResource{widgetGroupResource()}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{widgetGroupResource()}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -292,7 +293,7 @@ func TestCollectRelatedCRDs_skipsOperatorFrameworkAnnotation(t *testing.T) { client := fake.NewSimpleDynamicClient(scheme, crd) log := testLogger() - got, errs := collectRelatedCRDs([]*groupResource{widgetGroupResource()}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{widgetGroupResource()}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -307,7 +308,7 @@ func TestCollectRelatedCRDs_skipsOwnerReferenceCRD(t *testing.T) { client := fake.NewSimpleDynamicClient(scheme, crd) log := testLogger() - got, errs := collectRelatedCRDs([]*groupResource{widgetGroupResource()}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{widgetGroupResource()}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -322,7 +323,7 @@ func TestCollectRelatedCRDs_exportsUnmanagedCRD(t *testing.T) { client := fake.NewSimpleDynamicClient(scheme, crd) log := testLogger() - got, errs := collectRelatedCRDs([]*groupResource{widgetGroupResource()}, client, log, nil, nil) + got, errs := collectRelatedCRDs(0, []*groupResource{widgetGroupResource()}, client, log, nil, nil) if len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -331,6 +332,35 @@ func TestCollectRelatedCRDs_exportsUnmanagedCRD(t *testing.T) { } } +func TestCollectRelatedCRDs_timeoutError(t *testing.T) { + scheme := runtime.NewScheme() + client := fake.NewSimpleDynamicClient(scheme) + + // Simulate timeout error on CRD Get + client.PrependReactor("get", "customresourcedefinitions", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewTimeoutError("CRD get timeout", 1) + }) + + log := testLogger() + gr := widgetGroupResource() + + // Pass non-zero timeout + got, errs := collectRelatedCRDs(100, []*groupResource{gr}, client, log, nil, nil) + + if len(got) != 0 { + t.Fatalf("expected 0 CRDs on timeout, got %d", len(got)) + } + if len(errs) != 1 { + t.Fatalf("expected exactly 1 error, got %d: %v", len(errs), errs) + } + if !apierrors.IsTimeout(errs[0].Error) { + t.Fatalf("expected timeout error, got: %v", errs[0].Error) + } + if errs[0].APIResource.Kind != "CustomResourceDefinition" { + t.Fatalf("expected CRD kind in error, got: %s", errs[0].APIResource.Kind) + } +} + func TestGetOperatorManager(t *testing.T) { tests := []struct { name string diff --git a/cmd/export/discover.go b/cmd/export/discover.go index e5b07c0..b6f61b5 100644 --- a/cmd/export/discover.go +++ b/cmd/export/discover.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/sirupsen/logrus" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -240,7 +241,7 @@ func discoverPreferredResources( // resourceToExtract lists objects for each admitted API type in namespace (or cluster-wide // for allowed cluster-scoped kinds: ClusterRoleBinding, ClusterRole, SCC). It returns resources // with non-empty lists and a parallel slice of per-type list errors. -func resourceToExtract(namespace string, labelSelector string, dynamicClient dynamic.Interface, lists []*metav1.APIResourceList, log logrus.FieldLogger) ([]*groupResource, []*groupResourceError) { +func resourceToExtract(requestTimeout time.Duration, namespace string, labelSelector string, dynamicClient dynamic.Interface, lists []*metav1.APIResourceList, log logrus.FieldLogger) ([]*groupResource, []*groupResourceError) { resources := []*groupResource{} errors := []*groupResourceError{} @@ -277,8 +278,13 @@ func resourceToExtract(namespace string, labelSelector string, dynamicClient dyn APIResource: resource, } - objs, err := getObjects(g, namespace, labelSelector, dynamicClient, log) + objs, err := getObjects(requestTimeout, g, namespace, labelSelector, dynamicClient, log) if err != nil { + // Check if error is due to timeout/deadline exceeded - fail fast + if apierrors.IsTimeout(err) || strings.Contains(err.Error(), "context deadline exceeded") { + log.Errorf("request timeout exceeded for groupVersion %s, kind: %s: %v\n", g.APIGroupVersion, g.APIResource.Kind, err) + return nil, []*groupResourceError{{resource, err}} + } switch { case apierrors.IsForbidden(err): log.Debugf("access denied for groupVersion %s, kind: %s (expected for namespace-admin users)\n", g.APIGroupVersion, g.APIResource.Kind) @@ -318,17 +324,26 @@ func isAdmittedResource(gv schema.GroupVersion, resource metav1.APIResource) boo // getObjects lists objects for g using the dynamic client, with paging and optional // labelSelector. imagestreamtags and imagetags use per-item Get after List. -func getObjects(g *groupResource, namespace string, labelSelector string, d dynamic.Interface, logger logrus.FieldLogger) (*unstructured.UnstructuredList, error) { +func getObjects(requestTimeout time.Duration, g *groupResource, namespace string, labelSelector string, d dynamic.Interface, logger logrus.FieldLogger) (*unstructured.UnstructuredList, error) { c := d.Resource(schema.GroupVersionResource{ Group: g.APIGroup, Version: g.APIVersion, Resource: g.APIResource.Name, }) - p := pager.New(func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) { + p := pager.New(func(pagerCtx context.Context, opts metav1.ListOptions) (runtime.Object, error) { + // Create a fresh context with timeout for each page request + ctx := pagerCtx + cancel := func() {} + + if requestTimeout > 0 { + ctx, cancel = context.WithTimeout(pagerCtx, requestTimeout) + } + defer cancel() + if g.APIResource.Namespaced { - return c.Namespace(namespace).List(context.Background(), opts) + return c.Namespace(namespace).List(ctx, opts) } else { - return c.List(context.Background(), opts) + return c.List(ctx, opts) } }) listOptions := metav1.ListOptions{} @@ -336,12 +351,20 @@ func getObjects(g *groupResource, namespace string, labelSelector string, d dyna listOptions.LabelSelector = labelSelector } - list, _, err := p.List(context.TODO(), listOptions) + // Create context for pager.List + ctx := context.Background() + if requestTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, requestTimeout) + defer cancel() + } + + list, _, err := p.List(ctx, listOptions) if err != nil { return nil, err } if g.APIResource.Name == "imagestreamtags" || g.APIResource.Name == "imagetags" { - unstructuredList, err := iterateItemsByGet(c, g, list, namespace, logger) + unstructuredList, err := iterateItemsByGet(requestTimeout, c, g, list, namespace, logger) if err != nil { return nil, err } @@ -352,7 +375,7 @@ func getObjects(g *groupResource, namespace string, labelSelector string, d dyna // iterateItemsByGet builds a full UnstructuredList by Get-ing each item name from list // in namespace (used where List does not return complete objects). -func iterateItemsByGet(c dynamic.NamespaceableResourceInterface, g *groupResource, list runtime.Object, namespace string, logger logrus.FieldLogger) (*unstructured.UnstructuredList, error) { +func iterateItemsByGet(requestTimeout time.Duration, c dynamic.NamespaceableResourceInterface, g *groupResource, list runtime.Object, namespace string, logger logrus.FieldLogger) (*unstructured.UnstructuredList, error) { if g == nil { return nil, fmt.Errorf("groupResource cannot be nil") } @@ -364,7 +387,16 @@ func iterateItemsByGet(c dynamic.NamespaceableResourceInterface, g *groupResourc logger.Errorf("expected unstructured.Unstructured but got %T for groupResource %s and object: %#v\n", object, g.APIResource.Name, object) return fmt.Errorf("expected *unstructured.Unstructured but got %T", object) } - obj, err := c.Namespace(namespace).Get(context.TODO(), u.GetName(), metav1.GetOptions{}) + // Create fresh context with timeout for each Get request + ctx := context.Background() + cancel := func() {} + + if requestTimeout > 0 { + ctx, cancel = context.WithTimeout(ctx, requestTimeout) + } + + obj, err := c.Namespace(namespace).Get(ctx, u.GetName(), metav1.GetOptions{}) + cancel() if err != nil { return err } diff --git a/cmd/export/discover_test.go b/cmd/export/discover_test.go index da85d33..5bafc13 100644 --- a/cmd/export/discover_test.go +++ b/cmd/export/discover_test.go @@ -924,7 +924,7 @@ func TestResourceToExtract_SkipsEvents(t *testing.T) { }, } - resources, _ := resourceToExtract("default", "", client, lists, testLogger()) + resources, _ := resourceToExtract(0, "default", "", client, lists, testLogger()) for _, r := range resources { if r.APIResource.Kind == "Event" { t.Fatal("Event resources should be skipped") @@ -945,7 +945,7 @@ func TestResourceToExtract_SkipsClusterScopedNonAdmitted(t *testing.T) { }, } - resources, _ := resourceToExtract("default", "", client, lists, testLogger()) + resources, _ := resourceToExtract(0, "default", "", client, lists, testLogger()) for _, r := range resources { if r.APIResource.Kind == "Namespace" { t.Fatal("Namespace resources should be skipped (not admitted)") @@ -966,7 +966,7 @@ func TestResourceToExtract_SkipsEmptyVerbs(t *testing.T) { }, } - resources, _ := resourceToExtract("default", "", client, lists, testLogger()) + resources, _ := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(resources) > 0 { t.Fatal("resources with empty verbs should be skipped") } @@ -983,7 +983,7 @@ func TestResourceToExtract_SkipsEmptyAPIResources(t *testing.T) { }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(resources) != 0 || len(errs) != 0 { t.Fatal("empty APIResources list should produce no resources or errors") } @@ -1141,7 +1141,7 @@ func TestGetObjects_configMaps(t *testing.T) { APIGroupVersion: "v1", APIResource: metav1.APIResource{Name: "configmaps", Kind: "ConfigMap", Namespaced: true}, } - list, err := getObjects(g, "default", "", client, testLogger()) + list, err := getObjects(0, g, "default", "", client, testLogger()) if err != nil { t.Fatal(err) } @@ -1166,7 +1166,7 @@ func TestGetObjects_imageStreamTagsUsesGetPerItem(t *testing.T) { APIGroupVersion: "image.openshift.io/v1", APIResource: metav1.APIResource{Name: "imagestreamtags", Kind: "ImageStreamTag", Namespaced: true}, } - list, err := getObjects(g, "openshift", "", client, testLogger()) + list, err := getObjects(0, g, "openshift", "", client, testLogger()) if err != nil { t.Fatal(err) } @@ -1188,7 +1188,7 @@ func TestGetObjects_imagetagResourceName(t *testing.T) { APIGroupVersion: "example.com/v1", APIResource: metav1.APIResource{Name: "imagetags", Kind: "ImageTag", Namespaced: true}, } - list, err := getObjects(g, "ns1", "", client, testLogger()) + list, err := getObjects(0, g, "ns1", "", client, testLogger()) if err != nil { t.Fatal(err) } @@ -1218,7 +1218,7 @@ func TestGetObjects_passesLabelSelectorToList(t *testing.T) { APIGroupVersion: "v1", APIResource: metav1.APIResource{Name: "configmaps", Kind: "ConfigMap", Namespaced: true}, } - list, err := getObjects(g, "default", "app=test", client, testLogger()) + list, err := getObjects(0, g, "default", "app=test", client, testLogger()) if err != nil { t.Fatal(err) } @@ -1251,7 +1251,7 @@ func TestGetObjects_clusterScopedSkipsLabelSelector(t *testing.T) { APIGroupVersion: "rbac.authorization.k8s.io/v1", APIResource: metav1.APIResource{Name: "clusterrolebindings", Kind: "ClusterRoleBinding", Namespaced: false}, } - _, err := getObjects(g, "", "app=test", client, testLogger()) + _, err := getObjects(0,g, "", "app=test", client, testLogger()) if err != nil { t.Fatal(err) } @@ -1279,7 +1279,7 @@ func TestGetObjects_imageStreamTags_getFailure(t *testing.T) { APIGroupVersion: "image.openshift.io/v1", APIResource: metav1.APIResource{Name: "imagestreamtags", Kind: "ImageStreamTag", Namespaced: true}, } - _, err := getObjects(g, "openshift", "", client, testLogger()) + _, err := getObjects(0, g, "openshift", "", client, testLogger()) if err == nil || !strings.Contains(err.Error(), "unable to process the list") { t.Fatalf("got err=%v", err) } @@ -1303,7 +1303,7 @@ func TestIterateItemsByGet_rejectsNonUnstructured(t *testing.T) { g := &groupResource{APIGroup: "", APIResource: metav1.APIResource{Kind: "Pod"}} client := dynamicfake.NewSimpleDynamicClient(clientgoscheme.Scheme) c := client.Resource(schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}) - _, err := iterateItemsByGet(c, g, list, "default", testLogger()) + _, err := iterateItemsByGet(0, c, g, list, "default", testLogger()) if err == nil || !strings.Contains(err.Error(), "unable to process the list") { t.Fatalf("got %v", err) } @@ -1325,7 +1325,7 @@ func TestResourceToExtract_loadsConfigMaps(t *testing.T) { }, }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(errs) != 0 { t.Fatalf("unexpected errs: %v", errs) } @@ -1348,7 +1348,7 @@ func TestResourceToExtract_loadsClusterRoles(t *testing.T) { }, }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(errs) != 0 { t.Fatalf("unexpected errs: %v", errs) } @@ -1370,7 +1370,7 @@ func TestResourceToExtract_listForbidden(t *testing.T) { }, }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(resources) != 0 { t.Fatalf("expected no resources, got %d", len(resources)) } @@ -1392,12 +1392,41 @@ func TestResourceToExtract_listNotFound(t *testing.T) { }, }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(resources) != 0 || len(errs) != 1 || !apierrors.IsNotFound(errs[0].Error) { t.Fatalf("resources=%d errs=%v", len(resources), errs) } } +func TestResourceToExtract_timeoutFailsFast(t *testing.T) { + client := dynamicfake.NewSimpleDynamicClient(clientgoscheme.Scheme) + client.PrependReactor("list", "configmaps", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("context deadline exceeded") + }) + lists := []*metav1.APIResourceList{ + { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + {Name: "configmaps", Kind: "ConfigMap", Namespaced: true, Verbs: stdVerbs()}, + {Name: "services", Kind: "Service", Namespaced: true, Verbs: stdVerbs()}, + }, + }, + } + // Pass non-zero timeout to enable timeout detection + resources, errs := resourceToExtract(100, "default", "", client, lists, testLogger()) + // Expect: nil resources, exactly 1 timeout error, and no processing of services + if resources != nil { + t.Fatalf("expected nil resources on timeout, got %d resources", len(resources)) + } + if len(errs) != 1 { + t.Fatalf("expected exactly 1 error on timeout fail-fast, got %d errors: %v", len(errs), errs) + } + errMsg := errs[0].Error.Error() + if !strings.Contains(errMsg, "context deadline exceeded") { + t.Fatalf("expected timeout error containing 'context deadline exceeded', got: %v", errMsg) + } +} + func TestResourceToExtract_listMethodNotSupported(t *testing.T) { client := dynamicfake.NewSimpleDynamicClient(clientgoscheme.Scheme) client.PrependReactor("list", "configmaps", func(action kubetesting.Action) (handled bool, ret runtime.Object, err error) { @@ -1411,7 +1440,7 @@ func TestResourceToExtract_listMethodNotSupported(t *testing.T) { }, }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(resources) != 0 || len(errs) != 1 || !apierrors.IsMethodNotSupported(errs[0].Error) { t.Fatalf("resources=%d errs=%v", len(resources), errs) } @@ -1430,7 +1459,7 @@ func TestResourceToExtract_listGenericError(t *testing.T) { }, }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(resources) != 0 || len(errs) != 1 || errs[0].Error.Error() != "upstream timeout" { t.Fatalf("resources=%d errs=%v", len(resources), errs) } @@ -1446,7 +1475,7 @@ func TestResourceToExtract_zeroObjectsNotAdded(t *testing.T) { }, }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(resources) != 0 || len(errs) != 0 { t.Fatalf("empty list should skip resource (no error), got resources=%d errs=%v", len(resources), errs) } @@ -1463,7 +1492,7 @@ func TestResourceToExtract_skipsUnparseableGroupVersion(t *testing.T) { }, }, } - resources, errs := resourceToExtract("default", "", client, lists, testLogger()) + resources, errs := resourceToExtract(0, "default", "", client, lists, testLogger()) if len(resources) != 0 || len(errs) != 0 { t.Fatalf("got resources %d errs %d", len(resources), len(errs)) } diff --git a/cmd/export/export.go b/cmd/export/export.go index 5bdcefd..5c33c35 100644 --- a/cmd/export/export.go +++ b/cmd/export/export.go @@ -221,23 +221,35 @@ func (o *ExportOptions) Run() error { var errs []error - resources, resourceErrs := resourceToExtract(o.userSpecifiedNamespace, o.labelSelector, dynamicClient, resourceLists, log) + // Pass restConfig.Timeout to child functions for per-request timeout enforcement + requestTimeout := restConfig.Timeout + + resources, resourceErrs := resourceToExtract(requestTimeout, o.userSpecifiedNamespace, o.labelSelector, dynamicClient, resourceLists, log) clusterScopeHandler := NewClusterScopeHandler() resources = clusterScopeHandler.filterRbacResources(resources, log) clusterResourceDir := filepath.Join(o.exportDir, "resources", o.userSpecifiedNamespace, "_cluster") - crdResources, crdErrs := collectRelatedCRDs(resources, dynamicClient, log, o.crdSkipGroups, o.crdIncludeGroups) + crdResources, crdErrs := collectRelatedCRDs(requestTimeout, resources, dynamicClient, log, o.crdSkipGroups, o.crdIncludeGroups) resourceErrs = append(resourceErrs, crdErrs...) resources = append(resources, crdResources...) - if hasClusterScopedManifests(resources) { - if err = os.MkdirAll(clusterResourceDir, 0700); err != nil { - log.Errorf("error creating cluster resources directory: %#v", err) - return err + // Check if any resource errors are timeout errors and fail fast with exit code 1 + // Do this before writing any files to avoid partial exports + for _, resErr := range resourceErrs { + if resErr != nil && resErr.Error != nil { + if apierrors.IsTimeout(resErr.Error) || strings.Contains(resErr.Error.Error(), "context deadline exceeded") { + return resErr.Error + } } } + // After merging CRDs: prepare _cluster so hasClusterScopedManifests sees cluster-scoped CRD objects. + if err = prepareClusterResourceDir(clusterResourceDir, resources); err != nil { + log.Errorf("error preparing cluster resources directory: %#v", err) + return err + } + //count and log the no of crds crdCount := len(crdResources) if crdCount > 0 { @@ -285,6 +297,10 @@ func NewExportCommand(streams genericclioptions.IOStreams, f *flags.GlobalFlags) return err } if err := o.Run(); err != nil { + // Silence usage on timeout errors (network errors, not user input errors) + if apierrors.IsTimeout(err) || strings.Contains(err.Error(), "context deadline exceeded") { + c.SilenceUsage = true + } return err }