diff --git a/cmd/createtemplate.go b/cmd/createtemplate.go index 376bb35..bae9fbb 100644 --- a/cmd/createtemplate.go +++ b/cmd/createtemplate.go @@ -261,6 +261,33 @@ openshift: ssh_public_key_file: "~/.ssh/id_rsa.pub" # SSH public key for 'core' user access to the OpenShift nodes force_ocp_download: false # Set to true to force re-download of existing OpenShift artifacts + # --- ADVANCED OPENSHIFT NETWORK SETTINGS (optional, defaults shown) --- + # cluster_network_cidr: "10.128.0.0/14" # Pod network CIDR (default: 10.128.0.0/14) + # cluster_network_host_prefix: 23 # Per-node subnet prefix length (default: 23) + # service_network: "172.30.0.0/16" # Service/ClusterIP network CIDR (default: 172.30.0.0/16) + + # --- OCP CLIENT DOWNLOAD OVERRIDES (optional) --- + # Leave commented out to let ShiftLaunch auto-resolve download URLs from mirror.openshift.com. + # Useful for air-gapped environments, pinned versions, or custom mirrors. + # ocp_client_config: + # ocp_client: "https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/4.21.18/openshift-client-linux.tar.gz" + # ocp_installer: "https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/4.21.18/openshift-install-linux.tar.gz" +{{- if and (eq .IsolationLevel "air-gapped") (not .ExtRegistry) (ne .ReleaseType "ci")}} + # ocp_mirror_client: "https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/4.21.18/oc-mirror.tar.gz" +{{- end}} + # checksum_url: "https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/4.21.18/sha256sum.txt" + +{{- if ne .BootMethod "agent"}} + # --- RHCOS IMAGE OVERRIDES (optional) --- + # Leave commented out to let ShiftLaunch auto-resolve RHCOS image URLs. + # Required for air-gapped environments or when pinning a specific RHCOS build. + # rhcos_images: + # kernel_url: "https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/4.21/latest/rhcos-live-kernel.ppc64le" + # initramfs_url: "https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/4.21/latest/rhcos-live-initramfs.ppc64le.img" + # rootfs_url: "https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/4.21/latest/rhcos-live-rootfs.ppc64le.img" + # checksum_url: "https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/4.21/latest/sha256sum.txt" +{{- end}} + # ----------------------------------------------------------------------------- # 4. HMC CREDENTIALS (IBM Hardware Management Console) # ----------------------------------------------------------------------------- diff --git a/cmd/root.go b/cmd/root.go index 56cbaa7..3082f09 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,12 +18,26 @@ import ( "github.com/IBM/shiftlaunch/logger" "github.com/IBM/shiftlaunch/orchestrator" "github.com/IBM/shiftlaunch/types" + "github.com/IBM/shiftlaunch/utils" ) // version is dynamically injected via ldflags at compile time. // "dev-build" is the fallback if someone builds it without the Makefile. var version = "dev-build" +// Mirror URL format strings for ppc64le OCP artefacts. +// %s is the full version (e.g. "4.17.3") for client/installer paths, +// and the major.minor (e.g. "4.17") for RHCOS dependency paths. +const ( + mirrorClientFmt = "https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/%s/openshift-client-linux.tar.gz" + mirrorInstallerFmt = "https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/%s/openshift-install-linux.tar.gz" + mirrorOCMirrorFmt = "https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/%s/oc-mirror.tar.gz" + mirrorKernelFmt = "https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/%s/latest/rhcos-live-kernel.ppc64le" + mirrorInitramfsFmt = "https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/%s/latest/rhcos-live-initramfs.ppc64le.img" + mirrorRootfsFmt = "https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/%s/latest/rhcos-live-rootfs.ppc64le.img" + mirrorChecksumFmt = "https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/%s/latest/sha256sum.txt" +) + var ( // Global flags configFile string @@ -244,25 +258,41 @@ func loadConfig(requireConfig bool) (*types.AgentConfig, *config.AgentDaemonConf if parts := strings.Split(cfg.OpenShift.Version, "."); len(parts) >= 2 { majorMinor = parts[0] + "." + parts[1] } - if cfg.OpenShift.OCPClientConfig.Client == "" { - cfg.OpenShift.OCPClientConfig.Client = fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/%s/openshift-client-linux.tar.gz", cfg.OpenShift.Version) - } - if cfg.OpenShift.OCPClientConfig.Installer == "" { - cfg.OpenShift.OCPClientConfig.Installer = fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/%s/openshift-install-linux.tar.gz", cfg.OpenShift.Version) - } - // Auto-resolver for the oc-mirror plugin (required for air-gapped deployments) - if cfg.OpenShift.OCPClientConfig.MirrorClient == "" { - cfg.OpenShift.OCPClientConfig.MirrorClient = fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/ppc64le/clients/ocp/%s/oc-mirror.tar.gz", cfg.OpenShift.Version) - } - if cfg.Nodes.BootMethod != "agent" { - if cfg.OpenShift.RHCOSImages.KernelURL == "" { - cfg.OpenShift.RHCOSImages.KernelURL = fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/%s/latest/rhcos-live-kernel.ppc64le", majorMinor) + + // Pre-release versions (ec, rc, candidate, nightly, etc.) do not have stable + // mirror paths. Skip auto-resolving URLs and let validation enforce that the + // user explicitly provides them. + if !utils.IsPreReleaseVersion(cfg.OpenShift.Version) { + if cfg.OpenShift.OCPClientConfig.Client == "" { + cfg.OpenShift.OCPClientConfig.Client = fmt.Sprintf(mirrorClientFmt, cfg.OpenShift.Version) + } + if cfg.OpenShift.OCPClientConfig.Installer == "" { + cfg.OpenShift.OCPClientConfig.Installer = fmt.Sprintf(mirrorInstallerFmt, cfg.OpenShift.Version) + } + // Auto-resolver for the oc-mirror plugin: only needed for air-gapped managed-registry + // deployments with official releases (ci builds use 'oc adm release mirror' instead). + if cfg.OpenShift.OCPClientConfig.MirrorClient == "" && + cfg.Network.IsolationLevel == "air-gapped" && + cfg.Services.Registry.IsManaged() && + cfg.OpenShift.ReleaseType != "ci" { + cfg.OpenShift.OCPClientConfig.MirrorClient = fmt.Sprintf(mirrorOCMirrorFmt, cfg.OpenShift.Version) } - if cfg.OpenShift.RHCOSImages.InitramfsURL == "" { - cfg.OpenShift.RHCOSImages.InitramfsURL = fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/%s/latest/rhcos-live-initramfs.ppc64le.img", majorMinor) + if cfg.Nodes.BootMethod != "agent" { + if cfg.OpenShift.RHCOSImages.KernelURL == "" { + cfg.OpenShift.RHCOSImages.KernelURL = fmt.Sprintf(mirrorKernelFmt, majorMinor) + } + if cfg.OpenShift.RHCOSImages.InitramfsURL == "" { + cfg.OpenShift.RHCOSImages.InitramfsURL = fmt.Sprintf(mirrorInitramfsFmt, majorMinor) + } + if cfg.OpenShift.RHCOSImages.RootfsURL == "" { + cfg.OpenShift.RHCOSImages.RootfsURL = fmt.Sprintf(mirrorRootfsFmt, majorMinor) + } + if cfg.OpenShift.RHCOSImages.ChecksumURL == "" { + cfg.OpenShift.RHCOSImages.ChecksumURL = fmt.Sprintf(mirrorChecksumFmt, majorMinor) + } } - if cfg.OpenShift.RHCOSImages.RootfsURL == "" { - cfg.OpenShift.RHCOSImages.RootfsURL = fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/ppc64le/dependencies/rhcos/%s/latest/rhcos-live-rootfs.ppc64le.img", majorMinor) + if cfg.OpenShift.RHCOSImages.ChecksumURL == "" { + cfg.OpenShift.RHCOSImages.ChecksumURL = fmt.Sprintf(mirrorChecksumFmt, majorMinor) } } } diff --git a/cmd/scale.go b/cmd/scale.go index 49ab36e..ee80474 100644 --- a/cmd/scale.go +++ b/cmd/scale.go @@ -19,6 +19,7 @@ import ( "github.com/IBM/shiftlaunch/localexec" "github.com/IBM/shiftlaunch/services" "github.com/IBM/shiftlaunch/types" + "github.com/IBM/shiftlaunch/utils" ) // Day2NodesConfig represents the configuration for Day 2 nodes @@ -239,7 +240,7 @@ func runScale(cmd *cobra.Command, args []string) error { // 2. Identify added workers (Scale-Up) for _, worker := range updatedCfg.Nodes.Workers { readyMarker := "ready_" + worker.Hostname - if contains(state.CompletedPhases, readyMarker) { + if utils.Contains(state.CompletedPhases, readyMarker) { continue } @@ -255,7 +256,7 @@ func runScale(cmd *cobra.Command, args []string) error { scaleUpTargets = append(scaleUpTargets, worker) // If it hasn't been powered on by the HMC yet, it needs a boot - if !contains(state.CompletedPhases, "booted_"+worker.Hostname) { + if !utils.Contains(state.CompletedPhases, "booted_"+worker.Hostname) { pendingBoot = append(pendingBoot, worker) } } @@ -763,7 +764,7 @@ func runScale(cmd *cobra.Command, args []string) error { state, _ = stateManager.LoadState() for _, w := range scaleUpTargets { readyMarker := "ready_" + w.Hostname - if !contains(state.CompletedPhases, readyMarker) { + if !utils.Contains(state.CompletedPhases, readyMarker) { state.CompletedPhases = append(state.CompletedPhases, readyMarker) } } @@ -775,12 +776,4 @@ func runScale(cmd *cobra.Command, args []string) error { } // contains function is for search a string in a slice of strings -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} diff --git a/orchestrator/orchestrator.go b/orchestrator/orchestrator.go index 5418fec..40ba880 100644 --- a/orchestrator/orchestrator.go +++ b/orchestrator/orchestrator.go @@ -15,6 +15,7 @@ import ( "github.com/IBM/shiftlaunch/logger" "github.com/IBM/shiftlaunch/services" "github.com/IBM/shiftlaunch/types" + "github.com/IBM/shiftlaunch/utils" "github.com/pterm/pterm" "go.yaml.in/yaml/v3" ) @@ -76,7 +77,7 @@ func (o *Orchestrator) saveState(phase string) { } o.state.CurrentPhase = phase - if !contains(o.state.CompletedPhases, phase) { + if !utils.Contains(o.state.CompletedPhases, phase) { o.state.CompletedPhases = append(o.state.CompletedPhases, phase) } _ = o.stateManager.SaveState(o.state) @@ -288,7 +289,7 @@ func (o *Orchestrator) Deploy(ctx context.Context, resume bool) (err error) { } // --- PHASE 1: DISCOVERY --- - if !resume || !contains(o.state.CompletedPhases, "discovery") { + if !resume || !utils.Contains(o.state.CompletedPhases, "discovery") { phaseExec := o.startPhase("discovery") o.logger.StartPhase("[Phase 1/6] Pre-Flight & HMC Discovery") @@ -323,7 +324,7 @@ func (o *Orchestrator) Deploy(ctx context.Context, resume bool) (err error) { } // --- PHASE 2: DOWNLOADS --- - needsDownloads := !resume || !contains(o.state.CompletedPhases, "downloads") + needsDownloads := !resume || !utils.Contains(o.state.CompletedPhases, "downloads") // --- LBYL Safety Check for Missing Binaries --- if !needsDownloads { @@ -352,7 +353,7 @@ func (o *Orchestrator) Deploy(ctx context.Context, resume bool) (err error) { } // --- PHASE 3: MANAGED SERVICES --- - if !resume || !contains(o.state.CompletedPhases, "services") { + if !resume || !utils.Contains(o.state.CompletedPhases, "services") { phaseExec := o.startPhase("services") o.logger.StartPhase("[Phase 3/6] Configuring Managed Infrastructure Services") @@ -588,7 +589,7 @@ func (o *Orchestrator) Deploy(ctx context.Context, resume bool) (err error) { } // --- PHASE 4: IGNITION GENERATION --- - needsIgnition := !resume || !contains(o.state.CompletedPhases, "ignition") + needsIgnition := !resume || !utils.Contains(o.state.CompletedPhases, "ignition") if !needsIgnition { if _, err := os.Stat(filepath.Join(o.workspaceDir, "install-dir")); os.IsNotExist(err) { @@ -677,7 +678,7 @@ func (o *Orchestrator) Deploy(ctx context.Context, resume bool) (err error) { } // --- PHASE 5: BOOT --- - if !resume || !contains(o.state.CompletedPhases, "boot") { + if !resume || !utils.Contains(o.state.CompletedPhases, "boot") { phaseExec := o.startPhase("boot") o.logger.StartPhase("[Phase 5/6] Initiating Cluster Boot") @@ -720,7 +721,7 @@ func (o *Orchestrator) Deploy(ctx context.Context, resume bool) (err error) { } // --- PHASE 6: WAIT FOR INSTALLATION --- - if !resume || !contains(o.state.CompletedPhases, "wait") { + if !resume || !utils.Contains(o.state.CompletedPhases, "wait") { phaseExec := o.startPhase("wait") o.logger.StartPhase("[Phase 6/6] Waiting for OpenShift Installation") @@ -918,15 +919,6 @@ func (o *Orchestrator) DumpConfigs(ctx context.Context) error { return nil } -// Helper to check if a string exists in a slice -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} // findClusterUsingVIP searches all managed clusters to find if any is using the given VIP func (o *Orchestrator) findClusterUsingVIP(vip string) string { diff --git a/services/downloader.go b/services/downloader.go index 926a6ef..3954c53 100644 --- a/services/downloader.go +++ b/services/downloader.go @@ -66,6 +66,19 @@ func (d *Downloader) DownloadRHCOSImages(ctx context.Context, workspaceDir strin manifestPath := filepath.Join(rhcosDir, "sha256sum.txt") + // Fetch the checksum manifest if a URL is available (auto-resolved by root.go + // when rhcos_images is omitted, or explicitly set by the user). + if urls.ChecksumURL != "" { + d.logger.Info("Integrity Mode: Fetching fresh RHCOS checksum manifest", "url", urls.ChecksumURL) + _ = os.Remove(manifestPath) + dlManifestCmd := fmt.Sprintf("curl -sSL --fail --max-time %d -o %s -- %s", timeout, shellQuote(manifestPath), shellQuote(urls.ChecksumURL)) + if _, err := d.exec.Execute(ctx, dlManifestCmd); err != nil { + d.logger.Warn("Failed to fetch RHCOS checksum manifest", "error", err) + } else { + d.logger.Info("RHCOS checksum manifest downloaded") + } + } + images := []struct { url string filename string diff --git a/services/httphelper.go b/services/httphelper.go index 2323b77..77e629f 100644 --- a/services/httphelper.go +++ b/services/httphelper.go @@ -47,7 +47,7 @@ check_ignition() { check_rhcos() { echo "Checking RHCOS images..." local found=0 - for file in rhcos-live-kernel-ppc64le rhcos-live-initramfs.ppc64le.img rhcos-live-rootfs.ppc64le.img; do + for file in rhcos-live-kernel.ppc64le rhcos-live-initramfs.ppc64le.img rhcos-live-rootfs.ppc64le.img; do if [ -f "${HTTP_DIR}/rhcos/${file}" ]; then local size=$(du -h "${HTTP_DIR}/rhcos/${file}" | awk '{print $1}') echo " ${file} (${size})" diff --git a/services/registry.go b/services/registry.go index 3a2fd89..f07dce6 100644 --- a/services/registry.go +++ b/services/registry.go @@ -12,6 +12,7 @@ import ( "github.com/IBM/shiftlaunch/localexec" "github.com/IBM/shiftlaunch/logger" "github.com/IBM/shiftlaunch/types" + "github.com/IBM/shiftlaunch/utils" "gopkg.in/yaml.v3" ) @@ -252,7 +253,7 @@ func (r *RegistryManager) Setup(ctx context.Context, workspaceDir string) error mirrorEventID := fmt.Sprintf("mirror_release_%s", r.cfg.OpenShift.Version) // Check if mirroring was already completed - if r.state != nil && contains(r.state.CompletedEvents, mirrorEventID) { + if r.state != nil && utils.Contains(r.state.CompletedEvents, mirrorEventID) { r.logger.Info("Image mirroring already completed, skipping...") } else { r.logger.Info("Mirroring OpenShift release images (this may take 15-30 minutes)...") @@ -515,14 +516,6 @@ func (r *RegistryManager) isRegistryShared() bool { } // Helper function to check if a string exists in a slice -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} // shellQuote wraps s in single quotes and escapes any literal single quotes // inside it using the '\'' idiom, making the value safe to embed in a bash diff --git a/types/types.go b/types/types.go index 2e8a338..9febc45 100644 --- a/types/types.go +++ b/types/types.go @@ -268,6 +268,7 @@ type RHCOSURLs struct { KernelURL string `yaml:"kernel_url,omitempty"` InitramfsURL string `yaml:"initramfs_url,omitempty"` RootfsURL string `yaml:"rootfs_url,omitempty"` + ChecksumURL string `yaml:"checksum_url,omitempty"` } type OCPClientConfig struct { diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..68df51d --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,39 @@ +// Package utils provides small, reusable helpers shared across shiftlaunch packages. +package utils + +import ( + "regexp" + "strings" +) + +// versionRe matches a valid OpenShift version: MAJOR.MINOR.PATCH with an optional pre-release suffix. +// Examples: 4.17.3, 4.21.0-rc.2, 4.21.0-0.nightly-2025-01-01 +var versionRe = regexp.MustCompile(`^\d+\.\d+\.\d+(-\S+)?$`) + +// Contains reports whether item is present in slice. +func Contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// IsValidVersion reports whether version matches the expected MAJOR.MINOR.PATCH[‑suffix] format. +func IsValidVersion(version string) bool { + return versionRe.MatchString(version) +} + +// IsPreReleaseVersion returns true if the version string contains any pre-release +// marker that would not have a stable path on mirror.openshift.com. +// Examples: 4.21.0-ec.1, 4.21.0-rc.2, 4.21.0-candidate, 4.21.0-0.nightly-2025-01-01 +func IsPreReleaseVersion(version string) bool { + lower := strings.ToLower(version) + for _, marker := range []string{"ec", "rc", "candidate", "nightly", "pre", "alpha", "beta"} { + if strings.Contains(lower, "-"+marker) { + return true + } + } + return false +} diff --git a/validation/validator.go b/validation/validator.go index 93af6d6..9d20fe8 100644 --- a/validation/validator.go +++ b/validation/validator.go @@ -15,6 +15,7 @@ import ( "github.com/IBM/shiftlaunch/localexec" "github.com/IBM/shiftlaunch/logger" "github.com/IBM/shiftlaunch/types" + "github.com/IBM/shiftlaunch/utils" "go.yaml.in/yaml/v3" ) @@ -297,6 +298,8 @@ func (v *Validator) validateOpenShift() { if o.Version == "" { v.errors = append(v.errors, "openshift.version is required") + } else if !utils.IsValidVersion(o.Version) { + v.errors = append(v.errors, fmt.Sprintf("openshift.version '%s' is invalid: expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH- (e.g. 4.17.3, 4.21.0-rc.2)", o.Version)) } // Validate strict enum for Release Type @@ -324,36 +327,66 @@ func (v *Validator) validateOpenShift() { } } + preRelease := utils.IsPreReleaseVersion(o.Version) + // Skip RHCOS validation for Agent boot (Agent installer downloads RHCOS automatically) if v.cfg.Nodes.BootMethod == "agent" { v.log.Info("Skipping RHCOS image validation for Agent ISO boot (Agent installer downloads RHCOS automatically)") - // Still validate OCP client config + // OCP client URLs are always required for agent boot if o.OCPClientConfig.Client == "" { - v.errors = append(v.errors, "openshift.ocp_client_config.ocp_client is required") + if preRelease { + v.errors = append(v.errors, fmt.Sprintf("openshift.ocp_client_config.ocp_client is required: version '%s' is a pre-release and has no stable mirror path — provide the URL explicitly", o.Version)) + } else { + v.errors = append(v.errors, "openshift.ocp_client_config.ocp_client is required") + } } if o.OCPClientConfig.Installer == "" { - v.errors = append(v.errors, "openshift.ocp_client_config.ocp_installer is required") + if preRelease { + v.errors = append(v.errors, fmt.Sprintf("openshift.ocp_client_config.ocp_installer is required: version '%s' is a pre-release and has no stable mirror path — provide the URL explicitly", o.Version)) + } else { + v.errors = append(v.errors, "openshift.ocp_client_config.ocp_installer is required") + } } return // Skip RHCOS URL validation } // Validate RHCOS URLs for netboot if o.RHCOSImages.KernelURL == "" { - v.errors = append(v.errors, "openshift.rhcos_images.kernel_url is required") + if preRelease { + v.errors = append(v.errors, fmt.Sprintf("openshift.rhcos_images.kernel_url is required: version '%s' is a pre-release and has no stable mirror path — provide the URL explicitly", o.Version)) + } else { + v.errors = append(v.errors, "openshift.rhcos_images.kernel_url is required") + } } if o.RHCOSImages.InitramfsURL == "" { - v.errors = append(v.errors, "openshift.rhcos_images.initramfs_url is required") + if preRelease { + v.errors = append(v.errors, fmt.Sprintf("openshift.rhcos_images.initramfs_url is required: version '%s' is a pre-release and has no stable mirror path — provide the URL explicitly", o.Version)) + } else { + v.errors = append(v.errors, "openshift.rhcos_images.initramfs_url is required") + } } if o.RHCOSImages.RootfsURL == "" { - v.errors = append(v.errors, "openshift.rhcos_images.rootfs_url is required") + if preRelease { + v.errors = append(v.errors, fmt.Sprintf("openshift.rhcos_images.rootfs_url is required: version '%s' is a pre-release and has no stable mirror path — provide the URL explicitly", o.Version)) + } else { + v.errors = append(v.errors, "openshift.rhcos_images.rootfs_url is required") + } } // Validate OCP client config for netboot (already validated above for agent boot) if o.OCPClientConfig.Client == "" { - v.errors = append(v.errors, "openshift.ocp_client_config.ocp_client is required") + if preRelease { + v.errors = append(v.errors, fmt.Sprintf("openshift.ocp_client_config.ocp_client is required: version '%s' is a pre-release and has no stable mirror path — provide the URL explicitly", o.Version)) + } else { + v.errors = append(v.errors, "openshift.ocp_client_config.ocp_client is required") + } } if o.OCPClientConfig.Installer == "" { - v.errors = append(v.errors, "openshift.ocp_client_config.ocp_installer is required") + if preRelease { + v.errors = append(v.errors, fmt.Sprintf("openshift.ocp_client_config.ocp_installer is required: version '%s' is a pre-release and has no stable mirror path — provide the URL explicitly", o.Version)) + } else { + v.errors = append(v.errors, "openshift.ocp_client_config.ocp_installer is required") + } } } @@ -1114,4 +1147,3 @@ func (v *Validator) validateNodeIPsNotAlive(ctx context.Context) { // Add all collected errors to validator errors v.errors = append(v.errors, conflictErrors...) } -