Repository: code-ready/crc
Pull request #2287 (2021-04-28 16:35:01)

Run all setup preflights when --check-only is used
Previous attempt in commit e07d23985fa missed some checks as it is
running the checks which are run at crc start time, and thus it ignores
the ones marked as SetupOnly. In particular, it does not report properly
if the bundle needs to be extracted or not, which is one of the last
preflights ran by `crc setup`

This commit adds a checkOnly parameter to SetupHost() so that it can
error out when a preflight check fails rather than trying to fix it.

This should fix https://github.com/code-ready/crc/issues/2225

openshift-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/code-ready/crc/pull/2287#" title="Author self-approved">cfergeau</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=code-ready%2Fcrc).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/code-ready/crc/blob/master/OWNERS)~~ [cfergeau]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
openshift-ci[bot]: @cfergeau: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
ci/prow/integration-crc | 8b1c6d2b3902e0751a97e9b19e8287090458ed35 | [link](https://prow.ci.openshift.org/view/gs/origin-ci-test/pr-logs/pull/code-ready_crc/2287/pull-ci-code-ready-crc-master-integration-crc/1387445264838561792) | `/test integration-crc`

[Full PR test history](https://prow.ci.openshift.org/pr-history?org=code-ready&repo=crc&pr=2287). [Your PR dashboard](https://prow.ci.openshift.org/pr?query=is:pr+state:open+author:cfergeau).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->

Patch:
diff --git a/cmd/crc/cmd/bundle/bundle.go b/cmd/crc/cmd/bundle/bundle.go
new file mode 100644
index 000000000..90143f600
--- /dev/null
+++ b/cmd/crc/cmd/bundle/bundle.go
@@ -0,0 +1,19 @@
+package bundle
+
+import (
+	"github.com/code-ready/crc/pkg/crc/config"
+	"github.com/spf13/cobra"
+)
+
+func GetBundleCmd(config *config.Config) *cobra.Command {
+	bundleCmd := &cobra.Command{
+		Use:   "bundle SUBCOMMAND [flags]",
+		Short: "Manage CRC bundles",
+		Long:  "Manage CRC bundles",
+		Run: func(cmd *cobra.Command, args []string) {
+			_ = cmd.Help()
+		},
+	}
+	bundleCmd.AddCommand(getGenerateCmd(config))
+	return bundleCmd
+}
diff --git a/cmd/crc/cmd/bundle/generate.go b/cmd/crc/cmd/bundle/generate.go
new file mode 100644
index 000000000..c1cd18c81
--- /dev/null
+++ b/cmd/crc/cmd/bundle/generate.go
@@ -0,0 +1,30 @@
+package bundle
+
+import (
+	"github.com/code-ready/crc/pkg/crc/config"
+	"github.com/code-ready/crc/pkg/crc/constants"
+	"github.com/code-ready/crc/pkg/crc/logging"
+	"github.com/code-ready/crc/pkg/crc/machine"
+	"github.com/spf13/cobra"
+)
+
+func getGenerateCmd(config *config.Config) *cobra.Command {
+	return &cobra.Command{
+		Use:   "generate",
+		Short: "Generate a custom bundle from the running OpenShift cluster",
+		Long:  "Generate a custom bundle from the running OpenShift cluster",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runGenerate(config)
+		},
+	}
+}
+
+func runGenerate(config *config.Config) error {
+	client := machine.NewClient(constants.DefaultName, isDebugLog(), config)
+
+	return client.GenerateBundle()
+}
+
+func isDebugLog() bool {
+	return logging.LogLevel == "debug"
+}
diff --git a/cmd/crc/cmd/root.go b/cmd/crc/cmd/root.go
index 35e24e1b4..1d219f8a9 100644
--- a/cmd/crc/cmd/root.go
+++ b/cmd/crc/cmd/root.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 	"time"
 
+	cmdBundle "github.com/code-ready/crc/cmd/crc/cmd/bundle"
 	cmdConfig "github.com/code-ready/crc/cmd/crc/cmd/config"
 	crcConfig "github.com/code-ready/crc/pkg/crc/config"
 	"github.com/code-ready/crc/pkg/crc/constants"
@@ -61,6 +62,7 @@ func init() {
 
 	// subcommands
 	rootCmd.AddCommand(cmdConfig.GetConfigCmd(config))
+	rootCmd.AddCommand(cmdBundle.GetBundleCmd(config))
 
 	rootCmd.PersistentFlags().StringVar(&logging.LogLevel, "log-level", constants.DefaultLogLevel, "log level (e.g. \"debug | info | warn | error\")")
 }
diff --git a/cmd/crc/cmd/setup.go b/cmd/crc/cmd/setup.go
index 35aa39649..fe18745a6 100644
--- a/cmd/crc/cmd/setup.go
+++ b/cmd/crc/cmd/setup.go
@@ -54,17 +54,13 @@ func runSetup(arguments []string) error {
 			fmt.Printf("No worry, you can still enable telemetry manually with the command 'crc config set %s yes'.\n", cmdConfig.ConsentTelemetry)
 		}
 	}
