diff --git a/pkg/supportbundle/extract_license.go b/pkg/supportbundle/extract_license.go index 8acdc09a..7311eef6 100644 --- a/pkg/supportbundle/extract_license.go +++ b/pkg/supportbundle/extract_license.go @@ -34,6 +34,11 @@ func ExtractLicenseFromBundle(bundlePath string) (string, string, error) { tarReader := tar.NewReader(gzReader) + // Collect results from both sources in a single pass; license.json takes priority + // regardless of its position in the tar, since configmaps often appear earlier. + var licenseJSONID, licenseJSONSlug string + var configmapID, configmapSlug string + for { header, err := tarReader.Next() if err == io.EOF { @@ -43,50 +48,47 @@ func ExtractLicenseFromBundle(bundlePath string) (string, string, error) { return "", "", errors.Wrap(err, "failed to read tar header") } - // First priority: check for the new license.json file - if strings.Contains(header.Name, "cluster-resources/license.json") && header.Typeflag == tar.TypeReg { + if header.Typeflag != tar.TypeReg { + continue + } + + // First priority: cluster-resources/license.json + if strings.Contains(header.Name, "cluster-resources/license.json") { content := make([]byte, header.Size) if _, err := io.ReadFull(tarReader, content); err != nil { continue } - - // Parse the license.json file var licenseData struct { LicenseID string `json:"licenseID"` AppSlug string `json:"appSlug"` } if err := json.Unmarshal(content, &licenseData); err == nil { if licenseData.LicenseID != "" && licenseData.AppSlug != "" { - return licenseData.LicenseID, licenseData.AppSlug, nil + licenseJSONID = licenseData.LicenseID + licenseJSONSlug = licenseData.AppSlug } } continue } - // Fallback: process files in cluster-resources/configmaps/ + // Fallback: cluster-resources/configmaps/ + if configmapID != "" { + continue // already have a configmap result + } if !strings.Contains(header.Name, "cluster-resources/configmaps/") { continue } - - // Skip directories - if header.Typeflag != tar.TypeReg { - continue - } - - // Process .yaml, .yml, and .json files if !strings.HasSuffix(header.Name, ".yaml") && !strings.HasSuffix(header.Name, ".yml") && !strings.HasSuffix(header.Name, ".json") { continue } - // Read the file content content := make([]byte, header.Size) if _, err := io.ReadFull(tarReader, content); err != nil { - continue // Skip files we can't read + continue } - // Try to extract license from this configmap var license string if strings.HasSuffix(header.Name, ".json") { license = extractLicenseFromJSON(content) @@ -95,15 +97,21 @@ func ExtractLicenseFromBundle(bundlePath string) (string, string, error) { } if license != "" { - // Extract app slug from filename filename := filepath.Base(header.Name) - appSlug := strings.TrimSuffix(filename, ".json") - appSlug = strings.TrimSuffix(appSlug, ".yaml") - appSlug = strings.TrimSuffix(appSlug, ".yml") - return license, appSlug, nil + slug := strings.TrimSuffix(filename, ".json") + slug = strings.TrimSuffix(slug, ".yaml") + slug = strings.TrimSuffix(slug, ".yml") + configmapID = license + configmapSlug = slug } } + if licenseJSONID != "" { + return licenseJSONID, licenseJSONSlug, nil + } + if configmapID != "" { + return configmapID, configmapSlug, nil + } return "", "", nil // No license found } diff --git a/pkg/supportbundle/extract_license_test.go b/pkg/supportbundle/extract_license_test.go new file mode 100644 index 00000000..bafb8b56 --- /dev/null +++ b/pkg/supportbundle/extract_license_test.go @@ -0,0 +1,139 @@ +package supportbundle + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +// makeBundleTarGz creates a temporary .tar.gz file containing the given entries. +// Each entry is a (path, content) pair. Returns the file path; caller must remove it. +func makeBundleTarGz(t *testing.T, entries []struct{ name, content string }) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "bundle-*.tar.gz") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + for _, e := range entries { + hdr := &tar.Header{ + Name: e.name, + Typeflag: tar.TypeReg, + Size: int64(len(e.content)), + Mode: 0644, + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(e.content)); err != nil { + t.Fatal(err) + } + } + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + + return f.Name() +} + +func TestExtractLicenseFromBundle_PrefersLicenseJSONOverConfigmap(t *testing.T) { + // The configmap appears first in the tar but license.json should win. + configmapContent := `{ + "kind": "ConfigMapList", + "apiVersion": "v1", + "items": [{ + "data": { + "license": "configmapLicenseIDAAAAAAAAAAAA" + } + }] +}` + licenseJSONContent := `{"licenseID":"correctLicenseIDAAAAAAAAAAAA","appSlug":"my-app"}` + + bundlePath := makeBundleTarGz(t, []struct{ name, content string }{ + // configmap comes first in the tar — this is the bug trigger + {"bundle/cluster-resources/configmaps/kotsadm.json", configmapContent}, + {"bundle/cluster-resources/license.json", licenseJSONContent}, + }) + + licenseID, appSlug, err := ExtractLicenseFromBundle(bundlePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if licenseID != "correctLicenseIDAAAAAAAAAAAA" { + t.Errorf("licenseID = %q, want %q", licenseID, "correctLicenseIDAAAAAAAAAAAA") + } + if appSlug != "my-app" { + t.Errorf("appSlug = %q, want %q", appSlug, "my-app") + } +} + +func TestExtractLicenseFromBundle_FallsBackToConfigmap(t *testing.T) { + // No license.json — should fall back to configmap scan. + configmapContent := `{ + "kind": "ConfigMapList", + "apiVersion": "v1", + "items": [{ + "data": { + "licenseID": "fallbackLicenseIDAAAAAAAAAA" + } + }] +}` + bundlePath := makeBundleTarGz(t, []struct{ name, content string }{ + {"bundle/cluster-resources/configmaps/my-app.json", configmapContent}, + }) + + licenseID, appSlug, err := ExtractLicenseFromBundle(bundlePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if licenseID != "fallbackLicenseIDAAAAAAAAAA" { + t.Errorf("licenseID = %q, want %q", licenseID, "fallbackLicenseIDAAAAAAAAAA") + } + if appSlug != "my-app" { + t.Errorf("appSlug = %q, want %q", appSlug, "my-app") + } +} + +func TestExtractLicenseFromBundle_ReturnsEmptyWhenNotFound(t *testing.T) { + bundlePath := makeBundleTarGz(t, []struct{ name, content string }{ + {"bundle/cluster-resources/configmaps/some.json", `{"kind":"ConfigMapList"}`}, + }) + + licenseID, appSlug, err := ExtractLicenseFromBundle(bundlePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if licenseID != "" || appSlug != "" { + t.Errorf("expected empty results, got licenseID=%q appSlug=%q", licenseID, appSlug) + } +} + +func TestExtractLicenseFromBundle_RealBundle(t *testing.T) { + // Validates against the actual support bundle that triggered the bug. + // The bundle has license.json at tar entry #853 but kotsadm configmap at #94. + bundlePath := filepath.Join("..", "..", "support-bundle-2026-04-10T17_13_13.tar.gz") + if _, err := os.Stat(bundlePath); os.IsNotExist(err) { + t.Skip("real bundle not present") + } + + licenseID, appSlug, err := ExtractLicenseFromBundle(bundlePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if licenseID != "36G95wTeTQoX7UYcm2QvhssCIkH" { + t.Errorf("licenseID = %q, want %q", licenseID, "36G95wTeTQoX7UYcm2QvhssCIkH") + } + if appSlug != "embedded-cluster-smoke-test-staging-app" { + t.Errorf("appSlug = %q, want %q", appSlug, "embedded-cluster-smoke-test-staging-app") + } +}