Files
troubleshoot/pkg/supportbundle/extract_license.go
Noah Campbell 2cebe3d8f6 Support bundle upload functionality works for apps installed via Helm (#1904)
* Gets licenseid and app slug from cluster secrets

* Update upload.go

* Update cluster_resources.go
2025-10-15 13:12:36 -05:00

364 lines
10 KiB
Go

package supportbundle
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)
// ExtractLicenseFromBundle extracts the license ID from a support bundle
// It first looks for cluster-resources/license.json, then falls back to searching
// cluster-resources/configmaps/* for a license field
// Returns both the license ID and the app slug
func ExtractLicenseFromBundle(bundlePath string) (string, string, error) {
file, err := os.Open(bundlePath)
if err != nil {
return "", "", errors.Wrap(err, "failed to open bundle file")
}
defer file.Close()
gzReader, err := gzip.NewReader(file)
if err != nil {
return "", "", errors.Wrap(err, "failed to create gzip reader")
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
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 {
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
}
}
continue
}
// Fallback: process files in cluster-resources/configmaps/
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
}
// Try to extract license from this configmap
var license string
if strings.HasSuffix(header.Name, ".json") {
license = extractLicenseFromJSON(content)
} else {
license = extractLicenseFromConfigMap(content)
}
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
}
}
return "", "", nil // No license found
}
// extractLicenseFromConfigMap attempts to extract a license ID from a ConfigMap YAML
func extractLicenseFromConfigMap(content []byte) string {
// First try to parse as YAML
var configMap map[string]interface{}
if err := yaml.Unmarshal(content, &configMap); err != nil {
// If YAML parsing fails, try regex as fallback
return extractLicenseWithRegex(string(content))
}
// Look for data field in ConfigMap
data, ok := configMap["data"].(map[interface{}]interface{})
if !ok {
return extractLicenseWithRegex(string(content))
}
// Check for license field in data
for key, value := range data {
keyStr, ok := key.(string)
if !ok {
continue
}
// Look for license-related keys
if strings.ToLower(keyStr) == "license" || strings.Contains(strings.ToLower(keyStr), "license") {
valueStr, ok := value.(string)
if ok && isValidLicenseID(valueStr) {
return valueStr
}
// The license might be YAML within YAML
if licenseID := extractLicenseFromNested(valueStr); licenseID != "" {
return licenseID
}
}
}
// Fallback to regex search
return extractLicenseWithRegex(string(content))
}
// extractLicenseFromNested tries to extract license from nested YAML content
func extractLicenseFromNested(content string) string {
// Try to parse as YAML
var nested map[string]interface{}
if err := yaml.Unmarshal([]byte(content), &nested); err != nil {
return extractLicenseWithRegex(content)
}
// Look for licenseID or license field
if licenseID, ok := nested["licenseID"].(string); ok && isValidLicenseID(licenseID) {
return licenseID
}
if licenseID, ok := nested["license_id"].(string); ok && isValidLicenseID(licenseID) {
return licenseID
}
if licenseID, ok := nested["license"].(string); ok && isValidLicenseID(licenseID) {
return licenseID
}
return extractLicenseWithRegex(content)
}
// extractLicenseWithRegex uses regex to find license patterns in text
func extractLicenseWithRegex(content string) string {
// Common patterns for license IDs in various formats
// Including patterns that might appear in embedded YAML within JSON
patterns := []string{
`licenseID:\s*["']?([a-zA-Z0-9]{20,30})["']?`,
`license_id:\s*["']?([a-zA-Z0-9]{20,30})["']?`,
`license:\s*["']?([a-zA-Z0-9]{20,30})["']?`,
`"licenseID":\s*"([a-zA-Z0-9]{20,30})"`,
`"license_id":\s*"([a-zA-Z0-9]{20,30})"`,
`"license":\s*"([a-zA-Z0-9]{20,30})"`,
`licenseID: ([a-zA-Z0-9]{20,30})`, // YAML format without quotes
`\\nlicenseID: ([a-zA-Z0-9]{20,30})`, // With escaped newline
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
if isValidLicenseID(matches[1]) {
return matches[1]
}
}
}
return ""
}
// extractLicenseFromJSON extracts license ID from a JSON file
func extractLicenseFromJSON(content []byte) string {
// First try to find license ID directly in the raw content
// This handles cases where the license is in embedded YAML/strings
if license := extractLicenseWithRegex(string(content)); license != "" {
return license
}
// Then try parsing as JSON for structured search
var data map[string]interface{}
if err := json.Unmarshal(content, &data); err != nil {
return ""
}
// Look for license-related fields at any level
return findLicenseInMap(data)
}
// findLicenseInMap recursively searches for license ID in a map
func findLicenseInMap(data interface{}) string {
switch v := data.(type) {
case map[string]interface{}:
// Check for license fields at this level
for key, value := range v {
keyLower := strings.ToLower(key)
if keyLower == "licenseid" || keyLower == "license_id" || keyLower == "license" {
if str, ok := value.(string); ok && isValidLicenseID(str) {
return str
}
}
}
// Recurse into nested objects
for _, value := range v {
if result := findLicenseInMap(value); result != "" {
return result
}
}
case []interface{}:
// Recurse into arrays
for _, item := range v {
if result := findLicenseInMap(item); result != "" {
return result
}
}
case string:
// Check if this string itself is a license
if isValidLicenseID(v) {
return v
}
}
return ""
}
// isValidLicenseID checks if a string looks like a valid license ID
func isValidLicenseID(s string) bool {
// License IDs are typically 20-30 character alphanumeric strings
if len(s) < 20 || len(s) > 30 {
return false
}
// Must be alphanumeric
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
return false
}
}
return true
}
// ExtractAppSlugFromBundle attempts to extract the app slug from a support bundle
// by looking in configmaps for appSlug field
func ExtractAppSlugFromBundle(bundlePath string) (string, error) {
file, err := os.Open(bundlePath)
if err != nil {
return "", errors.Wrap(err, "failed to open bundle file")
}
defer file.Close()
gzReader, err := gzip.NewReader(file)
if err != nil {
return "", errors.Wrap(err, "failed to create gzip reader")
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", errors.Wrap(err, "failed to read tar header")
}
// Only process files in cluster-resources/configmaps/ (may be nested under bundle directory)
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
}
// Try to extract app slug from this content
if appSlug := extractAppSlugFromContent(string(content)); appSlug != "" {
return appSlug, nil
}
// Also try to extract from the filename as fallback
filename := filepath.Base(header.Name)
filename = strings.TrimSuffix(filename, ".yaml")
filename = strings.TrimSuffix(filename, ".yml")
filename = strings.TrimSuffix(filename, ".json")
// Skip common Kubernetes configmaps
if filename == "kube-root-ca.crt" || strings.HasPrefix(filename, "kube-") ||
strings.HasPrefix(filename, "kotsadm-") {
continue
}
// Use the filename as a potential app slug
if filename != "" && !strings.Contains(filename, "..") {
return filename, nil
}
}
return "", fmt.Errorf("could not determine app slug from bundle")
}
// extractAppSlugFromContent tries to find app slug in file content
func extractAppSlugFromContent(content string) string {
// Patterns to find app slug in various formats
patterns := []string{
`appSlug:\s*["']?([a-zA-Z0-9\-]+)["']?`,
`app_slug:\s*["']?([a-zA-Z0-9\-]+)["']?`,
`"appSlug":\s*"([a-zA-Z0-9\-]+)"`,
`"app_slug":\s*"([a-zA-Z0-9\-]+)"`,
`appSlug: ([a-zA-Z0-9\-]+)`, // YAML format without quotes
`\\nappSlug: ([a-zA-Z0-9\-]+)`, // With escaped newline
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
return matches[1]
}
}
return ""
}