Skip to content

Commit d09a477

Browse files
committed
Add bundle download cmd
Address comments 1 Address comments; consts, split bundle commands into files
1 parent 54c1407 commit d09a477

File tree

8 files changed

+496
-3
lines changed

8 files changed

+496
-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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
for _, file := range files {
38+
if strings.HasSuffix(file.Name(), ".crcbundle") {
39+
filePath := filepath.Join(cacheDir, file.Name())
40+
logging.Infof("Deleting %s", filePath)
41+
if err := os.RemoveAll(filePath); err != nil {
42+
logging.Errorf("Failed to remove %s: %v", filePath, err)
43+
}
44+
cleared = true
45+
}
46+
}
47+
48+
if !cleared {
49+
logging.Infof("No bundles found in %s", cacheDir)
50+
} else {
51+
logging.Infof("Cleared cached bundles in %s", cacheDir)
52+
}
53+
return nil
54+
}
55+

cmd/crc/cmd/bundle/download.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
// Disk space check (simple check for ~10GB free)
54+
// This is a basic check, more robust checking would require syscall/windows specific implementations
55+
// We skip this for now to avoid adding heavy OS-specific deps, assuming user manages disk space or download fails naturally.
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+
logging.Warnf("Could not resolve version %s: %v. Trying with original version string.", 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+
if strings.HasSuffix(line, bundleName) {
151+
sha256sum := strings.TrimSuffix(line, " "+bundleName)
152+
return sha256sum, nil
153+
}
154+
}
155+
return "", fmt.Errorf("hash for %s not found in signature file", bundleName)
156+
}

cmd/crc/cmd/bundle/list.go

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

cmd/crc/cmd/bundle/prune.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package bundle
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sort"
7+
"strings"
8+
9+
"github.com/crc-org/crc/v2/pkg/crc/constants"
10+
"github.com/crc-org/crc/v2/pkg/crc/logging"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func getPruneCmd() *cobra.Command {
15+
return &cobra.Command{
16+
Use: "prune",
17+
Short: "Prune old CRC bundles",
18+
Long: "Keep only the most recent bundles and delete older ones to save space.",
19+
RunE: func(cmd *cobra.Command, args []string) error {
20+
// Default keep 2 most recent
21+
return runPrune(2)
22+
},
23+
}
24+
}
25+
26+
func runPrune(keep int) error {
27+
cacheDir := constants.MachineCacheDir
28+
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
29+
logging.Infof("Cache directory %s does not exist", cacheDir)
30+
return nil
31+
}
32+
33+
files, err := os.ReadDir(cacheDir)
34+
if err != nil {
35+
return err
36+
}
37+
38+
var bundleFiles []os.DirEntry
39+
for _, file := range files {
40+
if strings.HasSuffix(file.Name(), ".crcbundle") {
41+
bundleFiles = append(bundleFiles, file)
42+
}
43+
}
44+
45+
if len(bundleFiles) <= keep {
46+
logging.Infof("Nothing to prune (found %d bundles, keeping %d)", len(bundleFiles), keep)
47+
return nil
48+
}
49+
50+
// Sort by modification time, newest first
51+
sort.Slice(bundleFiles, func(i, j int) bool {
52+
infoI, _ := bundleFiles[i].Info()
53+
infoJ, _ := bundleFiles[j].Info()
54+
return infoI.ModTime().After(infoJ.ModTime())
55+
})
56+
57+
for i := keep; i < len(bundleFiles); i++ {
58+
file := bundleFiles[i]
59+
filePath := filepath.Join(cacheDir, file.Name())
60+
logging.Infof("Pruning old bundle: %s", file.Name())
61+
if err := os.RemoveAll(filePath); err != nil {
62+
logging.Errorf("Failed to remove %s: %v", filePath, err)
63+
}
64+
}
65+
66+
return nil
67+
}
68+

0 commit comments

Comments
 (0)