Skip to content

Commit 452b940

Browse files
committed
Issue #5037 Add bundle download command
Add download, list, clear, and prune subcommands to crc bundle. Download supports preset/version/arch selection, mirror-based resolution, and signature-verified retrieval. Address review feedback: reject extra args, fail fast on version resolution, fix CRLF hash lookup, and implement version-aware per-group pruning.
1 parent 1b3aed9 commit 452b940

File tree

8 files changed

+563
-3
lines changed

8 files changed

+563
-3
lines changed

cmd/crc/cmd/bundle/bundle.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ func GetBundleCmd(config *config.Config) *cobra.Command {
99
bundleCmd := &cobra.Command{
1010
Use: "bundle SUBCOMMAND [flags]",
1111
Short: "Manage CRC bundles",
12-
Long: "Manage CRC bundles",
12+
Long: "Manage CRC bundles, including downloading, listing, and cleaning up cached bundles.",
1313
Run: func(cmd *cobra.Command, _ []string) {
1414
_ = cmd.Help()
1515
},
1616
}
1717
bundleCmd.AddCommand(getGenerateCmd(config))
18+
bundleCmd.AddCommand(getDownloadCmd(config))
19+
bundleCmd.AddCommand(getListCmd(config))
20+
bundleCmd.AddCommand(getClearCmd())
21+
bundleCmd.AddCommand(getPruneCmd())
1822
return bundleCmd
1923
}

cmd/crc/cmd/bundle/clear.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package bundle
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/crc-org/crc/v2/pkg/crc/constants"
9+
"github.com/crc-org/crc/v2/pkg/crc/logging"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func getClearCmd() *cobra.Command {
14+
return &cobra.Command{
15+
Use: "clear",
16+
Short: "Clear cached CRC bundles",
17+
Long: "Delete all downloaded CRC bundles from the cache directory.",
18+
RunE: func(cmd *cobra.Command, args []string) error {
19+
return runClear()
20+
},
21+
}
22+
}
23+
24+
func runClear() error {
25+
cacheDir := constants.MachineCacheDir
26+
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
27+
logging.Infof("Cache directory %s does not exist", cacheDir)
28+
return nil
29+
}
30+
31+
files, err := os.ReadDir(cacheDir)
32+
if err != nil {
33+
return err
34+
}
35+
36+
cleared := false
37+
var lastErr error
38+
for _, file := range files {
39+
if strings.HasSuffix(file.Name(), ".crcbundle") {
40+
filePath := filepath.Join(cacheDir, file.Name())
41+
logging.Infof("Deleting %s", filePath)
42+
if err := os.RemoveAll(filePath); err != nil {
43+
logging.Errorf("Failed to remove %s: %v", filePath, err)
44+
lastErr = err
45+
} else {
46+
cleared = true
47+
}
48+
}
49+
}
50+
51+
if !cleared && lastErr == nil {
52+
logging.Infof("No bundles found in %s", cacheDir)
53+
} else if cleared {
54+
logging.Infof("Cleared cached bundles in %s", cacheDir)
55+
}
56+
return lastErr
57+
}

