diff --git a/cmd/hcp/cmd.go b/cmd/hcp/cmd.go index 3218b0964..a7ffad4a6 100644 --- a/cmd/hcp/cmd.go +++ b/cmd/hcp/cmd.go @@ -1,6 +1,7 @@ package hcp import ( + "github.com/openshift/osdctl/cmd/hcp/forceupgrade" "github.com/openshift/osdctl/cmd/hcp/mustgather" "github.com/spf13/cobra" ) @@ -12,6 +13,7 @@ func NewCmdHCP() *cobra.Command { } hcp.AddCommand(mustgather.NewCmdMustGather()) + hcp.AddCommand(forceupgrade.NewCmdForceUpgrade()) return hcp } diff --git a/cmd/hcp/forceupgrade/cmd.go b/cmd/hcp/forceupgrade/cmd.go new file mode 100644 index 000000000..6d2272647 --- /dev/null +++ b/cmd/hcp/forceupgrade/cmd.go @@ -0,0 +1,10 @@ +package forceupgrade + +import ( + "github.com/spf13/cobra" +) + +// NewCmdForceUpgrade creates and returns the force upgrade command +func NewCmdForceUpgrade() *cobra.Command { + return newCmdForceUpgrade() +} diff --git a/cmd/hcp/forceupgrade/forceupgrade.go b/cmd/hcp/forceupgrade/forceupgrade.go new file mode 100644 index 000000000..c61a69e57 --- /dev/null +++ b/cmd/hcp/forceupgrade/forceupgrade.go @@ -0,0 +1,542 @@ +package forceupgrade + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/openshift-online/ocm-cli/pkg/arguments" + sdk "github.com/openshift-online/ocm-sdk-go" + v1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + "github.com/openshift/osdctl/internal/io" + "github.com/openshift/osdctl/internal/servicelog" + "github.com/openshift/osdctl/internal/utils" + ocmutils "github.com/openshift/osdctl/pkg/utils" + "github.com/spf13/cobra" +) + +// forceUpgradeOptions contains all options for the force upgrade command +type forceUpgradeOptions struct { + clusterID string + clustersFile string + targetYStream string + nextRunMinutes int + dryRun bool + serviceLogTemplate string + + // Parsed cluster IDs (populated during validation) + clusterIDs []string +} + +// Service log template mappings +var serviceLogTemplates = map[string]string{ + "end-of-support": "https://raw.githubusercontent.com/openshift/managed-notifications/refs/heads/master/hcp/end_of_support_force_upgrade.json", + // Future templates can be added here: + // "critical-fix": "https://raw.githubusercontent.com/openshift/managed-notifications/refs/heads/master/hcp/critical_fix.json", + // "security-fix": "https://raw.githubusercontent.com/openshift/managed-notifications/refs/heads/master/hcp/security_fix.json", +} + +// Regular expression for valid cluster IDs - alphanumeric characters and hyphens only +var validClusterIDRegex = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) + +func newCmdForceUpgrade() *cobra.Command { + opts := &forceUpgradeOptions{} + + cmd := &cobra.Command{ + Use: "force-upgrade", + Short: "Schedule forced control plane upgrade for HCP clusters (Requires ForceUpgrader permissions)", + Long: `Schedule forced control plane upgrades for ROSA HCP clusters. This command skips all validation checks +(critical alerts, cluster conditions, node pool checks, and version gate agreements). + +⚠️ REQUIRES ForceUpgrader PERMISSIONS ⚠️ + +This command can target clusters in two ways: +- Single cluster: --cluster-id +- Multiple clusters from file: --clusters-file + +UPGRADE BEHAVIOR: +The command explicitly upgrades clusters to the LATEST Z-STREAM version of the specified Y-stream. +This serves two purposes: +1. Force upgrades to latest z-stream of the SAME y-stream for critical bug fixes +2. Force upgrades to latest z-stream of a SUBSEQUENT y-stream when current y-stream goes out of support + +Example: --target-y 4.15 will upgrade to the latest available 4.15.z version (e.g., 4.15.32).`, + Example: ` # Force upgrade without service log + osdctl hcp force-upgrade -C cluster123 --target-y 4.15 + + # Force upgrade with end-of-support service log + osdctl hcp force-upgrade -C cluster123 --target-y 4.16 --send-service-log end-of-support + + # Multiple clusters from file with end-of-support service log + osdctl hcp force-upgrade --clusters-file clusters.json --target-y 4.16 --send-service-log end-of-support + + # Force upgrade with custom service log template file + osdctl hcp force-upgrade -C cluster123 --target-y 4.15 --send-service-log /path/to/custom-template.json + +`, + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return opts.Run() + }, + } + + // Cluster targeting flags + cmd.Flags().StringVarP(&opts.clusterID, "cluster-id", "C", "", "ID of the target HCP cluster") + cmd.Flags().StringVarP(&opts.clustersFile, "clusters-file", "c", "", "JSON file containing cluster IDs (format: {\"clusters\":[\"$CLUSTERID1\", \"$CLUSTERID2\"]})") + + // Upgrade configuration flags + cmd.Flags().StringVar(&opts.targetYStream, "target-y", "", "Target Y-stream version (e.g., 4.15) - will upgrade to the LATEST Z-stream of this Y-stream") + cmd.Flags().IntVar(&opts.nextRunMinutes, "next-run-minutes", 10, "Offset in minutes for scheduling upgrade (minimum 6 for the scheduling to take place)") + cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Simulate the upgrade without making any changes") + + // Service log flags + cmd.Flags().StringVar(&opts.serviceLogTemplate, "send-service-log", "", "Send service log notification after scheduling upgrade. Specify template name (e.g., 'end-of-support') or file path (e.g., '/path/to/template.json')") + + // Mark required flags + _ = cmd.MarkFlagRequired("target-y") + + return cmd +} + +func (o *forceUpgradeOptions) validate() error { + // Exactly one cluster targeting method must be provided + if o.clusterID == "" && o.clustersFile == "" { + return fmt.Errorf("no cluster identifier has been found, please specify either --cluster-id or --clusters-file") + } + + if o.clusterID != "" && o.clustersFile != "" { + return fmt.Errorf("cannot specify both --cluster-id and --clusters-file, choose one") + } + + if o.nextRunMinutes < 6 { + return fmt.Errorf("next-run-minutes must be at least 6 minutes") + } + + // Service log validation + if o.serviceLogTemplate != "" { + // Check if it's a template name + if _, exists := serviceLogTemplates[o.serviceLogTemplate]; !exists { + // If not a template name, check if it's a valid file path + if _, err := os.Stat(o.serviceLogTemplate); os.IsNotExist(err) { + var validTemplates []string + for template := range serviceLogTemplates { + validTemplates = append(validTemplates, template) + } + return fmt.Errorf("service log value '%s' is neither a valid template name %v nor an existing file", o.serviceLogTemplate, validTemplates) + } + } + } + + // Validate cluster ID format when using single cluster ID + if o.clusterID != "" { + if !validClusterIDRegex.MatchString(o.clusterID) { + return fmt.Errorf("cluster ID '%s' contains invalid characters - only alphanumeric characters and hyphens are allowed", o.clusterID) + } + } + + // Parse and validate cluster targets + if o.clustersFile != "" { + clusterIDs, err := io.ParseAndValidateClustersFile(o.clustersFile) + if err != nil { + return err + } + // Force upgrade requires at least one cluster + if len(clusterIDs) == 0 { + return fmt.Errorf("clusters file contains no cluster IDs - the 'clusters' array is empty") + } + o.clusterIDs = clusterIDs + } else { + o.clusterIDs = []string{o.clusterID} + } + + return nil +} + +func (o *forceUpgradeOptions) Run() error { + if err := o.validate(); err != nil { + return err + } + + ocmClient, err := ocmutils.CreateConnection() + if err != nil { + return fmt.Errorf("failed to create OCM connection: %w", err) + } + defer ocmClient.Close() + + clusters, err := o.getClusters(ocmClient) + if err != nil { + return fmt.Errorf("failed to get target clusters: %w", err) + } + + if len(clusters) == 0 { + fmt.Println("No clusters found matching the given cluster-id or cluster list.") + return nil + } + + // Display cluster list and service log preview before processing + if err := o.printPreProcessingSummary(clusters); err != nil { + return fmt.Errorf("failed to display pre-processing summary: %w", err) + } + + // Ask for confirmation before proceeding (unless in dry-run mode) + if !o.dryRun { + if !ocmutils.ConfirmPrompt() { + fmt.Println("Force upgrade operation cancelled.") + return nil + } + fmt.Println() + } + + var successful, failed []string + var serviceLogSuccessful, serviceLogFailed []string + + for i, cluster := range clusters { + fmt.Printf("\n[%d/%d] Processing cluster: %s (%s)\n", i+1, len(clusters), cluster.ID(), cluster.Name()) + + targetVersion, err := o.processCluster(ocmClient, cluster) + if err != nil { + failed = append(failed, fmt.Sprintf("%s: %s", cluster.ExternalID(), err.Error())) + fmt.Printf(" ⚠️ Failed to create upgrade policy: %v\n", err) + } else { + successful = append(successful, cluster.ExternalID()) + + if o.serviceLogTemplate != "" { + if o.dryRun { + fmt.Printf(" 📧 DRY-RUN: Would send service log notification\n") + serviceLogSuccessful = append(serviceLogSuccessful, cluster.ExternalID()) + continue + } + + if err := sendUpgradeServiceLog(ocmClient, cluster, o.serviceLogTemplate, targetVersion); err != nil { + serviceLogFailed = append(serviceLogFailed, fmt.Sprintf("%s: %s", cluster.ExternalID(), err.Error())) + fmt.Printf(" ⚠️ Failed to send service log: %v\n", err) + } else { + serviceLogSuccessful = append(serviceLogSuccessful, cluster.ExternalID()) + fmt.Printf(" 📧 Service log notification sent successfully\n") + } + } + } + } + + o.printSummary(successful, failed, serviceLogSuccessful, serviceLogFailed) + return nil +} + +func (o *forceUpgradeOptions) getClusters(ocmClient *sdk.Connection) ([]*v1.Cluster, error) { + clusterIDs := o.clusterIDs + + var queries []string + for _, id := range clusterIDs { + if id == "" { + return nil, fmt.Errorf("encountered empty cluster ID, this should not happen") + } + queries = append(queries, ocmutils.GenerateQuery(id)) + } + + // Ensure we have at least one valid query + if len(queries) == 0 { + return nil, fmt.Errorf("no valid cluster IDs found to create OCM search query") + } + + clusters, err := ocmutils.ApplyFilters(ocmClient, []string{strings.Join(queries, " or ")}) + if err != nil { + return nil, fmt.Errorf("failed to find clusters: %w", err) + } + + if len(clusterIDs) != len(clusters) { + fmt.Println("") + fmt.Printf("⚠️ Warning: found %d clusters but expected %d. This can happen when clusters are no longer available in OCM, e.g. due to a deletion.\n", len(clusterIDs), len(clusters)) + fmt.Println("") + } + + return clusters, nil +} + +func (o *forceUpgradeOptions) processCluster(ocmClient *sdk.Connection, cluster *v1.Cluster) (string, error) { + // Some sanity checking - we should only ever be upgrading ROSA HCP clusters. + if !cluster.Hypershift().Enabled() { + return "", fmt.Errorf("force upgrading is only allowed on ROSA HCP clusters") + } + + // Check cluster state + if cluster.State() != v1.ClusterStateReady { + return "", fmt.Errorf("cluster is not ready (current state: %s)", cluster.State()) + } + + // Check for available upgrades + if len(cluster.Version().AvailableUpgrades()) == 0 { + return "", fmt.Errorf("no available upgrades path") + } + + // Check for existing upgrade policies + policiesResponse, err := ocmClient.ClustersMgmt().V1().Clusters().Cluster(cluster.ID()). + ControlPlane().UpgradePolicies().List().Send() + if err != nil { + return "", fmt.Errorf("failed to list existing upgrade policies: %w", err) + } + + var automaticPolicyIDs []string + for _, policy := range policiesResponse.Items().Slice() { + if policy.ScheduleType() == v1.ScheduleTypeAutomatic { + automaticPolicyIDs = append(automaticPolicyIDs, policy.ID()) + } else { + return "", fmt.Errorf("existing manual upgrade policy found: target version %s scheduled at %s", + policy.Version(), policy.NextRun().Format(time.RFC3339)) + } + } + + // Find target version + targetVersion, err := o.determineTargetVersion(cluster.Version().AvailableUpgrades()) + if err != nil { + return "", fmt.Errorf("failed to determine target version: %w", err) + } + + if targetVersion == "" { + return "", fmt.Errorf("no valid upgrade version found for Y-stream '%s'", o.targetYStream) + } + + scheduleTime := time.Now().UTC().Add(time.Duration(o.nextRunMinutes) * time.Minute) + + if o.dryRun { + fmt.Printf(" 🔍 DRY RUN: Would schedule force upgrade to %s at %s\n", + targetVersion, scheduleTime.Format(time.RFC3339)) + return targetVersion, nil + } + + // Delete automatic Z-stream upgrade policies + for _, id := range automaticPolicyIDs { + fmt.Printf(" 🗑️ Deleting automatic Z-stream upgrade policy (ID: %s) ahead of scheduling manual upgrade\n", id) + _, err := ocmClient.ClustersMgmt().V1().Clusters().Cluster(cluster.ID()). + ControlPlane().UpgradePolicies().ControlPlaneUpgradePolicy(id).Delete().Send() + if err != nil { + return "", fmt.Errorf("failed to delete automatic upgrade policy (ID: %s): %w", id, err) + } + } + + policy, err := v1.NewControlPlaneUpgradePolicy(). + Version(targetVersion). + ScheduleType(v1.ScheduleTypeManual). + UpgradeType(v1.UpgradeTypeControlPlaneCVE). // UpgradeTypeControlPlaneCVE skips pre-flights such as OCM version gates, nodepool version constraints, cluster conditions, actively firing alerts (see OCM-DDR-0204) + NextRun(scheduleTime). + Build() + if err != nil { + return "", fmt.Errorf("failed to build upgrade policy: %w", err) + } + + _, err = ocmClient.ClustersMgmt().V1().Clusters().Cluster(cluster.ID()). + ControlPlane().UpgradePolicies().Add().Body(policy).Send() + if err != nil { + return "", fmt.Errorf("failed to create upgrade policy: %w", err) + } + + fmt.Printf(" ✅ Scheduled force upgrade to version %s at %s\n", + targetVersion, scheduleTime.Format(time.RFC3339)) + + return targetVersion, nil +} + +func (o *forceUpgradeOptions) determineTargetVersion(availableUpgrades []string) (string, error) { + var matchingVersions []*semver.Version + + for _, upgrade := range availableUpgrades { + version, err := semver.NewVersion(upgrade) + if err != nil { + fmt.Printf(" ⚠️ Skipping invalid version: %s\n", upgrade) + continue + } + + // Extract Y-stream (major.minor) and compare + upgradeYStream := fmt.Sprintf("%d.%d", version.Major(), version.Minor()) + if upgradeYStream == o.targetYStream { + matchingVersions = append(matchingVersions, version) + } + } + + if len(matchingVersions) == 0 { + return "", nil + } + + // Sort and return the highest version + var latest *semver.Version + for _, version := range matchingVersions { + if latest == nil || version.GreaterThan(latest) { + latest = version + } + } + + return latest.Original(), nil +} + +// loadServiceLogTemplate loads a service log template from either a predefined template name or file path +func loadServiceLogTemplate(templateOrFile string) ([]byte, bool, error) { + var templateBytes []byte + var err error + var usingDefaultTemplate bool + + // Check if it's a template name + if templateURL, exists := serviceLogTemplates[templateOrFile]; exists { + // Use predefined template URL + templateBytes, err = utils.CurlThis(templateURL) + if err != nil { + return nil, false, fmt.Errorf("failed to fetch template from %s: %w", templateURL, err) + } + usingDefaultTemplate = true + } else { + // Treat as file path + templateBytes, err = os.ReadFile(templateOrFile) + if err != nil { + return nil, false, fmt.Errorf("failed to read template file %s: %w", templateOrFile, err) + } + usingDefaultTemplate = false + } + + return templateBytes, usingDefaultTemplate, nil +} + +func sendUpgradeServiceLog(ocmClient *sdk.Connection, cluster *v1.Cluster, templateOrFile, targetVersion string) error { + templateBytes, usingDefaultTemplate, err := loadServiceLogTemplate(templateOrFile) + if err != nil { + return err + } + + if usingDefaultTemplate { + fmt.Printf(" 📄 Using service log template: %s\n", templateOrFile) + } else { + fmt.Printf(" 📄 Using custom service log template file: %s\n", templateOrFile) + } + + var message servicelog.Message + if err := json.Unmarshal(templateBytes, &message); err != nil { + return fmt.Errorf("failed to parse service log template: %w", err) + } + + // Set cluster-specific fields + message.ClusterUUID = cluster.ExternalID() + message.ClusterID = cluster.ID() + + // Only replace VERSION parameter if using the default template + if usingDefaultTemplate { + message.ReplaceWithFlag("${VERSION}", targetVersion) + } + + // Validate that all required parameters were replaced + if leftoverParams, found := message.FindLeftovers(); found { + if usingDefaultTemplate { + return fmt.Errorf("default template contains unresolved parameters: %v. This should not happen", leftoverParams) + } else { + return fmt.Errorf("custom template contains unresolved parameters: %v. Please ensure all parameters are defined in your template", leftoverParams) + } + } + + request := ocmClient.Post() + if err := arguments.ApplyPathArg(request, "/api/service_logs/v1/cluster_logs"); err != nil { + return fmt.Errorf("cannot parse API path: %v", err) + } + + messageBytes, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("cannot marshal service log message: %v", err) + } + + request.Bytes(messageBytes) + + response, err := ocmutils.SendRequest(request) + if err != nil { + return fmt.Errorf("failed to send service log: %w", err) + } + + if response.Status() != 201 { + return fmt.Errorf("service log request failed with status: %d", response.Status()) + } + + return nil +} + +func (o *forceUpgradeOptions) printSummary(successful, failed, serviceLogSuccessful, serviceLogFailed []string) { + fmt.Print("\n" + strings.Repeat("=", 60) + "\n") + fmt.Print("FORCE UPGRADE SUMMARY\n") + fmt.Print(strings.Repeat("=", 60) + "\n") + + total := len(successful) + len(failed) + fmt.Printf("Total clusters processed: %d\n", total) + fmt.Printf("Successfully scheduled: %d\n", len(successful)) + fmt.Printf("Failed: %d\n", len(failed)) + + if len(failed) > 0 { + fmt.Printf("\n⚠️ Failed to create upgrade policies for the following clusters (please follow-up manually):\n") + for _, entry := range failed { + fmt.Printf(" - %s\n", entry) + } + } + + if o.serviceLogTemplate != "" { + fmt.Printf("\n📧 SERVICE LOG SUMMARY:\n") + fmt.Printf("Successfully sent: %d\n", len(serviceLogSuccessful)) + fmt.Printf("Failed to send: %d\n", len(serviceLogFailed)) + + if len(serviceLogFailed) > 0 { + fmt.Printf("\n⚠️ Failed to send service logs for the following clusters (please follow-up manually):\n") + for _, entry := range serviceLogFailed { + fmt.Printf(" - %s\n", entry) + } + } + } + + fmt.Print(strings.Repeat("=", 60) + "\n") +} + +// printPreProcessingSummary displays the clusters and service log template before processing +func (o *forceUpgradeOptions) printPreProcessingSummary(clusters []*v1.Cluster) error { + fmt.Print("\n" + strings.Repeat("=", 60) + "\n") + fmt.Print("PRE-PROCESSING SUMMARY\n") + + fmt.Print(strings.Repeat("=", 60) + "\n") + + fmt.Printf("\nClusters to be upgraded (Target Y-stream: %s):\n", o.targetYStream) + for i, cluster := range clusters { + fmt.Printf("%2d. %s (%s) - %s - %s\n", + i+1, + cluster.ExternalID(), + cluster.Name(), + cluster.State(), + cluster.OpenshiftVersion(), + ) + } + + // Display service log template if service logs are enabled + if o.serviceLogTemplate != "" { + fmt.Printf("\nService Log to be sent after scheduling upgrades:\n") + + templateBytes, usingDefaultTemplate, err := loadServiceLogTemplate(o.serviceLogTemplate) + if err != nil { + return err + } + + // Pretty print the JSON template + var jsonData any + if err := json.Unmarshal(templateBytes, &jsonData); err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + prettyJSON, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return fmt.Errorf("failed to format template JSON: %w", err) + } + fmt.Printf("Template:\n%s\n", string(prettyJSON)) + + if usingDefaultTemplate { + fmt.Printf("\nNote: The ${VERSION} parameter will be replaced with the target upgrade version for each cluster.\n") + } else { + fmt.Printf("\nNote: Custom template files are used as-is without automatic parameter replacement.\n") + } + } + + fmt.Print(strings.Repeat("=", 60) + "\n") + + return nil +} diff --git a/cmd/hcp/forceupgrade/forceupgrade_test.go b/cmd/hcp/forceupgrade/forceupgrade_test.go new file mode 100644 index 000000000..829d0eeef --- /dev/null +++ b/cmd/hcp/forceupgrade/forceupgrade_test.go @@ -0,0 +1,292 @@ +package forceupgrade + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestForceUpgradeOptionsValidation(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test-template.json") + err := os.WriteFile(tmpFile, []byte(`{"test": "template"}`), 0600) + if err != nil { + t.Fatalf("Failed to create temp file for test: %v", err) + } + + tests := []struct { + name string + opts *forceUpgradeOptions + needsClustersFile bool + wantErr bool + errMsg string + }{ + { + name: "valid with cluster ID", + opts: &forceUpgradeOptions{ + clusterID: "test-cluster", + nextRunMinutes: 10, + }, + wantErr: false, + }, + { + name: "invalid - no cluster targeting", + opts: &forceUpgradeOptions{ + nextRunMinutes: 10, + }, + wantErr: true, + errMsg: "no cluster identifier has been found, please specify either --cluster-id or --clusters-file", + }, + { + name: "invalid - both cluster ID and file", + opts: &forceUpgradeOptions{ + clusterID: "test-cluster", + clustersFile: "clusters.json", + nextRunMinutes: 10, + }, + wantErr: true, + errMsg: "cannot specify both --cluster-id and --clusters-file, choose one", + }, + { + name: "invalid - next run minutes too low", + opts: &forceUpgradeOptions{ + clusterID: "test-cluster", + nextRunMinutes: 5, + }, + wantErr: true, + errMsg: "next-run-minutes must be at least 6 minutes", + }, + { + name: "valid - service log with template name", + opts: &forceUpgradeOptions{ + clusterID: "test-cluster", + nextRunMinutes: 10, + serviceLogTemplate: "end-of-support", + }, + wantErr: false, + }, + { + name: "valid - service log with file path", + opts: &forceUpgradeOptions{ + clusterID: "test-cluster", + nextRunMinutes: 10, + serviceLogTemplate: tmpFile, + }, + wantErr: false, + }, + { + name: "invalid - service log with invalid template name", + opts: &forceUpgradeOptions{ + clusterID: "test-cluster", + nextRunMinutes: 10, + serviceLogTemplate: "invalid-template", + }, + wantErr: true, + errMsg: "service log value 'invalid-template' is neither a valid template name [end-of-support] nor an existing file", + }, + { + name: "invalid - service log file does not exist", + opts: &forceUpgradeOptions{ + clusterID: "test-cluster", + nextRunMinutes: 10, + serviceLogTemplate: "/nonexistent/file.json", + }, + wantErr: true, + errMsg: "service log value '/nonexistent/file.json' is neither a valid template name [end-of-support] nor an existing file", + }, + { + name: "valid - cluster ID with alphanumeric and hyphens", + opts: &forceUpgradeOptions{ + clusterID: "test-cluster-123", + nextRunMinutes: 10, + }, + wantErr: false, + }, + { + name: "invalid - cluster ID with special characters", + opts: &forceUpgradeOptions{ + clusterID: "test@cluster!", + nextRunMinutes: 10, + }, + wantErr: true, + errMsg: "cluster ID 'test@cluster!' contains invalid characters - only alphanumeric characters and hyphens are allowed", + }, + { + name: "invalid - cluster ID with underscore", + opts: &forceUpgradeOptions{ + clusterID: "test_cluster", + nextRunMinutes: 10, + }, + wantErr: true, + errMsg: "cluster ID 'test_cluster' contains invalid characters - only alphanumeric characters and hyphens are allowed", + }, + { + name: "invalid - cluster ID with dot", + opts: &forceUpgradeOptions{ + clusterID: "test.cluster", + nextRunMinutes: 10, + }, + wantErr: true, + errMsg: "cluster ID 'test.cluster' contains invalid characters - only alphanumeric characters and hyphens are allowed", + }, + // Basic clusters file test - just verify it can be used + { + name: "valid clusters file", + opts: &forceUpgradeOptions{ + nextRunMinutes: 10, + // clustersFile will be set in the test + }, + needsClustersFile: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.needsClustersFile { + tmpClustersFile := filepath.Join(t.TempDir(), "clusters.json") + fileContent := `{"clusters": ["cluster1", "cluster2", "cluster3"]}` + err := os.WriteFile(tmpClustersFile, []byte(fileContent), 0600) + if err != nil { + t.Fatalf("Failed to create temp clusters file: %v", err) + } + tt.opts.clustersFile = tmpClustersFile + } + + err := tt.opts.validate() + if (err != nil) != tt.wantErr { + t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && tt.errMsg != "" && err != nil { + if err.Error() != tt.errMsg { + t.Errorf("validate() error message = %v, want %v", err.Error(), tt.errMsg) + } + } + }) + } +} + +func TestLoadServiceLogTemplate(t *testing.T) { + // Create a temporary template file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test-template.json") + testContent := `{"test": "template content"}` + err := os.WriteFile(tmpFile, []byte(testContent), 0600) + if err != nil { + t.Fatalf("Failed to create temp template file: %v", err) + } + + tests := []struct { + name string + templateOrFile string + expectError bool + expectedUsingDefault bool + expectedContentContains string + }{ + { + name: "valid template name", + templateOrFile: "end-of-support", + expectError: false, + expectedUsingDefault: true, + }, + { + name: "valid file path", + templateOrFile: tmpFile, + expectError: false, + expectedUsingDefault: false, + expectedContentContains: "template content", + }, + { + name: "invalid template name (treated as file path)", + templateOrFile: "non-existent-template", + expectError: true, + }, + { + name: "non-existent file path", + templateOrFile: "/path/that/does/not/exist.json", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + templateBytes, usingDefaultTemplate, err := loadServiceLogTemplate(tt.templateOrFile) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } else { + if usingDefaultTemplate != tt.expectedUsingDefault { + t.Errorf("Expected usingDefaultTemplate=%v, got %v", tt.expectedUsingDefault, usingDefaultTemplate) + } + if tt.expectedContentContains != "" && !strings.Contains(string(templateBytes), tt.expectedContentContains) { + t.Errorf("Expected template content to contain '%s', got: %s", tt.expectedContentContains, string(templateBytes)) + } + } + } + }) + } +} + +func TestDetermineTargetVersion(t *testing.T) { + opts := &forceUpgradeOptions{targetYStream: "4.15"} + + tests := []struct { + name string + availableUpgrades []string + expectedVersion string + expectError bool + }{ + { + name: "single matching version", + availableUpgrades: []string{"4.15.1"}, + expectedVersion: "4.15.1", + expectError: false, + }, + { + name: "multiple matching versions - returns highest", + availableUpgrades: []string{"4.15.1", "4.15.3", "4.15.2"}, + expectedVersion: "4.15.3", + expectError: false, + }, + { + name: "no matching versions", + availableUpgrades: []string{"4.14.1", "4.16.1"}, + expectedVersion: "", + expectError: false, + }, + { + name: "mixed versions with matching Y-stream", + availableUpgrades: []string{"4.14.5", "4.15.2", "4.16.1", "4.15.4"}, + expectedVersion: "4.15.4", + expectError: false, + }, + { + name: "invalid semver in upgrades", + availableUpgrades: []string{"4.15.1", "invalid-version", "4.15.2"}, + expectedVersion: "4.15.2", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, err := opts.determineTargetVersion(tt.availableUpgrades) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if version != tt.expectedVersion { + t.Errorf("Expected version %s, got %s", tt.expectedVersion, version) + } + }) + } +} diff --git a/cmd/servicelog/post.go b/cmd/servicelog/post.go index 3b39cad11..c99b6d76c 100644 --- a/cmd/servicelog/post.go +++ b/cmd/servicelog/post.go @@ -20,6 +20,7 @@ import ( "github.com/openshift-online/ocm-cli/pkg/dump" sdk "github.com/openshift-online/ocm-sdk-go" v1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + "github.com/openshift/osdctl/internal/io" "github.com/openshift/osdctl/internal/servicelog" "github.com/openshift/osdctl/internal/utils" "github.com/openshift/osdctl/pkg/link_validator" @@ -33,7 +34,6 @@ import ( type PostCmdOptions struct { Message servicelog.Message - ClustersFile servicelog.ClustersFile Template string TemplateParams []string Overrides []string @@ -191,16 +191,12 @@ func (o *PostCmdOptions) Run() error { // Combine existing OCM filters with any cluster id-related flags var queries []string if o.clustersFile != "" { - contents, err := o.accessFile(o.clustersFile) + clusterIDs, err := io.ParseAndValidateClustersFile(o.clustersFile) if err != nil { - return fmt.Errorf("cannot read file %s: %w", o.clustersFile, err) + return fmt.Errorf("cannot parse clusters file %s: %w", o.clustersFile, err) } - if err := o.parseClustersFile(contents); err != nil { - return fmt.Errorf("cannot parse file %s: %w", o.clustersFile, err) - } - for i := range o.ClustersFile.Clusters { - cluster := o.ClustersFile.Clusters[i] - queries = append(queries, ocmutils.GenerateQuery(cluster)) + for _, clusterID := range clusterIDs { + queries = append(queries, ocmutils.GenerateQuery(clusterID)) } } if o.ClusterId != "" { @@ -464,11 +460,6 @@ func (o *PostCmdOptions) accessFile(filePath string) ([]byte, error) { return nil, fmt.Errorf("cannot read the file %q", filePath) } -// parseClustersFile reads the clustrs file into a JSON struct -func (o *PostCmdOptions) parseClustersFile(jsonFile []byte) error { - return json.Unmarshal(jsonFile, &o.ClustersFile) -} - // parseTemplate reads the template file into a JSON struct func (o *PostCmdOptions) parseTemplate(jsonFile []byte) error { return json.Unmarshal(jsonFile, &o.Message) diff --git a/cmd/servicelog/post_test.go b/cmd/servicelog/post_test.go index d4e9503b8..02a5f051f 100644 --- a/cmd/servicelog/post_test.go +++ b/cmd/servicelog/post_test.go @@ -380,39 +380,6 @@ func TestListMessagedClusters(t *testing.T) { }) } } -func TestParseClustersFile(t *testing.T) { - tests := []struct { - name string - input []byte - expected error - }{ - { - name: "valid_JSON_input_with_no_errors", - input: []byte(`{"clusters":["cluster-1","cluster-2"]}`), - expected: nil, - }, - { - name: "invalid_JSON_input_with_errors", - input: []byte(`{"clusters":["cluster-1","cluster-2"`), - expected: assert.AnError, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - options := &PostCmdOptions{} - err := options.parseClustersFile(tt.input) - if tt.expected == nil { - assert.NoError(t, err) - assert.Equal(t, 2, len(options.ClustersFile.Clusters)) - assert.Equal(t, "cluster-1", options.ClustersFile.Clusters[0]) - assert.Equal(t, "cluster-2", options.ClustersFile.Clusters[1]) - } else { - assert.Error(t, err) - } - }) - } -} func TestReplaceFlags(t *testing.T) { tests := []struct { name string diff --git a/docs/README.md b/docs/README.md index d441b3f3a..fe0ba5f06 100644 --- a/docs/README.md +++ b/docs/README.md @@ -88,6 +88,7 @@ - `url --cluster-id ` - Get the Dynatrace Tenant URL for a given MC or HCP cluster - `env [flags] [env-alias]` - Create an environment to interact with a cluster - `hcp` - + - `force-upgrade` - Schedule forced control plane upgrade for HCP clusters (Requires ForceUpgrader permissions) - `must-gather --cluster-id ` - Create a must-gather for HCP cluster - `hive` - hive related utilities - `clusterdeployment` - cluster deployment related utilities @@ -2661,6 +2662,51 @@ osdctl hcp [flags] -S, --skip-version-check skip checking to see if this is the most recent release ``` +### osdctl hcp force-upgrade + +Schedule forced control plane upgrades for ROSA HCP clusters. This command skips all validation checks +(critical alerts, cluster conditions, node pool checks, and version gate agreements). + +⚠️ REQUIRES ForceUpgrader PERMISSIONS ⚠️ + +This command can target clusters in two ways: +- Single cluster: --cluster-id +- Multiple clusters from file: --clusters-file + +UPGRADE BEHAVIOR: +The command explicitly upgrades clusters to the LATEST Z-STREAM version of the specified Y-stream. +This serves two purposes: +1. Force upgrades to latest z-stream of the SAME y-stream for critical bug fixes +2. Force upgrades to latest z-stream of a SUBSEQUENT y-stream when current y-stream goes out of support + +Example: --target-y 4.15 will upgrade to the latest available 4.15.z version (e.g., 4.15.32). + +``` +osdctl hcp force-upgrade [flags] +``` + +#### Flags + +``` + --as string Username to impersonate for the operation. User could be a regular user or a service account in a namespace. + --cluster string The name of the kubeconfig cluster to use + -C, --cluster-id string ID of the target HCP cluster + -c, --clusters-file string JSON file containing cluster IDs (format: {"clusters":["$CLUSTERID1", "$CLUSTERID2"]}) + --context string The name of the kubeconfig context to use + --dry-run Simulate the upgrade without making any changes + -h, --help help for force-upgrade + --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure + --kubeconfig string Path to the kubeconfig file to use for CLI requests. + --next-run-minutes int Offset in minutes for scheduling upgrade (minimum 6 for the scheduling to take place) (default 10) + -o, --output string Valid formats are ['', 'json', 'yaml', 'env'] + --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") + --send-service-log string Send service log notification after scheduling upgrade. Specify template name (e.g., 'end-of-support') or file path (e.g., '/path/to/template.json') + -s, --server string The address and port of the Kubernetes API server + --skip-aws-proxy-check aws_proxy Don't use the configured aws_proxy value + -S, --skip-version-check skip checking to see if this is the most recent release + --target-y string Target Y-stream version (e.g., 4.15) - will upgrade to the LATEST Z-stream of this Y-stream +``` + ### osdctl hcp must-gather Create a must-gather for an HCP cluster with optional gather targets diff --git a/docs/osdctl_hcp.md b/docs/osdctl_hcp.md index e14d1c717..43ebd8ea1 100644 --- a/docs/osdctl_hcp.md +++ b/docs/osdctl_hcp.md @@ -26,5 +26,6 @@ ### SEE ALSO * [osdctl](osdctl.md) - OSD CLI +* [osdctl hcp force-upgrade](osdctl_hcp_force-upgrade.md) - Schedule forced control plane upgrade for HCP clusters (Requires ForceUpgrader permissions) * [osdctl hcp must-gather](osdctl_hcp_must-gather.md) - Create a must-gather for HCP cluster diff --git a/docs/osdctl_hcp_force-upgrade.md b/docs/osdctl_hcp_force-upgrade.md new file mode 100644 index 000000000..677276182 --- /dev/null +++ b/docs/osdctl_hcp_force-upgrade.md @@ -0,0 +1,76 @@ +## osdctl hcp force-upgrade + +Schedule forced control plane upgrade for HCP clusters (Requires ForceUpgrader permissions) + +### Synopsis + +Schedule forced control plane upgrades for ROSA HCP clusters. This command skips all validation checks +(critical alerts, cluster conditions, node pool checks, and version gate agreements). + +⚠️ REQUIRES ForceUpgrader PERMISSIONS ⚠️ + +This command can target clusters in two ways: +- Single cluster: --cluster-id +- Multiple clusters from file: --clusters-file + +UPGRADE BEHAVIOR: +The command explicitly upgrades clusters to the LATEST Z-STREAM version of the specified Y-stream. +This serves two purposes: +1. Force upgrades to latest z-stream of the SAME y-stream for critical bug fixes +2. Force upgrades to latest z-stream of a SUBSEQUENT y-stream when current y-stream goes out of support + +Example: --target-y 4.15 will upgrade to the latest available 4.15.z version (e.g., 4.15.32). + +``` +osdctl hcp force-upgrade [flags] +``` + +### Examples + +``` + # Force upgrade without service log + osdctl hcp force-upgrade -C cluster123 --target-y 4.15 + + # Force upgrade with end-of-support service log + osdctl hcp force-upgrade -C cluster123 --target-y 4.16 --send-service-log end-of-support + + # Multiple clusters from file with end-of-support service log + osdctl hcp force-upgrade --clusters-file clusters.json --target-y 4.16 --send-service-log end-of-support + + # Force upgrade with custom service log template file + osdctl hcp force-upgrade -C cluster123 --target-y 4.15 --send-service-log /path/to/custom-template.json + + +``` + +### Options + +``` + -C, --cluster-id string ID of the target HCP cluster + -c, --clusters-file string JSON file containing cluster IDs (format: {"clusters":["$CLUSTERID1", "$CLUSTERID2"]}) + --dry-run Simulate the upgrade without making any changes + -h, --help help for force-upgrade + --next-run-minutes int Offset in minutes for scheduling upgrade (minimum 6 for the scheduling to take place) (default 10) + --send-service-log string Send service log notification after scheduling upgrade. Specify template name (e.g., 'end-of-support') or file path (e.g., '/path/to/template.json') + --target-y string Target Y-stream version (e.g., 4.15) - will upgrade to the LATEST Z-stream of this Y-stream +``` + +### Options inherited from parent commands + +``` + --as string Username to impersonate for the operation. User could be a regular user or a service account in a namespace. + --cluster string The name of the kubeconfig cluster to use + --context string The name of the kubeconfig context to use + --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure + --kubeconfig string Path to the kubeconfig file to use for CLI requests. + -o, --output string Valid formats are ['', 'json', 'yaml', 'env'] + --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") + -s, --server string The address and port of the Kubernetes API server + --skip-aws-proxy-check aws_proxy Don't use the configured aws_proxy value + -S, --skip-version-check skip checking to see if this is the most recent release +``` + +### SEE ALSO + +* [osdctl hcp](osdctl_hcp.md) - + diff --git a/go.mod b/go.mod index 2b6faef0c..69c56eead 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( cloud.google.com/go/compute v1.37.0 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Dynatrace/dynatrace-operator v0.14.2 + github.com/Masterminds/semver/v3 v3.4.0 github.com/PagerDuty/go-pagerduty v1.8.0 github.com/andygrunwald/go-jira v1.17.0 github.com/aws/aws-sdk-go-v2 v1.40.0 diff --git a/internal/io/clusters_file.go b/internal/io/clusters_file.go new file mode 100644 index 000000000..6f9198ff7 --- /dev/null +++ b/internal/io/clusters_file.go @@ -0,0 +1,39 @@ +package io + +import ( + "encoding/json" + "fmt" + "os" + "regexp" +) + +// ClustersFile represents the structure of a cluster file for mass cluster operations +type ClustersFile struct { + Clusters []string `json:"clusters"` +} + +// Regular expression for valid cluster IDs - alphanumeric characters and hyphens only +var validClusterIDRegex = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) + +// ParseAndValidateClustersFile reads, parses, and validates a cluster file +// Returns a slice of validated cluster IDs for mass cluster operations +func ParseAndValidateClustersFile(filePath string) ([]string, error) { + file, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read clusters file: %w", err) + } + + var clustersFile ClustersFile + if err := json.Unmarshal(file, &clustersFile); err != nil { + return nil, fmt.Errorf("failed to parse clusters file: %w", err) + } + + // Validate each cluster ID using regex + for i, id := range clustersFile.Clusters { + if !validClusterIDRegex.MatchString(id) { + return nil, fmt.Errorf("clusters file contains invalid cluster ID at index %d: '%s' - only alphanumeric characters and hyphens are allowed", i, id) + } + } + + return clustersFile.Clusters, nil +} diff --git a/internal/io/clusters_file_test.go b/internal/io/clusters_file_test.go new file mode 100644 index 000000000..70021a4a6 --- /dev/null +++ b/internal/io/clusters_file_test.go @@ -0,0 +1,319 @@ +package io + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseAndValidateClustersFile(t *testing.T) { + tests := []struct { + name string + fileContent string + expectError bool + errorContains string + expectedIDs []string + }{ + { + name: "valid cluster file with mixed ID types", + fileContent: `{"clusters": ["2npb79qc3lqkrnn4g6u9cd9mqtlkb4gj", "testhcp", "a537f279-a25a-4b2b-b2d8-00b06d41ff2f"]}`, + expectError: false, + expectedIDs: []string{"2npb79qc3lqkrnn4g6u9cd9mqtlkb4gj", "testhcp", "a537f279-a25a-4b2b-b2d8-00b06d41ff2f"}, + }, + { + name: "valid cluster file with long alphanumeric cluster ID", + fileContent: `{"clusters": ["2npb79qc3lqkrnn4g6u9cd9mqtlkb4gj"]}`, + expectError: false, + expectedIDs: []string{"2npb79qc3lqkrnn4g6u9cd9mqtlkb4gj"}, + }, + { + name: "valid cluster file with cluster name", + fileContent: `{"clusters": ["testhcp"]}`, + expectError: false, + expectedIDs: []string{"testhcp"}, + }, + { + name: "valid cluster file with external ID (UUID)", + fileContent: `{"clusters": ["a537f279-a25a-4b2b-b2d8-00b06d41ff2f"]}`, + expectError: false, + expectedIDs: []string{"a537f279-a25a-4b2b-b2d8-00b06d41ff2f"}, + }, + { + name: "valid cluster file with alphanumeric and hyphens", + fileContent: `{"clusters": ["cluster-1", "test-cluster-123", "my-cluster-name"]}`, + expectError: false, + expectedIDs: []string{"cluster-1", "test-cluster-123", "my-cluster-name"}, + }, + { + name: "valid cluster file with single cluster", + fileContent: `{"clusters": ["single-cluster"]}`, + expectError: false, + expectedIDs: []string{"single-cluster"}, + }, + { + name: "empty clusters array", + fileContent: `{"clusters": []}`, + expectError: false, + expectedIDs: []string{}, + }, + { + name: "invalid cluster ID with special characters", + fileContent: `{"clusters": ["cluster1", "test@cluster!", "cluster3"]}`, + expectError: true, + errorContains: "clusters file contains invalid cluster ID at index 1: 'test@cluster!' - only alphanumeric characters and hyphens are allowed", + }, + { + name: "invalid cluster ID with underscore", + fileContent: `{"clusters": ["cluster1", "test_cluster", "cluster3"]}`, + expectError: true, + errorContains: "clusters file contains invalid cluster ID at index 1: 'test_cluster' - only alphanumeric characters and hyphens are allowed", + }, + { + name: "invalid cluster ID with dot", + fileContent: `{"clusters": ["cluster1", "test.cluster", "cluster3"]}`, + expectError: true, + errorContains: "clusters file contains invalid cluster ID at index 1: 'test.cluster' - only alphanumeric characters and hyphens are allowed", + }, + { + name: "invalid cluster ID with spaces", + fileContent: `{"clusters": ["cluster1", "test cluster", "cluster3"]}`, + expectError: true, + errorContains: "clusters file contains invalid cluster ID at index 1: 'test cluster' - only alphanumeric characters and hyphens are allowed", + }, + { + name: "empty cluster ID", + fileContent: `{"clusters": ["cluster1", "", "cluster3"]}`, + expectError: true, + errorContains: "clusters file contains invalid cluster ID at index 1: '' - only alphanumeric characters and hyphens are allowed", + }, + { + name: "whitespace-only cluster ID", + fileContent: `{"clusters": ["cluster1", " ", "cluster3"]}`, + expectError: true, + errorContains: "clusters file contains invalid cluster ID at index 1: ' ' - only alphanumeric characters and hyphens are allowed", + }, + { + name: "cluster ID with forward slash", + fileContent: `{"clusters": ["cluster/path"]}`, + expectError: true, + errorContains: "clusters file contains invalid cluster ID at index 0: 'cluster/path' - only alphanumeric characters and hyphens are allowed", + }, + { + name: "cluster ID with colon", + fileContent: `{"clusters": ["cluster:name"]}`, + expectError: true, + errorContains: "clusters file contains invalid cluster ID at index 0: 'cluster:name' - only alphanumeric characters and hyphens are allowed", + }, + { + name: "invalid JSON - missing quotes", + fileContent: `{clusters: [cluster1, cluster2]}`, + expectError: true, + errorContains: "failed to parse clusters file", + }, + { + name: "invalid JSON - malformed", + fileContent: `{"clusters": [cluster1]}`, + expectError: true, + errorContains: "failed to parse clusters file", + }, + { + name: "invalid JSON - missing closing brace", + fileContent: `{"clusters": ["cluster1", "cluster2"`, + expectError: true, + errorContains: "failed to parse clusters file", + }, + { + name: "wrong JSON structure - clusters not an array", + fileContent: `{"clusters": "cluster1"}`, + expectError: true, + errorContains: "failed to parse clusters file", + }, + { + name: "wrong JSON structure - missing clusters field", + fileContent: `{"items": ["cluster1", "cluster2"]}`, + expectError: false, + expectedIDs: []string{}, + }, + { + name: "empty file", + fileContent: ``, + expectError: true, + errorContains: "failed to parse clusters file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "clusters.json") + + err := os.WriteFile(tmpFile, []byte(tt.fileContent), 0600) + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + clusters, err := ParseAndValidateClustersFile(tmpFile) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err) + } + } else { + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } else { + // Verify the returned cluster IDs match expected + if len(clusters) != len(tt.expectedIDs) { + t.Errorf("Expected %d clusters, got %d", len(tt.expectedIDs), len(clusters)) + } else { + for i, expectedID := range tt.expectedIDs { + if clusters[i] != expectedID { + t.Errorf("Expected cluster ID at index %d to be '%s', got '%s'", i, expectedID, clusters[i]) + } + } + } + } + } + }) + } +} + +func TestParseAndValidateClustersFileNotFound(t *testing.T) { + nonExistentFile := "/path/that/does/not/exist/clusters.json" + + _, err := ParseAndValidateClustersFile(nonExistentFile) + + if err == nil { + t.Error("Expected error for non-existent file, but got none") + } + + if !strings.Contains(err.Error(), "failed to read clusters file") { + t.Errorf("Expected error to contain 'failed to read clusters file', got: %v", err) + } +} + +func TestValidClusterIDRegex(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "long alphanumeric cluster ID", + input: "2npb79qc3lqkrnn4g6u9cd9mqtlkb4gj", + expected: true, + }, + { + name: "cluster name", + input: "testhcp", + expected: true, + }, + { + name: "external ID (UUID)", + input: "a537f279-a25a-4b2b-b2d8-00b06d41ff2f", + expected: true, + }, + { + name: "simple alphanumeric", + input: "cluster123", + expected: true, + }, + { + name: "alphanumeric with hyphens", + input: "test-cluster-123", + expected: true, + }, + { + name: "single character", + input: "a", + expected: true, + }, + { + name: "single number", + input: "1", + expected: true, + }, + { + name: "single hyphen", + input: "-", + expected: true, + }, + { + name: "starts with hyphen", + input: "-cluster", + expected: true, + }, + { + name: "ends with hyphen", + input: "cluster-", + expected: true, + }, + { + name: "multiple consecutive hyphens", + input: "test--cluster", + expected: true, + }, + { + name: "empty string", + input: "", + expected: false, + }, + { + name: "contains underscore", + input: "test_cluster", + expected: false, + }, + { + name: "contains dot", + input: "test.cluster", + expected: false, + }, + { + name: "contains space", + input: "test cluster", + expected: false, + }, + { + name: "contains special characters", + input: "test@cluster!", + expected: false, + }, + { + name: "contains forward slash", + input: "cluster/path", + expected: false, + }, + { + name: "contains colon", + input: "cluster:name", + expected: false, + }, + { + name: "whitespace only", + input: " ", + expected: false, + }, + { + name: "tabs", + input: "\t", + expected: false, + }, + { + name: "newlines", + input: "\n", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validClusterIDRegex.MatchString(tt.input) + if result != tt.expected { + t.Errorf("For input '%s', expected %v, got %v", tt.input, tt.expected, result) + } + }) + } +} diff --git a/internal/servicelog/clustersFile.go b/internal/servicelog/clustersFile.go deleted file mode 100644 index 9a0a5cd2b..000000000 --- a/internal/servicelog/clustersFile.go +++ /dev/null @@ -1,5 +0,0 @@ -package servicelog - -type ClustersFile struct { - Clusters []string `json:"clusters"` -}