-	var err error
-	if checkOnly {
-		err = preflight.StartPreflightChecks(config)
-		if err != nil {
-			err = exec.CodeExitError{
-				Err:  err,
-				Code: preflightFailedExitCode,
-			}
+
+	err := preflight.SetupHost(config, checkOnly)
+	if err != nil && checkOnly {
+		err = exec.CodeExitError{
+			Err:  err,
+			Code: preflightFailedExitCode,
 		}
-	} else {
-		err = preflight.SetupHost(config)
 	}
 
 	return render(&setupResult{
diff --git a/pkg/compress/compress.go b/pkg/compress/compress.go
new file mode 100644
index 000000000..bed9498cd
--- /dev/null
+++ b/pkg/compress/compress.go
@@ -0,0 +1,71 @@
+package compress
+
+import (
+	"archive/tar"
+	"io"
+	"os"
+	"path/filepath"
+
+	"github.com/code-ready/crc/pkg/crc/logging"
+	"github.com/klauspost/compress/zstd"
+)
+
+func Compress(src, dest string) (err error) {
+	out, err := os.Create(dest)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		cerr := out.Close()
+		if err == nil {
+			err = cerr
+		}
+	}()
+
+	enc, err := zstd.NewWriter(out)
+	if err != nil {
+		return err
+	}
+	defer enc.Close()
+
+	tarWriter := tar.NewWriter(enc)
+	defer tarWriter.Close()
+
+	basePath, _ := filepath.Split(src)
+
+	// Just use top level directory as part of tarball
+	// $ zstdcat crc_libvirt_4.7.1_custom.zstd  | tar t
+	// crc_libvirt_4.7.1_custom
+	// crc_libvirt_4.7.1_custom/test
+	return filepath.Walk(src, func(file string, fi os.FileInfo, err1 error) error {
+		if err1 != nil {
+			return err1
+		}
+		logging.Debugf("Adding %s", file)
+		header, err := tar.FileInfoHeader(fi, file)
+		if err != nil {
+			return err
+		}
+		// must provide real name
+		// (see https://golang.org/src/archive/tar/common.go?#L626)
+		header.Name, err = filepath.Rel(basePath, filepath.ToSlash(file))
+		if err != nil {
+			return err
+		}
+
+		if err := tarWriter.WriteHeader(header); err != nil {
+			return err
+		}
+		if !fi.IsDir() {
+			data, err := os.Open(file)
+			if err != nil {
+				return err
+			}
+			defer data.Close()
+			if _, err := io.Copy(tarWriter, data); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
diff --git a/pkg/crc/cluster/cluster.go b/pkg/crc/cluster/cluster.go
index d8bb9e942..2a62bb52e 100644
--- a/pkg/crc/cluster/cluster.go
+++ b/pkg/crc/cluster/cluster.go
@@ -126,6 +126,35 @@ func EnsurePullSecretPresentInTheCluster(ocConfig oc.Config, pullSec PullSecretL
 	return nil
 }
 
+func RemovePullSecretFromCluster(ocConfig oc.Config, sshRunner *ssh.Runner) error {
+	logging.Info("Removing user's pull secret from instance disk and from cluster secret...")
+	if _, _, err := sshRunner.RunPrivileged("Removing user's pull secret", "rm", "-fr", vmPullSecretPath); err != nil {
+		return err
+	}
+	cmdArgs := []string{"patch", "secret", "pull-secret", "-p",
+		`'{"data":{".dockerconfigjson":"e30K"}}'`,
+		"-n", "openshift-config", "--type", "merge"}
+
+	_, stderr, err := ocConfig.RunOcCommand(cmdArgs...)
+	if err != nil {
+		return fmt.Errorf("Failed to remove Pull secret %w: %s", err, stderr)
+	}
+
+	cmdArgs = []string{"delete", "machineconfigs", "--all"}
+	_, stderr, err = ocConfig.RunOcCommand(cmdArgs...)
+	if err != nil {
+		return fmt.Errorf("Failed to remove machineconfigs %w: %s", err, stderr)
+	}
+
+	cmdArgs = []string{"delete", "machineconfigpools", "--all"}
+	_, stderr, err = ocConfig.RunOcCommand(cmdArgs...)
+	if err != nil {
+		return fmt.Errorf("Failed to remove machineconfigpools %w: %s", err, stderr)
+	}
+
+	return nil
+}
+
 func EnsureClusterIDIsNotEmpty(ocConfig oc.Config) error {
 	if err := WaitForOpenshiftResource(ocConfig, "clusterversion"); err != nil {
 		return err
diff --git a/pkg/crc/machine/bundle/copier.go b/pkg/crc/machine/bundle/copier.go
new file mode 100644
index 000000000..18562c577
--- /dev/null
+++ b/pkg/crc/machine/bundle/copier.go
@@ -0,0 +1,160 @@
+package bundle
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/code-ready/crc/pkg/compress"
+	"github.com/code-ready/crc/pkg/crc/logging"
+	crcos "github.com/code-ready/crc/pkg/os"
+	godeepcopy "github.com/jinzhu/copier"
+)
+
+type Copier struct {
+	srcBundle    *CrcBundleInfo
+	copiedBundle CrcBundleInfo
+}
+
+func NewCopier(srcBundle *CrcBundleInfo, basePath string, customBundleName string) (*Copier, error) {
+	var copier Copier
+
+	bundlePath := filepath.Join(basePath, customBundleName)
+	if err := os.Mkdir(bundlePath, 0775); err != nil {
+		return nil, err
+	}
+
+	// Creating a deepCopy of source bundle metadata so that whatever we make
+	// modification of any metadata field for copied operation should only
+	// effect the copied bundle metadata instead of source bundle metadata.
+	if err := godeepcopy.Copy(&copier.copiedBundle, srcBundle); err != nil {
+		return nil, err
+	}
+
+	copier.copiedBundle.bundleName = customBundleName
+	copier.copiedBundle.Type = "custom"
+	copier.srcBundle = srcBundle
+	copier.copiedBundle.cachedPath = bundlePath
+
+	return &copier, nil
+}
+
+func (copier *Copier) Cleanup() error {
+	return os.RemoveAll(copier.copiedBundle.cachedPath)
+}
+
+func (copier *Copier) resolvePath(filename string) string {
+	return copier.copiedBundle.resolvePath(filename)
+}
+
+func (copier *Copier) CachedPath() string {
+	return copier.copiedBundle.cachedPath
+}
+
+func (copier *Copier) CopyPrivateSSHKey(srcPath string) error {
+	sshKeyFileName := filepath.Base(copier.srcBundle.GetSSHKeyPath())
+	destPath := copier.resolvePath(sshKeyFileName)
+	return crcos.CopyFileContents(srcPath, destPath, 0400)
+}
+
+func (copier *Copier) SetKubeAdminPassword(kubePassword string) error {
+	kubeAdminPasswordFileName := copier.srcBundle.ClusterInfo.KubeadminPasswordFile
+	path := copier.resolvePath(kubeAdminPasswordFileName)
+	return ioutil.WriteFile(path, []byte(kubePassword), 0600)
+}
+
+func (copier *Copier) CopyKubeConfig() error {
+	kubeConfigFileName := filepath.Base(copier.srcBundle.GetKubeConfigPath())
+	srcPath := copier.srcBundle.GetKubeConfigPath()
+	destPath := copier.resolvePath(kubeConfigFileName)
+	return crcos.CopyFileContents(srcPath, destPath, 0640)
+}
+
+func (copier *Copier) CopyFilesFromFileList() error {
+	for _, file := range copier.srcBundle.Storage.Files {
+		srcPath := copier.srcBundle.resolvePath(file.Name)
+		destPath := copier.resolvePath(filepath.Base(srcPath))
+		if err := crcos.CopyFileContents(srcPath, destPath, 0755); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (copier *Copier) SetDiskImage(path string, format string) error {
+	fileInfo, err := getFileInfo(path)
+	if err != nil {
+		return err
+	}
+	diskImage := DiskImage{
+		File:   *fileInfo,
+		Format: format,
+	}
+
+	copier.copiedBundle.Storage.DiskImages = []DiskImage{diskImage}
+
+	return nil
+}
+
+func (copier *Copier) GenerateBundle(bundleName string) error {
+	if err := copier.copiedBundle.verify(); err != nil {
+		return err
+	}
+
+	// update bundle info
+	copier.copiedBundle.BuildInfo.BuildTime = time.Now().String()
+
+	// Create the metadata json for custom bundle
+	bundleContent, err := json.MarshalIndent(copier.copiedBundle, "", " ")
+	if err != nil {
+		return err
+	}
+	err = ioutil.WriteFile(copier.resolvePath("crc-bundle-info.json"), bundleContent, 0600)
+	if err != nil {
+		return fmt.Errorf("error copying bundle metadata  %w", err)
+	}
+
+	logging.Infof("Compressing %s...", GetBundleNameWithoutExtension(copier.copiedBundle.bundleName))
+	return compress.Compress(copier.copiedBundle.cachedPath, fmt.Sprintf("%s%s", bundleName, bundleExtension))
+}
+
+func sha256sum(path string) (string, error) {
+	logging.Infof("Generating sha256sum for %s...", filepath.Base(path))
+	f, err := os.Open(path)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+
+	h := sha256.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return "", err
+	}
+
+	return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+func getFileInfo(path string) (*File, error) {
+	fileInfo := File{}
+	fileInfo.Name = filepath.Base(path)
+
+	stat, err := os.Stat(path)
+	if err != nil {
+		return nil, err
+	}
+	fileInfo.Size = fmt.Sprintf("%d", stat.Size())
+
+	checksum, err := sha256sum(path)
+	if err != nil {
+		return nil, err
+	}
+	fileInfo.Checksum = checksum
+
+	return &fileInfo, nil
+}
diff --git a/pkg/crc/machine/bundle/metadata.go b/pkg/crc/machine/bundle/metadata.go
index 73bcef7b8..859efc299 100644
--- a/pkg/crc/machine/bundle/metadata.go
+++ b/pkg/crc/machine/bundle/metadata.go
@@ -103,6 +103,10 @@ func (bundle *CrcBundleInfo) GetDiskImagePath() string {
 	return bundle.resolvePath(bundle.Storage.DiskImages[0].Name)
 }
 
+func (bundle *CrcBundleInfo) GetDiskImageFormat() string {
+	return bundle.Storage.DiskImages[0].Format
+}
+
 func (bundle *CrcBundleInfo) GetKubeConfigPath() string {
 	return bundle.resolvePath(bundle.ClusterInfo.KubeConfig)
 }
@@ -145,6 +149,10 @@ func (bundle *CrcBundleInfo) GetInitramfsPath() string {
 	return bundle.resolvePath(bundle.Nodes[0].Initramfs)
 }
 
+func (bundle *CrcBundleInfo) GetKernelCommandLine() string {
+	return bundle.Nodes[0].KernelCmdLine
+}
+
 func (bundle *CrcBundleInfo) GetKubeadminPassword() (string, error) {
 	rawData, err := ioutil.ReadFile(bundle.resolvePath(bundle.ClusterInfo.KubeadminPasswordFile))
 	if err != nil {
@@ -161,6 +169,10 @@ func (bundle *CrcBundleInfo) GetOpenshiftVersion() string {
 	return bundle.ClusterInfo.OpenShiftVersion
 }
 
+func (bundle *CrcBundleInfo) GetBundleNameWithoutExtension() string {
+	return GetBundleNameWithoutExtension(bundle.GetBundleName())
+}
+
 func (bundle *CrcBundleInfo) verify() error {
 	files := []string{
 		bundle.GetOcPath(),
@@ -202,3 +214,15 @@ func (bundle *CrcBundleInfo) checkDiskImageSize() error {
 	}
 	return nil
 }
+
+func GetBundleNameWithoutExtension(bundleName string) string {
+	return strings.TrimSuffix(bundleName, bundleExtension)
+}
+
+func GetCustomBundleNameWithoutExtension(bundleName string) string {
+	return fmt.Sprintf("%s_%d", GetBundleNameWithoutExtension(bundleName), time.Now().Unix())
+}
+
+func GetCustomBundle(bundleNameWithoutExtension string) string {
+	return fmt.Sprintf("%s.%s", bundleNameWithoutExtension, bundleExtension)
+}
diff --git a/pkg/crc/machine/bundle/repository.go b/pkg/crc/machine/bundle/repository.go
index 532d7ffb1..1ffed704d 100644
--- a/pkg/crc/machine/bundle/repository.go
+++ b/pkg/crc/machine/bundle/repository.go
@@ -7,7 +7,6 @@ import (
 	"os"
 	"path/filepath"
 	"runtime"
-	"strings"
 	"time"
 
 	"github.com/Masterminds/semver"
@@ -30,7 +29,7 @@ type Repository struct {
 }
 
 func (repo *Repository) Get(bundleName string) (*CrcBundleInfo, error) {
-	path := filepath.Join(repo.CacheDir, strings.TrimSuffix(bundleName, bundleExtension))
+	path := filepath.Join(repo.CacheDir, GetBundleNameWithoutExtension(bundleName))
 	if _, err := os.Stat(path); err != nil {
 		return nil, errors.Wrapf(err, "could not find cached bundle info in %s", path)
 	}
@@ -150,7 +149,7 @@ func (repo *Repository) Extract(path string) error {
 		return err
 	}
 
-	bundleBaseDir := strings.TrimSuffix(bundleName, bundleExtension)
+	bundleBaseDir := GetBundleNameWithoutExtension(bundleName)
 	bundleDir := filepath.Join(repo.CacheDir, bundleBaseDir)
 	_ = os.RemoveAll(bundleDir)
 	return crcerrors.RetryAfter(time.Minute, func() error {
diff --git a/pkg/crc/machine/client.go b/pkg/crc/machine/client.go
index a9c07a81c..6d40d6de2 100644
--- a/pkg/crc/machine/client.go
+++ b/pkg/crc/machine/client.go
@@ -22,6 +22,7 @@ type Client interface {
 	Status() (*types.ClusterStatusResult, error)
 	Stop() (state.State, error)
 	IsRunning() (bool, error)
+	GenerateBundle() error
 }
 
 type client struct {
diff --git a/pkg/crc/machine/fakemachine/client.go b/pkg/crc/machine/fakemachine/client.go
index fb53392e1..cc00373c8 100644
--- a/pkg/crc/machine/fakemachine/client.go
+++ b/pkg/crc/machine/fakemachine/client.go
@@ -68,6 +68,10 @@ func (c *Client) PowerOff() error {
 	return nil
 }
 
+func (c *Client) GenerateBundle() error {
+	return nil
+}
+
 func (c *Client) Start(ctx context.Context, startConfig types.StartConfig) (*types.StartResult, error) {
 	if c.Failing {
 		return nil, errors.New("Failed to start")
diff --git a/pkg/crc/machine/generate_bundle.go b/pkg/crc/machine/generate_bundle.go
new file mode 100644
index 000000000..78e97414a
--- /dev/null
+++ b/pkg/crc/machine/generate_bundle.go
@@ -0,0 +1,132 @@
+package machine
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"github.com/code-ready/crc/pkg/crc/cluster"
+	"github.com/code-ready/crc/pkg/crc/constants"
+	"github.com/code-ready/crc/pkg/crc/logging"
+	"github.com/code-ready/crc/pkg/crc/machine/bundle"
+	"github.com/code-ready/crc/pkg/crc/oc"
+	crcssh "github.com/code-ready/crc/pkg/crc/ssh"
+	"github.com/code-ready/machine/libmachine/state"
+	"github.com/pkg/errors"
+)
+
+func (client *client) GenerateBundle() error {
+	bundleMetadata, sshRunner, err := loadVM(client)
+	if err != nil {
+		return err
+	}
+	defer sshRunner.Close()
+
+	ocConfig := oc.UseOCWithSSH(sshRunner)
+	if err := cluster.RemovePullSecretFromCluster(ocConfig, sshRunner); err != nil {
+		return errors.Wrap(err, "Error loading bundle metadata")
+	}
+
+	// Stop the cluster
+	currentState, err := client.Stop()
+	if err != nil {
+		return err
+	}
+	if currentState != state.Stopped {
+		return fmt.Errorf("VM is not stopped, current state is %s", currentState.String())
+	}
+
+	tmpBaseDir, err := ioutil.TempDir("", "crc_custom_bundle")
+	if err != nil {
+		return err
+	}
+	defer os.RemoveAll(tmpBaseDir)
+
+	// Create the custom bundle directory which is used as top level directory for tarball during compression
+	customBundleNameWithoutExtension := bundle.GetCustomBundleNameWithoutExtension(bundleMetadata.GetBundleName())
+
+	copier, err := bundle.NewCopier(bundleMetadata, tmpBaseDir, customBundleNameWithoutExtension)
+	if err != nil {
+		return err
+	}
+	defer copier.Cleanup() //nolint
+	customBundleDir := copier.CachedPath()
+
+	if err := copier.CopyKubeConfig(); err != nil {
+		return err
+	}
+
+	kubePassword, err := cluster.GetKubeadminPassword(bundleMetadata)
+	if err != nil {
+		return err
+	}
+	if err := copier.SetKubeAdminPassword(kubePassword); err != nil {
+		return err
+	}
+
+	if err := copier.CopyPrivateSSHKey(constants.GetPrivateKeyPath()); err != nil {
+		return err
+	}
+
+	if err := copier.CopyFilesFromFileList(); err != nil {
+		return err
+	}
+
+	// Copy disk image
+	logging.Infof("Copying the disk image to %s", customBundleNameWithoutExtension)
+	logging.Debugf("Absolute path of custom bundle directory: %s", customBundleDir)
+	diskPath, diskFormat, err := copyDiskImage(customBundleDir, bundleMetadata.GetDiskImagePath())
+	if err != nil {
+		return err
+	}
+
+	if err := copier.SetDiskImage(diskPath, diskFormat); err != nil {
+		return err
+	}
+
+	if err := copier.GenerateBundle(customBundleNameWithoutExtension); err != nil {
+		return err
+	}
+	cwd, err := os.Getwd()
+	if err != nil {
+		return err
+	}
+	logging.Infof("Bundle is generated in %s", filepath.Join(cwd, bundle.GetCustomBundle(customBundleNameWithoutExtension)))
+	logging.Infof("You need to perform 'crc delete' and 'crc start -b %s' to use this bundle", filepath.Join(cwd, bundle.GetCustomBundle(customBundleNameWithoutExtension)))
+	return nil
+}
+
+func loadVM(client *client) (*bundle.CrcBundleInfo, *crcssh.Runner, error) {
+	libMachineAPIClient, cleanup := createLibMachineClient()
+	defer cleanup()
+
+	host, err := libMachineAPIClient.Load(client.name)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "Cannot load machine")
+	}
+
+	currentState, err := host.Driver.GetState()
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "Cannot get machine state")
+	}
+	if currentState != state.Running {
+		return nil, nil, errors.New("machine is not running")
+	}
+
+	crcBundleMetadata, err := getBundleMetadataFromDriver(host.Driver)
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "Error loading bundle metadata")
+	}
+
+	instanceIP, err := getIP(host, client.useVSock())
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "Error getting the IP")
+	}
+	sshRunner, err := crcssh.CreateRunner(instanceIP, getSSHPort(client.useVSock()), crcBundleMetadata.GetSSHKeyPath(), constants.GetPrivateKeyPath(), constants.GetRsaPrivateKeyPath())
+	if err != nil {
+		return nil, nil, errors.Wrap(err, "Error creating the ssh client")
+	}
+
+	return crcBundleMetadata, sshRunner, nil
+}
diff --git a/pkg/crc/machine/generate_bundle_linux.go b/pkg/crc/machine/generate_bundle_linux.go
new file mode 100644
index 000000000..1a35bed19
--- /dev/null
+++ b/pkg/crc/machine/generate_bundle_linux.go
@@ -0,0 +1,50 @@
+package machine
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/code-ready/crc/pkg/crc/constants"
+	crcos "github.com/code-ready/crc/pkg/os"
+)
+
+func copyDiskImage(destDir string, srcDir string) (string, string, error) {
+	tmpDir := filepath.Join(constants.MachineCacheDir, "image-rebase")
+	_ = os.RemoveAll(tmpDir) // clean up before using it
+	if err := os.Mkdir(tmpDir, 0700); err != nil {
+		return "", "", err
+	}
+	defer func() {
+		_ = os.RemoveAll(tmpDir) // clean up after using it
+	}()
+
+	// We copy the overlay file because before doing the commit, we need to adjust the backing file.
+	baseImageName := filepath.Base(srcDir)
+	srcOverlayDiskPath := filepath.Join(constants.MachineInstanceDir, constants.DefaultName, baseImageName)
+	destOverlayDiskPath := filepath.Join(tmpDir, "overlayImage.qcow2")
+	if err := crcos.CopyFileContents(srcOverlayDiskPath, destOverlayDiskPath, 0644); err != nil {
+		return "", "", err
+	}
+
+	// We copy the base image because this is where we are going to commit the changes which were made to the VM
+	if err := crcos.CopyFileContents(srcDir,
+		filepath.Join(tmpDir, baseImageName), 0644); err != nil {
+		return "", "", err
+	}
+
+	// Use qemu-img commands to rebase and commit it.
+	format := "qcow2"
+	if _, _, err := crcos.RunWithDefaultLocale("qemu-img", "rebase", "-F", format, "-b",
+		filepath.Join(tmpDir, baseImageName), destOverlayDiskPath); err != nil {
+		return "", "", err
+	}
+	if _, _, err := crcos.RunWithDefaultLocale("qemu-img", "commit", destOverlayDiskPath); err != nil {
+		return "", "", err
+	}
+
+	// Copy the final image to destination dir
+	if err := crcos.CopyFileContents(filepath.Join(tmpDir, baseImageName), filepath.Join(destDir, baseImageName), 0644); err != nil {
+		return "", "", err
+	}
+	return filepath.Join(destDir, baseImageName), format, nil
+}
diff --git a/pkg/crc/machine/generate_bundle_nonlinux.go b/pkg/crc/machine/generate_bundle_nonlinux.go
new file mode 100644
index 000000000..f723a309a
--- /dev/null
+++ b/pkg/crc/machine/generate_bundle_nonlinux.go
@@ -0,0 +1,12 @@
+// +build !linux
+
+package machine
+
+import (
+	"fmt"
+	"runtime"
+)
+
+func copyDiskImage(dirName string, srcDir string) (string, string, error) {
+	return "", "", fmt.Errorf("Not implemented for %s", runtime.GOOS)
+}
diff --git a/pkg/crc/machine/start.go b/pkg/crc/machine/start.go
index 54004ef97..025fe3ce1 100644
--- a/pkg/crc/machine/start.go
+++ b/pkg/crc/machine/start.go
@@ -186,9 +186,9 @@ func (client *client) Start(ctx context.Context, startConfig types.StartConfig)
 
 		// Retrieve metadata info
 		machineConfig.ImageSourcePath = crcBundleMetadata.GetDiskImagePath()
-		machineConfig.ImageFormat = crcBundleMetadata.Storage.DiskImages[0].Format
+		machineConfig.ImageFormat = crcBundleMetadata.GetDiskImageFormat()
 		machineConfig.SSHKeyPath = crcBundleMetadata.GetSSHKeyPath()
-		machineConfig.KernelCmdLine = crcBundleMetadata.Nodes[0].KernelCmdLine
+		machineConfig.KernelCmdLine = crcBundleMetadata.GetKernelCommandLine()
 		machineConfig.Initramfs = crcBundleMetadata.GetInitramfsPath()
 		machineConfig.Kernel = crcBundleMetadata.GetKernelPath()
 
diff --git a/pkg/crc/machine/sync.go b/pkg/crc/machine/sync.go
index 4fe952967..874e7b8b6 100644
--- a/pkg/crc/machine/sync.go
+++ b/pkg/crc/machine/sync.go
@@ -169,3 +169,7 @@ func (s *Synchronized) Status() (*types.ClusterStatusResult, error) {
 func (s *Synchronized) IsRunning() (bool, error) {
 	return s.underlying.IsRunning()
 }
+
+func (s *Synchronized) GenerateBundle() error {
+	return s.underlying.GenerateBundle()
+}
diff --git a/pkg/crc/preflight/preflight.go b/pkg/crc/preflight/preflight.go
index c94dd265c..a2f93eac6 100644
--- a/pkg/crc/preflight/preflight.go
+++ b/pkg/crc/preflight/preflight.go
@@ -101,7 +101,7 @@ func doPreflightChecks(config config.Storage, checks []Check) error {
 	return nil
 }
 
-func doFixPreflightChecks(config config.Storage, checks []Check) error {
+func doFixPreflightChecks(config config.Storage, checks []Check, checkOnly bool) error {
 	for _, check := range checks {
 		if check.flags&CleanUpOnly == CleanUpOnly {
 			continue
@@ -109,6 +109,8 @@ func doFixPreflightChecks(config config.Storage, checks []Check) error {
 		err := check.doCheck(config)
 		if err == nil {
 			continue
+		} else if checkOnly {
+			return err
 		}
 		if err = check.doFix(); err != nil {
 			return err
@@ -160,11 +162,11 @@ func StartPreflightChecks(config config.Storage) error {
 }
 
 // SetupHost performs the prerequisite checks and setups the host to run the cluster
-func SetupHost(config config.Storage) error {
+func SetupHost(config config.Storage, checkOnly bool) error {
 	experimentalFeatures := config.Get(cmdConfig.ExperimentalFeatures).AsBool()
 	mode := network.ParseMode(config.Get(cmdConfig.NetworkMode).AsString())
 	trayAutostart := config.Get(cmdConfig.AutostartTray).AsBool()
-	return doFixPreflightChecks(config, getPreflightChecks(experimentalFeatures, trayAutostart, mode))
+	return doFixPreflightChecks(config, getPreflightChecks(experimentalFeatures, trayAutostart, mode), checkOnly)
 }
 
 func RegisterSettings(config config.Schema) {
diff --git a/pkg/crc/validation/validation.go b/pkg/crc/validation/validation.go
index 8a3bb78d1..12e6f3027 100644
--- a/pkg/crc/validation/validation.go
+++ b/pkg/crc/validation/validation.go
@@ -8,6 +8,7 @@ import (
 	"os"
 	"path/filepath"
 	"runtime"
+	"strings"
 
 	"github.com/code-ready/crc/pkg/crc/constants"
 	"github.com/code-ready/crc/pkg/crc/logging"
@@ -58,16 +59,24 @@ func ValidateEnoughMemory(value int) error {
 }
 
 // ValidateBundlePath checks if the provided bundle path exist
-func ValidateBundlePath(bundle string) error {
-	if err := ValidatePath(bundle); err != nil {
+func ValidateBundlePath(bundlePath string) error {
+	if err := ValidatePath(bundlePath); err != nil {
 		if constants.BundleEmbedded() {
 			return fmt.Errorf("Run 'crc setup' to unpack the bundle to disk")
 		}
-		return fmt.Errorf("%s not found, please provide the path to a valid bundle using the -b option", bundle)
+		return fmt.Errorf("%s not found, please provide the path to a valid bundle using the -b option", bundlePath)
 	}
 
-	userProvidedBundle := filepath.Base(bundle)
+	userProvidedBundle := filepath.Base(bundlePath)
 	if userProvidedBundle != constants.GetDefaultBundle() {
+		// Should append underscore (_) here, as we don't want crc_libvirt_4.7.15.crcbundle
+		// to be detected as a custom bundle for crc_libvirt_4.7.1.crcbundle
+		usingCustomBundle := strings.HasPrefix(bundle.GetBundleNameWithoutExtension(userProvidedBundle),
+			fmt.Sprintf("%s_", bundle.GetBundleNameWithoutExtension(constants.GetDefaultBundle())))
+		if usingCustomBundle {
+			logging.Warnf("Using custom bundle %s", userProvidedBundle)
+			return nil
+		}
 		if !constants.IsRelease() {
 			logging.Warnf("Using unsupported bundle %s", userProvidedBundle)
 			return nil


Test patch:
diff --git a/pkg/compress/compress_test.go b/pkg/compress/compress_test.go
new file mode 100644
index 000000000..1d1314490
--- /dev/null
+++ b/pkg/compress/compress_test.go
@@ -0,0 +1,144 @@
+package compress
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/code-ready/crc/pkg/crc/logging"
+	"github.com/code-ready/crc/pkg/extract"
+
+	"github.com/stretchr/testify/require"
+)
+
+const testArchiveName = "testdata.tar.zst"
+
+var (
+	files fileMap = map[string]string{
+		filepath.Join("a"):      "a",
+		filepath.Join("c"):      "c",
+		filepath.Join("b", "d"): "d",
+	}
+)
+
+func testCompress(t *testing.T, baseDir string) {
+	// This is useful to check that the top-level directory of the archive
+	// was created with the correct permissions, and was not created by the
+	// `os.MkdirAll(0750)` call at the beginning of `untarFile`
+	require.NoError(t, os.Chmod(baseDir, 0700))
+	require.NoError(t, Compress(baseDir, testArchiveName))
+	defer os.Remove(testArchiveName)
+
+	destDir, err := ioutil.TempDir("", "testdata-extracted")
+	require.NoError(t, err)
+	defer os.RemoveAll(destDir)
+
+	fileList, err := extract.Uncompress(testArchiveName, destDir, false)
+	require.NoError(t, err)
+
+	_, d := filepath.Split(baseDir)
+	fi, err := os.Stat(filepath.Join(destDir, d))
+	require.NoError(t, err)
+	fMode := os.FileMode(0700)
+	if runtime.GOOS == "windows" {
+		// https://golang.org/pkg/os/#Chmod
+		fMode = os.FileMode(0777)
+	}
+	require.Equal(t, fi.Mode().Perm(), fMode)
+
+	require.NoError(t, checkFileList(filepath.Join(destDir, d), fileList, files))
+	require.NoError(t, checkFiles(filepath.Join(destDir, d), files))
+}
+
+func TestCompressRelative(t *testing.T) {
+	// Test with relative path
+	testCompress(t, "testdata")
+
+	// Test with absolute path
+	currentDir, err := os.Getwd()
+	require.NoError(t, err)
+	testCompress(t, filepath.Join(currentDir, "testdata"))
+}
+
+/* The code below is duplicated from pkg/extract/extract_test.go */
+type fileMap map[string]string
+
+func copyFileMap(orig fileMap) fileMap {
+	copy := fileMap{}
+	for key, value := range orig {
+		copy[key] = value
+	}
+	return copy
+}
+
+// This checks that the list of files returned by Uncompress matches what we expect
+func checkFileList(destDir string, extractedFiles []string, expectedFiles fileMap) error {
+	// We are going to remove elements from  the map, but we don't want to modify the map used by the caller
+	expectedFiles = copyFileMap(expectedFiles)
+
+	for _, file := range extractedFiles {
+		rel, err := filepath.Rel(destDir, file)
+		if err != nil {
+			return err
+		}
+		_, found := expectedFiles[rel]
+		if !found {
+			return fmt.Errorf("Unexpected file '%s' in file list %v", rel, expectedFiles)
+		}
+		delete(expectedFiles, rel)
+	}
+
+	if len(expectedFiles) != 0 {
+		return fmt.Errorf("Some expected files were not in file list: %v", expectedFiles)
+	}
+
+	return nil
+}
+
+// This checks that the files in the destination directory matches what we expect
+func checkFiles(destDir string, files fileMap) error {
+	// We are going to remove elements from  the map, but we don't want to modify the map used by the caller
+	files = copyFileMap(files)
+
+	err := filepath.Walk(destDir, func(path string, info os.FileInfo, err error) error {
+		logging.Debugf("Walking %s", path)
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			logging.Debugf("Skipping directory %s", path)
+			return nil
+		}
+		logging.Debugf("Checking file %s", path)
+		archivePath, err := filepath.Rel(destDir, path)
+		if err != nil {
+			return err
+		}
+		expectedContent, found := files[archivePath]
+		if !found {
+			return fmt.Errorf("Unexpected extracted file '%s'", path)
+		}
+		delete(files, archivePath)
+
+		data, err := ioutil.ReadFile(path) // #nosec G304
+		if err != nil {
+			return err
+		}
+		if string(data) != expectedContent {
+			return fmt.Errorf("Unexpected content for '%s': expected [%s], got [%s]", path, expectedContent, string(data))
+		}
+		logging.Debugf("'%s' successfully checked", path)
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	if len(files) != 0 {
+		return fmt.Errorf("Some expected files were not extracted: %v", files)
+	}
+
+	return nil
+}
diff --git a/pkg/compress/testdata/a b/pkg/compress/testdata/a
new file mode 100644
index 000000000..2e65efe2a
--- /dev/null
+++ b/pkg/compress/testdata/a
@@ -0,0 +1,1 @@
+a
\ No newline at end of file
diff --git a/pkg/compress/testdata/b/d b/pkg/compress/testdata/b/d
new file mode 100644
index 000000000..c59d9b634
--- /dev/null
+++ b/pkg/compress/testdata/b/d
@@ -0,0 +1,1 @@
+d
\ No newline at end of file
diff --git a/pkg/compress/testdata/c b/pkg/compress/testdata/c
new file mode 100644
index 000000000..3410062ba
--- /dev/null
+++ b/pkg/compress/testdata/c
@@ -0,0 +1,1 @@
+c
\ No newline at end of file
diff --git a/pkg/crc/machine/bundle/copier_test.go b/pkg/crc/machine/bundle/copier_test.go
new file mode 100644
index 000000000..c21a3531e
--- /dev/null
+++ b/pkg/crc/machine/bundle/copier_test.go
@@ -0,0 +1,72 @@
+package bundle
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/code-ready/crc/pkg/crc/constants"
+	crcos "github.com/code-ready/crc/pkg/os"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGenerateBundle(t *testing.T) {
+	var b CrcBundleInfo
+	assert.NoError(t, json.Unmarshal([]byte(reference), &b))
+	b.bundleName = "crc_4.7.1"
+	b.Storage.Files[0].Name = constants.OcExecutableName
+
+	tmpBundleDir, err := ioutil.TempDir("", "bundle_data")
+	assert.NoError(t, err)
+	b.cachedPath = filepath.Join(tmpBundleDir, b.bundleName)
+	defer os.RemoveAll(tmpBundleDir)
+
+	createDummyBundleFiles(t, &b)
+
+	srcDir, err := ioutil.TempDir("", "testdata")
+	assert.NoError(t, err)
+	defer os.RemoveAll(srcDir)
+
+	customBundleName := "custom_bundle"
+	copier, err := NewCopier(&b, srcDir, customBundleName)
+	assert.NoError(t, err)
+
+	assert.NoError(t, copier.CopyKubeConfig())
+
+	assert.NoError(t, copier.SetKubeAdminPassword("****-****-****"))
+	assert.NoError(t, copier.CopyPrivateSSHKey(copier.srcBundle.GetSSHKeyPath()))
+	assert.NoError(t, copier.CopyFilesFromFileList())
+
+	err = crcos.CopyFileContents(copier.srcBundle.GetDiskImagePath(), copier.copiedBundle.GetDiskImagePath(), 0644)
+	assert.NoError(t, err)
+	assert.NoError(t, copier.SetDiskImage(copier.copiedBundle.GetDiskImagePath(), "qcow2"))
+
+	assert.NoError(t, copier.GenerateBundle(customBundleName))
+	defer os.Remove(fmt.Sprintf("%s%s", customBundleName, bundleExtension))
+}
+
+func createDummyBundleFiles(t *testing.T, bundle *CrcBundleInfo) {
+	assert.NoError(t, os.MkdirAll(bundle.cachedPath, 0750))
+
+	files := []string{
+		bundle.GetOcPath(),
+		bundle.resolvePath(bundle.ClusterInfo.KubeadminPasswordFile),
+		bundle.GetKubeConfigPath(),
+		bundle.GetSSHKeyPath(),
+		bundle.GetDiskImagePath(),
+		bundle.GetKernelPath(),
+		bundle.GetInitramfsPath(),
+	}
+
+	for _, file := range files {
+		if file == "" {
+			continue
+		}
+		fd, err := os.Create(file)
+		fd.Close()
+		assert.NoError(t, err)
+	}
+}
diff --git a/pkg/crc/machine/sync_test.go b/pkg/crc/machine/sync_test.go
index e29e0e9e8..461afeadd 100644
--- a/pkg/crc/machine/sync_test.go
+++ b/pkg/crc/machine/sync_test.go
@@ -165,3 +165,7 @@ func (m *waitingMachine) Stop() (state.State, error) {
 	<-m.stopCompleteCh
 	return state.Stopped, nil
 }
+
+func (m *waitingMachine) GenerateBundle() error {
+	return errors.New("not implemented")
+}
diff --git a/pkg/crc/preflight/preflight_test.go b/pkg/crc/preflight/preflight_test.go
index c624566ca..74aa312ef 100644
--- a/pkg/crc/preflight/preflight_test.go
+++ b/pkg/crc/preflight/preflight_test.go
@@ -34,11 +34,21 @@ func TestFixPreflight(t *testing.T) {
 	cfg := config.New(config.NewEmptyInMemoryStorage())
 	doRegisterSettings(cfg, []Check{*check})
 
-	assert.NoError(t, doFixPreflightChecks(cfg, []Check{*check}))
+	assert.NoError(t, doFixPreflightChecks(cfg, []Check{*check}, false))
 	assert.True(t, calls.checked)
 	assert.True(t, calls.fixed)
 }
 
+func TestFixPreflightCheckOnly(t *testing.T) {
+	check, calls := sampleCheck(errors.New("check failed"), nil)
+	cfg := config.New(config.NewEmptyInMemoryStorage())
+	doRegisterSettings(cfg, []Check{*check})
+
+	assert.Error(t, doFixPreflightChecks(cfg, []Check{*check}, true))
+	assert.True(t, calls.checked)
+	assert.False(t, calls.fixed)
+}
+
 func sampleCheck(checkErr, fixErr error) (*Check, *status) {
 	status := &status{}
 	return &Check{
diff --git a/test/e2e/crcsuite/crcsuite.go b/test/e2e/crcsuite/crcsuite.go
index 7214a346b..e96aa7431 100644
--- a/test/e2e/crcsuite/crcsuite.go
+++ b/test/e2e/crcsuite/crcsuite.go
@@ -36,6 +36,8 @@ func FeatureContext(s *godog.Suite) {
 		RemoveCRCHome)
 	s.Step(`^starting CRC with default bundle (succeeds|fails)$`,
 		StartCRCWithDefaultBundleSucceedsOrFails)
+	s.Step(`^starting CRC with custom bundle (succeeds|fails)$`,
+		StartCRCWithCustomBundleSucceedsOrFails)
 	s.Step(`^starting CRC with default bundle along with stopped network time synchronization (succeeds|fails)$`,
 		StartCRCWithDefaultBundleWithStopNetworkTimeSynchronizationSucceedsOrFails)
 	s.Step(`^starting CRC with default bundle and nameserver "(.*)" (succeeds|fails)$`,
@@ -346,6 +348,11 @@ func StartCRCWithDefaultBundleWithStopNetworkTimeSynchronizationSucceedsOrFails(
 	return err
 }
 
+func StartCRCWithCustomBundleSucceedsOrFails(expected string) error {
+	cmd := fmt.Sprintf("crc start -p '%s' -b *.crcbundle --log-level debug", pullSecretFile)
+	return clicumber.ExecuteCommandSucceedsOrFails(cmd, expected)
+}
+
 func StartCRCWithDefaultBundleAndNameServerSucceedsOrFails(nameserver string, expected string) error {
 
 	var extraBundleArgs string
diff --git a/test/e2e/features/basic.feature b/test/e2e/features/basic.feature
index dd0e6cd53..42ed9e7b9 100644
--- a/test/e2e/features/basic.feature
+++ b/test/e2e/features/basic.feature
@@ -135,7 +135,14 @@ Feature: Basic test
         And unsetting config property "enable-cluster-monitoring" succeeds
         And unsetting config property "memory" succeeds
 
-    @darwin @linux @windows
+    @linux
+    Scenario: Bundle generation check
+        # This will remove the pull secret from the instance and from the cluster
+        # You need to provide pull secret file again if you want to start this cluster
+        # from an stopped state.
+        Given executing "crc bundle generate" succeeds
+
+    @darwin @windows
     Scenario: CRC forcible stop
         When executing "crc stop -f"
         Then stdout should match "(.*)[Ss]topped the OpenShift cluster"
@@ -156,6 +163,11 @@ Feature: Basic test
         When executing "crc delete -f" succeeds
         Then stdout should contain "Deleted the OpenShift cluster"
 
+    @linux
+    Scenario: CRC starts with generated bundle
+        Given starting CRC with custom bundle succeeds
+        Then stderr should contain "Using custom bundle"
+
     @darwin
     Scenario Outline: CRC clean-up
         When executing "crc cleanup" succeeds