cmd/crc/cmd/bundle/download.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package bundle
2+
3+
import (
4+
"context"
5+
"encoding/hex"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
13+
crcConfig "github.com/crc-org/crc/v2/pkg/crc/config"
14+
"github.com/crc-org/crc/v2/pkg/crc/constants"
15+
"github.com/crc-org/crc/v2/pkg/crc/gpg"
16+
"github.com/crc-org/crc/v2/pkg/crc/logging"
17+
"github.com/crc-org/crc/v2/pkg/crc/machine/bundle"
18+
crcPreset "github.com/crc-org/crc/v2/pkg/crc/preset"
19+
"github.com/crc-org/crc/v2/pkg/download"
20+
"github.com/spf13/cobra"
21+
)
22+
23+
func getDownloadCmd(config *crcConfig.Config) *cobra.Command {
24+
downloadCmd := &cobra.Command{
25+
Use: "download [version] [architecture]",
26+
Short: "Download a specific CRC bundle",
27+
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.",
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
force, _ := cmd.Flags().GetBool("force")
30+
presetStr, _ := cmd.Flags().GetString("preset")
31+
32+
var preset crcPreset.Preset
33+
if presetStr != "" {
34+
var err error
35+
preset, err = crcPreset.ParsePresetE(presetStr)
36+
if err != nil {
37+
return err
38+
}
39+
} else {
40+
preset = crcConfig.GetPreset(config)
41+
}
42+
43+
return runDownload(args, preset, force)
44+
},
45+
}
46+
downloadCmd.Flags().BoolP("force", "f", false, "Overwrite existing bundle if present")
47+
downloadCmd.Flags().StringP("preset", "p", "", "Target preset (openshift, okd, microshift)")
48+
49+
return downloadCmd
50+
}
51+
52+
func runDownload(args []string, preset crcPreset.Preset, force bool) error {
53+
if len(args) > 2 {
54+
return fmt.Errorf("too many arguments: expected at most 2 (version, architecture), got %d", len(args))
55+
}
56+
57+
// If no args, use default bundle path
58+
if len(args) == 0 {
59+
defaultBundlePath := constants.GetDefaultBundlePath(preset)
60+
if !force {
61+
if _, err := os.Stat(defaultBundlePath); err == nil {
62+
logging.Infof("Bundle %s already exists. Use --force to overwrite.", defaultBundlePath)
63+
return nil
64+
}
65+
}
66+
67+
logging.Debugf("Source: %s", constants.GetDefaultBundleDownloadURL(preset))
68+
logging.Debugf("Destination: %s", defaultBundlePath)
69+
// For default bundle, we use the existing logic which handles verification internally
70+
_, err := bundle.Download(context.Background(), preset, defaultBundlePath, false)
71+
return err
72+
}
73+
74+
// If args provided, we are constructing a URL
75+
version := args[0]
76+
77+
// Check if version is partial (Major.Minor) and resolve it if necessary
78+
resolvedVersion, err := resolveOpenShiftVersion(preset, version)
79+
if err != nil {
80+
return fmt.Errorf("failed to resolve version %s: %w", version, err)
81+
} else if resolvedVersion != version {
82+
logging.Debugf("Resolved version %s to %s", version, resolvedVersion)
83+
version = resolvedVersion
84+
}
85+
86+
architecture := runtime.GOARCH
87+
if len(args) > 1 {
88+
architecture = args[1]
89+
}
90+
91+
bundleName := constants.BundleName(preset, version, architecture)
92+
bundlePath := filepath.Join(constants.MachineCacheDir, bundleName)
93+
94+
if !force {
95+
if _, err := os.Stat(bundlePath); err == nil {
96+
logging.Infof("Bundle %s already exists. Use --force to overwrite.", bundleName)
97+
return nil
98+
}
99+
}
100+
101+
// Base URL for the directory containing the bundle and signature
102+
baseVersionURL := fmt.Sprintf("%s/%s/%s/", constants.DefaultMirrorURL, preset.String(), version)
103+
bundleURL := fmt.Sprintf("%s%s", baseVersionURL, bundleName)
104+
sigURL := fmt.Sprintf("%s%s", baseVersionURL, "sha256sum.txt.sig")
105+
106+
logging.Infof("Downloading bundle: %s", bundleName)
107+
logging.Debugf("Source: %s", bundleURL)
108+
logging.Debugf("Destination: %s", constants.MachineCacheDir)
109+
110+
// Implement verification logic
111+
logging.Infof("Verifying signature for %s...", version)
112+
sha256sum, err := getVerifiedHashForCustomVersion(sigURL, bundleName)
113+
if err != nil {
114+
// Fallback: try without .sig if .sig not found, maybe just sha256sum.txt?
115+
// For now, fail if signature verification fails as requested for "Safeguards"
116+
return fmt.Errorf("signature verification failed: %w", err)
117+
}
118+
119+
sha256bytes, err := hex.DecodeString(sha256sum)
120+
if err != nil {
121+
return fmt.Errorf("failed to decode sha256sum: %w", err)
122+
}
123+
124+
_, err = download.Download(context.Background(), bundleURL, bundlePath, 0664, sha256bytes)
125+
return err
126+
}
127+
128+
func getVerifiedHashForCustomVersion(sigURL string, bundleName string) (string, error) {
129+
// Reuse existing verification logic from bundle package via a helper here
130+
// We essentially replicate getVerifiedHash but with our custom URL
131+
132+
res, err := download.InMemory(sigURL)
133+
if err != nil {
134+
return "", fmt.Errorf("failed to fetch signature file: %w", err)
135+
}
136+
defer res.Close()
137+
138+
signedHashes, err := io.ReadAll(res)
139+
if err != nil {
140+
return "", fmt.Errorf("failed to read signature file: %w", err)
141+
}
142+
143+
verifiedHashes, err := gpg.GetVerifiedClearsignedMsgV3(constants.RedHatReleaseKey, string(signedHashes))
144+
if err != nil {
145+
return "", fmt.Errorf("invalid signature: %w", err)
146+
}
147+
148+
lines := strings.Split(verifiedHashes, "\n")
149+
for _, line := range lines {
150+
line = strings.TrimSpace(line)
151+
if strings.HasSuffix(line, bundleName) {
152+
sha256sum := strings.TrimSuffix(line, " "+bundleName)
153+
sha256sum = strings.TrimSpace(sha256sum)
154+
return sha256sum, nil
155+
}
156+
}
157+
return "", fmt.Errorf("hash for %s not found in signature file", bundleName)
158+
}

