Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/crc/cmd/bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
55 changes: 55 additions & 0 deletions cmd/crc/cmd/bundle/clear.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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
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)
}
cleared = true
}
}

if !cleared {
logging.Infof("No bundles found in %s", cacheDir)
} else {
logging.Infof("Cleared cached bundles in %s", cacheDir)
}
return nil
}

156 changes: 156 additions & 0 deletions cmd/crc/cmd/bundle/download.go
Original file line number Diff line number Diff line change
@@ -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)
}
65 changes: 65 additions & 0 deletions cmd/crc/cmd/bundle/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package bundle

import (
"fmt"

"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 {
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
}
}
}
Comment on lines +23 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add validation for the number of arguments.

The function checks len(args) > 0 to determine if a filter is provided, but doesn't validate that at most one argument is supplied. If a user provides multiple arguments (e.g., crc bundle list 4.19 4.20), only the first is used and the rest are silently ignored, which may confuse users.

🔎 Apply this diff to add argument validation:
 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)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func runList(args []string, config *crcConfig.Config) error {
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
}
}
}
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
}
}
}
🤖 Prompt for AI Agents
In cmd/crc/cmd/bundle/list.go around lines 23 to 47, the handler accepts a
filter if len(args) > 0 but does not validate that the caller provided at most
one argument, causing extra args to be silently ignored; add validation that
returns a clear error (or usage message) when len(args) > 1 so the command fails
fast and informs the user that only a single version filter is accepted,
otherwise proceed to parse args[0] as currently implemented.


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()) {
cachedStr = " (cached)"
}
fmt.Printf("%s%s\n", v.String(), cachedStr)
}
return nil
}

68 changes: 68 additions & 0 deletions cmd/crc/cmd/bundle/prune.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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, _ := bundleFiles[i].Info()
infoJ, _ := bundleFiles[j].Info()
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
}

Loading
Loading