From 1b2454f4a9e56f501b3c67a39ad82f3ba1e26b04 Mon Sep 17 00:00:00 2001 From: Brandon Palm Date: Tue, 2 Dec 2025 13:38:24 -0600 Subject: [PATCH] Add bundle download cmd Address comments 1 Address comments; consts, split bundle commands into files --- cmd/crc/cmd/bundle/bundle.go | 6 +- cmd/crc/cmd/bundle/clear.go | 57 ++++++++++++ cmd/crc/cmd/bundle/download.go | 156 +++++++++++++++++++++++++++++++++ cmd/crc/cmd/bundle/list.go | 69 +++++++++++++++ cmd/crc/cmd/bundle/prune.go | 71 +++++++++++++++ cmd/crc/cmd/bundle/util.go | 136 ++++++++++++++++++++++++++++ cmd/crc/cmd/root_test.go | 4 + pkg/crc/constants/constants.go | 9 +- 8 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 cmd/crc/cmd/bundle/clear.go create mode 100644 cmd/crc/cmd/bundle/download.go create mode 100644 cmd/crc/cmd/bundle/list.go create mode 100644 cmd/crc/cmd/bundle/prune.go create mode 100644 cmd/crc/cmd/bundle/util.go diff --git a/cmd/crc/cmd/bundle/bundle.go b/cmd/crc/cmd/bundle/bundle.go index 70b54b5fb9..8934911a60 100644 --- a/cmd/crc/cmd/bundle/bundle.go +++ b/cmd/crc/cmd/bundle/bundle.go @@ -9,11 +9,15 @@ func GetBundleCmd(config *config.Config) *cobra.Command { bundleCmd := &cobra.Command{ Use: "bundle SUBCOMMAND [flags]", Short: "Manage CRC bundles", - Long: "Manage CRC bundles", + Long: "Manage CRC bundles, including downloading, listing, and cleaning up cached bundles.", Run: func(cmd *cobra.Command, _ []string) { _ = cmd.Help() }, } bundleCmd.AddCommand(getGenerateCmd(config)) + bundleCmd.AddCommand(getDownloadCmd(config)) + bundleCmd.AddCommand(getListCmd(config)) + bundleCmd.AddCommand(getClearCmd()) + bundleCmd.AddCommand(getPruneCmd()) return bundleCmd } diff --git a/cmd/crc/cmd/bundle/clear.go b/cmd/crc/cmd/bundle/clear.go new file mode 100644 index 0000000000..b86bfd9cf0 --- /dev/null +++ b/cmd/crc/cmd/bundle/clear.go @@ -0,0 +1,57 @@ +package bundle + +import ( + "os" + "path/filepath" + "strings" + + "github.com/crc-org/crc/v2/pkg/crc/constants" + "github.com/crc-org/crc/v2/pkg/crc/logging" + "github.com/spf13/cobra" +) + +func getClearCmd() *cobra.Command { + return &cobra.Command{ + Use: "clear", + Short: "Clear cached CRC bundles", + Long: "Delete all downloaded CRC bundles from the cache directory.", + RunE: func(cmd *cobra.Command, args []string) error { + return runClear() + }, + } +} + +func runClear() error { + cacheDir := constants.MachineCacheDir + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + logging.Infof("Cache directory %s does not exist", cacheDir) + return nil + } + + files, err := os.ReadDir(cacheDir) + if err != nil { + return err + } + + cleared := false + var lastErr error + for _, file := range files { + if strings.HasSuffix(file.Name(), ".crcbundle") { + filePath := filepath.Join(cacheDir, file.Name()) + logging.Infof("Deleting %s", filePath) + if err := os.RemoveAll(filePath); err != nil { + logging.Errorf("Failed to remove %s: %v", filePath, err) + lastErr = err + } else { + cleared = true + } + } + } + + if !cleared && lastErr == nil { + logging.Infof("No bundles found in %s", cacheDir) + } else if cleared { + logging.Infof("Cleared cached bundles in %s", cacheDir) + } + return lastErr +} diff --git a/cmd/crc/cmd/bundle/download.go b/cmd/crc/cmd/bundle/download.go new file mode 100644 index 0000000000..764e5d17cc --- /dev/null +++ b/cmd/crc/cmd/bundle/download.go @@ -0,0 +1,156 @@ +package bundle + +import ( + "context" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + crcConfig "github.com/crc-org/crc/v2/pkg/crc/config" + "github.com/crc-org/crc/v2/pkg/crc/constants" + "github.com/crc-org/crc/v2/pkg/crc/gpg" + "github.com/crc-org/crc/v2/pkg/crc/logging" + "github.com/crc-org/crc/v2/pkg/crc/machine/bundle" + crcPreset "github.com/crc-org/crc/v2/pkg/crc/preset" + "github.com/crc-org/crc/v2/pkg/download" + "github.com/spf13/cobra" +) + +func getDownloadCmd(config *crcConfig.Config) *cobra.Command { + downloadCmd := &cobra.Command{ + Use: "download [version] [architecture]", + Short: "Download a specific CRC bundle", + Long: "Download a specific CRC bundle from the mirrors. If no version or architecture is specified, the bundle for the current CRC version will be downloaded.", + RunE: func(cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") + presetStr, _ := cmd.Flags().GetString("preset") + + var preset crcPreset.Preset + if presetStr != "" { + var err error + preset, err = crcPreset.ParsePresetE(presetStr) + if err != nil { + return err + } + } else { + preset = crcConfig.GetPreset(config) + } + + return runDownload(args, preset, force) + }, + } + downloadCmd.Flags().BoolP("force", "f", false, "Overwrite existing bundle if present") + downloadCmd.Flags().StringP("preset", "p", "", "Target preset (openshift, okd, microshift)") + + return downloadCmd +} + +func runDownload(args []string, preset crcPreset.Preset, force bool) error { + // Disk space check (simple check for ~10GB free) + // This is a basic check, more robust checking would require syscall/windows specific implementations + // We skip this for now to avoid adding heavy OS-specific deps, assuming user manages disk space or download fails naturally. + + // If no args, use default bundle path + if len(args) == 0 { + defaultBundlePath := constants.GetDefaultBundlePath(preset) + if !force { + if _, err := os.Stat(defaultBundlePath); err == nil { + logging.Infof("Bundle %s already exists. Use --force to overwrite.", defaultBundlePath) + return nil + } + } + + logging.Debugf("Source: %s", constants.GetDefaultBundleDownloadURL(preset)) + logging.Debugf("Destination: %s", defaultBundlePath) + // For default bundle, we use the existing logic which handles verification internally + _, err := bundle.Download(context.Background(), preset, defaultBundlePath, false) + return err + } + + // If args provided, we are constructing a URL + version := args[0] + + // Check if version is partial (Major.Minor) and resolve it if necessary + resolvedVersion, err := resolveOpenShiftVersion(preset, version) + if err != nil { + logging.Warnf("Could not resolve version %s: %v. Trying with original version string.", version, err) + } else if resolvedVersion != version { + logging.Debugf("Resolved version %s to %s", version, resolvedVersion) + version = resolvedVersion + } + + architecture := runtime.GOARCH + if len(args) > 1 { + architecture = args[1] + } + + bundleName := constants.BundleName(preset, version, architecture) + bundlePath := filepath.Join(constants.MachineCacheDir, bundleName) + + if !force { + if _, err := os.Stat(bundlePath); err == nil { + logging.Infof("Bundle %s already exists. Use --force to overwrite.", bundleName) + return nil + } + } + + // Base URL for the directory containing the bundle and signature + baseVersionURL := fmt.Sprintf("%s/%s/%s/", constants.DefaultMirrorURL, preset.String(), version) + bundleURL := fmt.Sprintf("%s%s", baseVersionURL, bundleName) + sigURL := fmt.Sprintf("%s%s", baseVersionURL, "sha256sum.txt.sig") + + logging.Infof("Downloading bundle: %s", bundleName) + logging.Debugf("Source: %s", bundleURL) + logging.Debugf("Destination: %s", constants.MachineCacheDir) + + // Implement verification logic + logging.Infof("Verifying signature for %s...", version) + sha256sum, err := getVerifiedHashForCustomVersion(sigURL, bundleName) + if err != nil { + // Fallback: try without .sig if .sig not found, maybe just sha256sum.txt? + // For now, fail if signature verification fails as requested for "Safeguards" + return fmt.Errorf("signature verification failed: %w", err) + } + + sha256bytes, err := hex.DecodeString(sha256sum) + if err != nil { + return fmt.Errorf("failed to decode sha256sum: %w", err) + } + + _, err = download.Download(context.Background(), bundleURL, bundlePath, 0664, sha256bytes) + return err +} + +func getVerifiedHashForCustomVersion(sigURL string, bundleName string) (string, error) { + // Reuse existing verification logic from bundle package via a helper here + // We essentially replicate getVerifiedHash but with our custom URL + + res, err := download.InMemory(sigURL) + if err != nil { + return "", fmt.Errorf("failed to fetch signature file: %w", err) + } + defer res.Close() + + signedHashes, err := io.ReadAll(res) + if err != nil { + return "", fmt.Errorf("failed to read signature file: %w", err) + } + + verifiedHashes, err := gpg.GetVerifiedClearsignedMsgV3(constants.RedHatReleaseKey, string(signedHashes)) + if err != nil { + return "", fmt.Errorf("invalid signature: %w", err) + } + + lines := strings.Split(verifiedHashes, "\n") + for _, line := range lines { + if strings.HasSuffix(line, bundleName) { + sha256sum := strings.TrimSuffix(line, " "+bundleName) + return sha256sum, nil + } + } + return "", fmt.Errorf("hash for %s not found in signature file", bundleName) +} diff --git a/cmd/crc/cmd/bundle/list.go b/cmd/crc/cmd/bundle/list.go new file mode 100644 index 0000000000..0ac55485f3 --- /dev/null +++ b/cmd/crc/cmd/bundle/list.go @@ -0,0 +1,69 @@ +package bundle + +import ( + "fmt" + "runtime" + + "github.com/Masterminds/semver/v3" + crcConfig "github.com/crc-org/crc/v2/pkg/crc/config" + "github.com/crc-org/crc/v2/pkg/crc/logging" + "github.com/spf13/cobra" +) + +func getListCmd(config *crcConfig.Config) *cobra.Command { + return &cobra.Command{ + Use: "list [version]", + Short: "List available CRC bundles", + Long: "List available CRC bundles from the mirrors. Optionally filter by major.minor version (e.g. 4.19).", + RunE: func(cmd *cobra.Command, args []string) error { + return runList(args, config) + }, + } +} + +func runList(args []string, config *crcConfig.Config) error { + if len(args) > 1 { + return fmt.Errorf("too many arguments: expected at most 1 version filter, got %d", len(args)) + } + + preset := crcConfig.GetPreset(config) + versions, err := fetchAvailableVersions(preset) + if err != nil { + return err + } + + if len(versions) == 0 { + logging.Infof("No bundles found for preset %s", preset) + return nil + } + + var filter *semver.Version + if len(args) > 0 { + v, err := semver.NewVersion(args[0] + ".0") // Treat 4.19 as 4.19.0 for partial matching + if err == nil { + filter = v + } else { + // Try parsing as full version just in case + v, err = semver.NewVersion(args[0]) + if err == nil { + filter = v + } + } + } + + logging.Infof("Available bundles for %s:", preset) + for _, v := range versions { + if filter != nil { + if v.Major() != filter.Major() || v.Minor() != filter.Minor() { + continue + } + } + + cachedStr := "" + if isBundleCached(preset, v.String(), runtime.GOARCH) { + cachedStr = " (cached)" + } + fmt.Printf("%s%s\n", v.String(), cachedStr) + } + return nil +} diff --git a/cmd/crc/cmd/bundle/prune.go b/cmd/crc/cmd/bundle/prune.go new file mode 100644 index 0000000000..219f6be59e --- /dev/null +++ b/cmd/crc/cmd/bundle/prune.go @@ -0,0 +1,71 @@ +package bundle + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/crc-org/crc/v2/pkg/crc/constants" + "github.com/crc-org/crc/v2/pkg/crc/logging" + "github.com/spf13/cobra" +) + +func getPruneCmd() *cobra.Command { + return &cobra.Command{ + Use: "prune", + Short: "Prune old CRC bundles", + Long: "Keep only the most recent bundles and delete older ones to save space.", + RunE: func(cmd *cobra.Command, args []string) error { + // Default keep 2 most recent + return runPrune(2) + }, + } +} + +func runPrune(keep int) error { + cacheDir := constants.MachineCacheDir + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + logging.Infof("Cache directory %s does not exist", cacheDir) + return nil + } + + files, err := os.ReadDir(cacheDir) + if err != nil { + return err + } + + var bundleFiles []os.DirEntry + for _, file := range files { + if strings.HasSuffix(file.Name(), ".crcbundle") { + bundleFiles = append(bundleFiles, file) + } + } + + if len(bundleFiles) <= keep { + logging.Infof("Nothing to prune (found %d bundles, keeping %d)", len(bundleFiles), keep) + return nil + } + + // Sort by modification time, newest first + sort.Slice(bundleFiles, func(i, j int) bool { + infoI, errI := bundleFiles[i].Info() + infoJ, errJ := bundleFiles[j].Info() + if errI != nil || errJ != nil { + // If we can't get info, treat as oldest (sort to end for pruning) + return errJ != nil && errI == nil + } + return infoI.ModTime().After(infoJ.ModTime()) + }) + + for i := keep; i < len(bundleFiles); i++ { + file := bundleFiles[i] + filePath := filepath.Join(cacheDir, file.Name()) + logging.Infof("Pruning old bundle: %s", file.Name()) + if err := os.RemoveAll(filePath); err != nil { + logging.Errorf("Failed to remove %s: %v", filePath, err) + } + } + + return nil +} diff --git a/cmd/crc/cmd/bundle/util.go b/cmd/crc/cmd/bundle/util.go new file mode 100644 index 0000000000..5516ede673 --- /dev/null +++ b/cmd/crc/cmd/bundle/util.go @@ -0,0 +1,136 @@ +package bundle + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/crc-org/crc/v2/pkg/crc/constants" + "github.com/crc-org/crc/v2/pkg/crc/logging" + "github.com/crc-org/crc/v2/pkg/crc/network/httpproxy" + crcPreset "github.com/crc-org/crc/v2/pkg/crc/preset" +) + +func fetchAvailableVersions(preset crcPreset.Preset) ([]*semver.Version, error) { + // Base URL for the preset + baseURL := fmt.Sprintf("%s/%s/", constants.DefaultMirrorURL, preset.String()) + + client := &http.Client{ + Transport: httpproxy.HTTPTransport(), + Timeout: 10 * time.Second, + } + + req, err := http.NewRequest("GET", baseURL, nil) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch versions from mirror: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Parse the HTML directory listing to find version directories + versionRegex := regexp.MustCompile(`href=["']?\.?/?(\d+\.\d+\.\d+)/?["']?`) + + matches := versionRegex.FindAllStringSubmatch(string(body), -1) + + var versions []*semver.Version + seen := make(map[string]bool) + + for _, match := range matches { + if len(match) > 1 { + vStr := match[1] + if seen[vStr] { + continue + } + v, err := semver.NewVersion(vStr) + if err == nil { + versions = append(versions, v) + seen[vStr] = true + } + } + } + + // If regex failed, try a simpler one for directory names in text + if len(versions) == 0 { + simpleRegex := regexp.MustCompile(`>(\d+\.\d+\.\d+)/?<`) + matches = simpleRegex.FindAllStringSubmatch(string(body), -1) + for _, match := range matches { + if len(match) > 1 { + vStr := match[1] + if seen[vStr] { + continue + } + v, err := semver.NewVersion(vStr) + if err == nil { + versions = append(versions, v) + seen[vStr] = true + } + } + } + } + + // Sort reverse (newest first) + sort.Sort(sort.Reverse(semver.Collection(versions))) + return versions, nil +} + +func resolveOpenShiftVersion(preset crcPreset.Preset, inputVersion string) (string, error) { + // If input already looks like a full version (Major.Minor.Patch), return as is + fullVersionRegex := regexp.MustCompile(`^\d+\.\d+\.\d+$`) + if fullVersionRegex.MatchString(inputVersion) { + return inputVersion, nil + } + + // If not Major.Minor, return as is (could be a tag or other format user intends) + partialVersionRegex := regexp.MustCompile(`^(\d+\.\d+)$`) + if !partialVersionRegex.MatchString(inputVersion) { + return inputVersion, nil + } + + logging.Debugf("Resolving latest version for %s...", inputVersion) + + versions, err := fetchAvailableVersions(preset) + if err != nil { + return "", err + } + + inputVer, err := semver.NewVersion(inputVersion + ".0") + if err != nil { + return "", fmt.Errorf("invalid input version format: %v", err) + } + + for _, v := range versions { + if v.Major() == inputVer.Major() && v.Minor() == inputVer.Minor() { + return v.String(), nil + } + } + + return "", fmt.Errorf("no matching versions found for %s", inputVersion) +} + +func isBundleCached(preset crcPreset.Preset, version string, arch string) bool { + bundleName := constants.BundleName(preset, version, arch) + bundlePath := filepath.Join(constants.MachineCacheDir, bundleName) + if _, err := os.Stat(bundlePath); err == nil { + return true + } + return false +} diff --git a/cmd/crc/cmd/root_test.go b/cmd/crc/cmd/root_test.go index 47f3bf0af2..1d25f1bd4e 100644 --- a/cmd/crc/cmd/root_test.go +++ b/cmd/crc/cmd/root_test.go @@ -23,7 +23,11 @@ func TestCrcManPageGenerator_WhenInvoked_GeneratesManPagesForAllCrcSubCommands(t manPagesFiles = append(manPagesFiles, manPage.Name()) } assert.ElementsMatch(t, []string{ + "crc-bundle-clear.1", + "crc-bundle-download.1", "crc-bundle-generate.1", + "crc-bundle-list.1", + "crc-bundle-prune.1", "crc-bundle.1", "crc-cleanup.1", "crc-config-get.1", diff --git a/pkg/crc/constants/constants.go b/pkg/crc/constants/constants.go index 6fc779b424..9b9b607e3c 100644 --- a/pkg/crc/constants/constants.go +++ b/pkg/crc/constants/constants.go @@ -30,7 +30,8 @@ const ( CrcLandingPageURL = "https://console.redhat.com/openshift/create/local" // #nosec G101 DefaultAdminHelperURLBase = "https://github.com/crc-org/admin-helper/releases/download/v%s/%s" BackgroundLauncherURL = "https://github.com/crc-org/win32-background-launcher/releases/download/v%s/win32-background-launcher.exe" - DefaultBundleURLBase = "https://mirror.openshift.com/pub/openshift-v4/clients/crc/bundles/%s/%s/%s" + DefaultMirrorURL = "https://mirror.openshift.com/pub/openshift-v4/clients/crc/bundles" + DefaultBundleURLBase = DefaultMirrorURL + "/%s/%s/%s" DefaultContext = "admin" DefaultDeveloperPassword = "developer" DaemonHTTPEndpoint = "http://unix/api" @@ -81,6 +82,10 @@ func GetAdminHelperURL() string { } func BundleForPreset(preset crcpreset.Preset, version string) string { + return BundleName(preset, version, runtime.GOARCH) +} + +func BundleName(preset crcpreset.Preset, version string, arch string) string { var bundleName strings.Builder bundleName.WriteString("crc") @@ -101,7 +106,7 @@ func BundleForPreset(preset crcpreset.Preset, version string) string { bundleName.WriteString("_hyperv") } - fmt.Fprintf(&bundleName, "_%s_%s.crcbundle", version, runtime.GOARCH) + fmt.Fprintf(&bundleName, "_%s_%s.crcbundle", version, arch) return bundleName.String() }