diff --git a/pkg/cli/admin/mustgather/mustgather.go b/pkg/cli/admin/mustgather/mustgather.go index 38c52b270b..4d733b3586 100644 --- a/pkg/cli/admin/mustgather/mustgather.go +++ b/pkg/cli/admin/mustgather/mustgather.go @@ -63,6 +63,14 @@ const ( defaultMustGatherCommand = "/usr/bin/gather" defaultVolumePercentage = 70 defaultSourceDir = "/must-gather/" + + // defaultKeepAliveInterval is the default interval for client-side + // authenticated API probes that reset the OAuth access-token inactivity + // timer. Clusters with accessTokenInactivityTimeout (common in regulated + // environments) will revoke idle tokens; periodic probes prevent that + // during long-running log-follow connections that the API server does + // not count as activity. + defaultKeepAliveInterval = 30 * time.Second ) var ( @@ -445,8 +453,9 @@ type MustGatherOptions struct { Since time.Duration SinceTime string - RsyncRshCmd string - clock clock.PassiveClock + RsyncRshCmd string + keepAliveInterval time.Duration + clock clock.PassiveClock PrinterCreated printers.ResourcePrinter PrinterDeleted printers.ResourcePrinter @@ -805,6 +814,36 @@ func (o *MustGatherOptions) Run() error { return kutilerrors.NewAggregate(errs) } +// startClientKeepAlive spawns a background goroutine that periodically makes +// an authenticated API call to prevent the user's OAuth access token from being +// revoked due to accessTokenInactivityTimeout. The log-follow connection held +// by getGatherContainerLogs is a single long-lived HTTP stream that the API +// server does not count as discrete activity, so without these probes the token +// can expire on clusters with short inactivity windows (e.g. 5-10 minutes in +// banking/government/PCI-DSS environments). +// +// The goroutine runs until ctx is cancelled. +func (o *MustGatherOptions) startClientKeepAlive(ctx context.Context) { + interval := o.keepAliveInterval + if interval == 0 { + interval = defaultKeepAliveInterval + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := o.Client.Discovery().ServerVersion(); err != nil && !errors.Is(err, context.Canceled) { + klog.V(2).Infof("keep-alive probe failed (non-fatal): %v", err) + } + } + } + }() +} + // processNextWorkItem creates & processes the must-gather pod and returns error if any func (o *MustGatherOptions) processNextWorkItem(ctx context.Context, ns string, pod *corev1.Pod) error { var err error @@ -831,6 +870,13 @@ func (o *MustGatherOptions) processNextWorkItem(ctx context.Context, ns string, log := o.newPodOutLogger(o.Out, pod.Name) + // Prevent the user's OAuth token from expiring due to + // accessTokenInactivityTimeout while we hold long-lived log-follow + // connections. The probes run until the gather output has been copied. + keepAliveCtx, stopKeepAlive := context.WithCancel(ctx) + defer stopKeepAlive() + o.startClientKeepAlive(keepAliveCtx) + // wait for gather container to be running (gather is running) if err := o.waitForGatherContainerRunning(ctx, pod); err != nil { log("gather did not start: %s", err) diff --git a/pkg/cli/admin/mustgather/mustgather_test.go b/pkg/cli/admin/mustgather/mustgather_test.go index 8fb6fdba37..99ed038d68 100644 --- a/pkg/cli/admin/mustgather/mustgather_test.go +++ b/pkg/cli/admin/mustgather/mustgather_test.go @@ -699,3 +699,55 @@ func TestNewPod(t *testing.T) { }) } } + +func TestStartClientKeepAlive(t *testing.T) { + t.Run("makes periodic authenticated API calls", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + o := &MustGatherOptions{ + IOStreams: genericiooptions.NewTestIOStreamsDiscard(), + Client: fakeClient, + keepAliveInterval: 50 * time.Millisecond, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + o.startClientKeepAlive(ctx) + time.Sleep(200 * time.Millisecond) + cancel() + + actions := fakeClient.Actions() + var probeCalls int + for _, a := range actions { + if a.GetVerb() == "get" { + probeCalls++ + } + } + if probeCalls < 2 { + t.Errorf("expected at least 2 keep-alive probes, got %d actions: %v", probeCalls, actions) + } + }) + + t.Run("stops when context is cancelled", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + o := &MustGatherOptions{ + IOStreams: genericiooptions.NewTestIOStreamsDiscard(), + Client: fakeClient, + keepAliveInterval: 50 * time.Millisecond, + } + + ctx, cancel := context.WithCancel(context.Background()) + o.startClientKeepAlive(ctx) + time.Sleep(150 * time.Millisecond) + cancel() + + time.Sleep(50 * time.Millisecond) + before := len(fakeClient.Actions()) + time.Sleep(200 * time.Millisecond) + after := len(fakeClient.Actions()) + + if after != before { + t.Errorf("keep-alive goroutine continued after cancel: actions before=%d, after=%d", before, after) + } + }) +}