cmd/crc/cmd/bundle/list.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package bundle
2+
3+
import (
4+
"fmt"
5+
"runtime"
6+
7+
"github.com/Masterminds/semver/v3"
8+
crcConfig "github.com/crc-org/crc/v2/pkg/crc/config"
9+
"github.com/crc-org/crc/v2/pkg/crc/logging"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func getListCmd(config *crcConfig.Config) *cobra.Command {
14+
return &cobra.Command{
15+
Use: "list [version]",
16+
Short: "List available CRC bundles",
17+
Long: "List available CRC bundles from the mirrors. Optionally filter by major.minor version (e.g. 4.19).",
18+
RunE: func(cmd *cobra.Command, args []string) error {
19+
return runList(args, config)
20+
},
21+
}
22+
}
23+
24+
func runList(args []string, config *crcConfig.Config) error {
25+
if len(args) > 1 {
26+
return fmt.Errorf("too many arguments: expected at most 1 version filter, got %d", len(args))
27+
}
28+
29+
preset := crcConfig.GetPreset(config)
30+
versions, err := fetchAvailableVersions(preset)
31+
if err != nil {
32+
return err
33+
}
34+
35+
if len(versions) == 0 {
36+
logging.Infof("No bundles found for preset %s", preset)
37+
return nil
38+
}
39+
40+
var filter *semver.Version
41+
if len(args) > 0 {
42+
v, err := semver.NewVersion(args[0] + ".0") // Treat 4.19 as 4.19.0 for partial matching
43+
if err == nil {
44+
filter = v
45+
} else {
46+
// Try parsing as full version just in case
47+
v, err = semver.NewVersion(args[0])
48+
if err == nil {
49+
filter = v
50+
}
51+
}
52+
}
53+
54+
logging.Infof("Available bundles for %s:", preset)
55+
for _, v := range versions {
56+
if filter != nil {
57+
if v.Major() != filter.Major() || v.Minor() != filter.Minor() {
58+
continue
59+
}
60+
}
61+
62+
cachedStr := ""
63+
if isBundleCached(preset, v.String(), runtime.GOARCH) {
64+
cachedStr = " (cached)"
65+
}
66+
fmt.Printf("%s%s\n", v.String(), cachedStr)
67+
}
68+
return nil
69+
}

0 commit comments

Comments
 (0)