Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ require (
k8s.io/klog/v2 v2.140.0
k8s.io/kubectl v0.35.2
k8s.io/pod-security-admission v0.35.2
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2
sigs.k8s.io/yaml v1.6.0
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -614,8 +614,8 @@ k8s.io/metrics v0.35.2 h1:PJRP88qeadR5evg4ZKJAh3NR3ICchwM51/Aidd0LHjc=
k8s.io/metrics v0.35.2/go.mod h1:w1pJmSu2j8ftVI26MGcJtMnpmZ06oKwb4Enm+xVl06Q=
k8s.io/pod-security-admission v0.35.2 h1:vzEfL/TpdwwIE25xQiamiRfmWD+FIcNXJYzoMI50AUY=
k8s.io/pod-security-admission v0.35.2/go.mod h1:zrNF0GSYasCR8SHiAD67q2iUTHitVoFQRvTOy/UijyU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/kube-storage-version-migrator v0.0.6-0.20230721195810-5c8923c5ff96 h1:PFWFSkpArPNJxFX4ZKWAk9NSeRoZaXschn+ULa4xVek=
Expand Down
53 changes: 51 additions & 2 deletions pkg/cli/admin/mustgather/mustgather.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"k8s.io/kubectl/pkg/scheme"
"k8s.io/kubectl/pkg/util/templates"
admissionapi "k8s.io/pod-security-admission/api"
"k8s.io/utils/clock"
"k8s.io/utils/exec"
utilptr "k8s.io/utils/ptr"

Expand Down Expand Up @@ -73,7 +74,9 @@ var (
`)

mustGatherExample = templates.Examples(`
# Gather information using the default plug-in image and command, writing into ./must-gather.local.<rand>
# Gather information using the default plug-in image and command, writing into
# ./must-gather.local.<cluster-id-suffix>.<timestamp(UTC)>.<rand>
# or ./must-gather.local.<timestamp(UTC)>.<rand> if the cluster ID is unavailable
oc adm must-gather

# Gather information with a specific local folder to copy to
Expand Down Expand Up @@ -230,7 +233,7 @@ func (o *MustGatherOptions) Complete(f kcmdutil.Factory, cmd *cobra.Command, arg
}
}
if len(o.DestDir) == 0 {
o.DestDir = fmt.Sprintf("must-gather.local.%06d", rand.Int63())
o.DestDir = o.generateDestDir()
}
// TODO: this should be in Validate() method, but added here because of the call to o.completeImages() below
if o.AllImages {
Expand Down Expand Up @@ -266,6 +269,51 @@ func (o *MustGatherOptions) Complete(f kcmdutil.Factory, cmd *cobra.Command, arg
return nil
}

// getClock returns the injected PassiveClock, defaulting to clock.RealClock{} when unset.
func (o *MustGatherOptions) getClock() clock.PassiveClock {
if o.clock != nil {
return o.clock
}
return clock.RealClock{}
}

// generateDestDir builds the default destination directory name for must-gather output.
// The format includes a partial cluster ID (last 12 characters), a UTC timestamp, and a
// random ID to help distinguish must-gather archives from different clusters and collection
// times. If the cluster ID cannot be retrieved (e.g. cluster is unreachable), it falls back
// to the timestamp and random ID only.
func (o *MustGatherOptions) generateDestDir() string {
parts := []string{"must-gather.local"}

if clusterID := o.getClusterIDSuffix(); clusterID != "" {
parts = append(parts, clusterID)
}

timestamp := o.getClock().Now().UTC().Format("20060102T150405Z")
parts = append(parts, timestamp)

parts = append(parts, fmt.Sprintf("%06d", rand.Int63()))

return strings.Join(parts, ".")
}

func (o *MustGatherOptions) getClusterIDSuffix() string {
lookupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

cv, err := o.ConfigClient.ConfigV1().ClusterVersions().Get(lookupCtx, "version", metav1.GetOptions{})
if err != nil {
klog.V(4).Infof("unable to retrieve cluster ID for directory name: %v", err)
return ""
}

id := string(cv.Spec.ClusterID)
if length := len(id); length > 12 {
return id[length-12:]
}
return id
}

func (o *MustGatherOptions) completeImages(ctx context.Context) error {
for _, imageStream := range o.ImageStreams {
if image, err := o.resolveImageStreamTagString(ctx, imageStream); err == nil {
Expand Down Expand Up @@ -398,6 +446,7 @@ type MustGatherOptions struct {
SinceTime string

RsyncRshCmd string
clock clock.PassiveClock

PrinterCreated printers.ResourcePrinter
PrinterDeleted printers.ResourcePrinter
Expand Down
53 changes: 53 additions & 0 deletions pkg/cli/admin/mustgather/mustgather_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"path"
"reflect"
"strings"
"testing"
"time"

Expand All @@ -20,6 +21,7 @@ import (
"k8s.io/cli-runtime/pkg/printers"
dynamicfake "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/kubernetes/fake"
clocktesting "k8s.io/utils/clock/testing"
"k8s.io/utils/diff"

configv1 "github.com/openshift/api/config/v1"
Expand All @@ -28,6 +30,57 @@ import (
imageclient "github.com/openshift/client-go/image/clientset/versioned/fake"
)

func TestGenerateDestDir(t *testing.T) {
fixedTime := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC)
fc := clocktesting.NewFakePassiveClock(fixedTime)

tests := []struct {
name string
clusterID string
expectedPrefix string
}{
{
name: "with full cluster ID includes last 12 chars",
clusterID: "0194fffc-f70a-4776-b00d-76708af6b91c",
expectedPrefix: "must-gather.local.76708af6b91c.20260414T120000Z.",
},
{
name: "with short cluster ID uses full ID",
clusterID: "abcdef",
expectedPrefix: "must-gather.local.abcdef.20260414T120000Z.",
},
{
name: "without cluster ID falls back gracefully",
expectedPrefix: "must-gather.local.20260414T120000Z.",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var configObjects []runtime.Object
if tc.clusterID != "" {
configObjects = []runtime.Object{
&configv1.ClusterVersion{
ObjectMeta: metav1.ObjectMeta{Name: "version"},
Spec: configv1.ClusterVersionSpec{
ClusterID: configv1.ClusterID(tc.clusterID),
},
},
}
}
options := MustGatherOptions{
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
ConfigClient: configv1fake.NewSimpleClientset(configObjects...),
clock: fc,
}
destDir := options.generateDestDir()
if !strings.HasPrefix(destDir, tc.expectedPrefix) {
t.Errorf("expected prefix %q, got %q", tc.expectedPrefix, destDir)
}
})
}
}

func TestImagesAndImageStreams(t *testing.T) {

testCases := []struct {
Expand Down
Loading