diff --git a/cli/cmd/init.go b/cli/cmd/init.go index a93a95f03..fe43c0477 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -721,86 +721,6 @@ func (r *runners) promptForPreflightPathsWithCharts(charts []tools.ChartConfig, return preflights, nil } -func (r *runners) promptForPreflightValues(preflightPath string, charts []tools.ChartConfig) (string, error) { - if len(charts) == 0 { - // No charts configured, just ask if they want to specify a custom path - addValuesPath := promptui.Select{ - Label: fmt.Sprintf("Does '%s' use Helm chart values?", filepath.Base(preflightPath)), - Items: []string{"No", "Yes - specify path"}, - } - _, result, err := addValuesPath.Run() - if err != nil { - if err == promptui.ErrInterrupt { - return "", errors.New("cancelled") - } - return "", errors.Wrap(err, "failed to read values option") - } - - if result == "Yes - specify path" { - valuesPathPrompt := promptui.Prompt{ - Label: "Values file path", - Default: "", - } - valuesPath, err := valuesPathPrompt.Run() - if err != nil { - if err == promptui.ErrInterrupt { - return "", errors.New("cancelled") - } - return "", errors.Wrap(err, "failed to read values path") - } - return valuesPath, nil - } - return "", nil - } - - // Charts are configured, offer them as options - options := []string{"No"} - for _, chart := range charts { - options = append(options, fmt.Sprintf("Yes - use %s", chart.Path)) - } - options = append(options, "Yes - other path") - - valuesPrompt := promptui.Select{ - Label: fmt.Sprintf("Does '%s' use Helm chart values?", filepath.Base(preflightPath)), - Items: options, - } - _, result, err := valuesPrompt.Run() - if err != nil { - if err == promptui.ErrInterrupt { - return "", errors.New("cancelled") - } - return "", errors.Wrap(err, "failed to read values option") - } - - if result == "No" { - return "", nil - } - - if result == "Yes - other path" { - valuesPathPrompt := promptui.Prompt{ - Label: "Values file path", - Default: "", - } - valuesPath, err := valuesPathPrompt.Run() - if err != nil { - if err == promptui.ErrInterrupt { - return "", errors.New("cancelled") - } - return "", errors.Wrap(err, "failed to read values path") - } - return valuesPath, nil - } - - // Extract the chart path from the selection - for _, chart := range charts { - if result == fmt.Sprintf("Yes - use %s", chart.Path) { - return chart.Path, nil - } - } - - return "", nil -} - func (r *runners) promptForManifests() ([]string, error) { var manifests []string diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index 66fb3b901..b6e4dd29a 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -1,22 +1,27 @@ package cmd import ( + "context" "encoding/base64" "encoding/json" "fmt" "io/ioutil" "os" + "os/exec" "path/filepath" "strings" "time" + "github.com/bmatcuk/doublestar/v4" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" + "github.com/google/uuid" "github.com/manifoldco/promptui" "github.com/pkg/errors" "github.com/replicatedhq/replicated/client" kotstypes "github.com/replicatedhq/replicated/pkg/kots/release/types" "github.com/replicatedhq/replicated/pkg/logger" + "github.com/replicatedhq/replicated/pkg/tools" "github.com/replicatedhq/replicated/pkg/types" "github.com/spf13/cobra" "helm.sh/helm/v3/pkg/chart/loader" @@ -31,7 +36,22 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) error { Use: "create", Short: "Create a new release", Long: `Create a new release by providing application manifests for the next release in - your sequence.`, + your sequence. + + If no flags are provided, the command will automatically use the configuration from + .replicated file in the current directory (or parent directories). The config should + specify charts and manifests to include. Charts will be automatically packaged using + helm, and manifests will be collected using glob patterns. + + Example .replicated config: + appSlug: "my-app" + charts: + - path: ./chart + manifests: + - ./manifests/*.yaml + + With this config, simply run: + replicated release create --version 1.0.0 --promote Unstable`, SilenceUsage: false, SilenceErrors: true, // this command uses custom error printing } @@ -63,6 +83,50 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) error { cmd.Flags().MarkHidden("yaml") cmd.Flags().MarkHidden("chart") + // Override parent's PersistentPreRunE to handle config-based flow + // The parent prerun tries to resolve app from cache/env which may fail + // when using a different profile. For config flow, we'll resolve the app ourselves. + originalPreRun := parent.PersistentPreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + // Check if we're using config-based flow + useConfigFlow := r.args.createReleaseYaml == "" && + r.args.createReleaseYamlFile == "" && + r.args.createReleaseYamlDir == "" && + r.args.createReleaseChart == "" + + if useConfigFlow { + // For config flow, temporarily clear app state before calling parent prerun + // This prevents the parent from trying to resolve a cached/env app that doesn't exist in this profile + savedAppID := r.appID + savedAppSlug := r.appSlug + savedAppType := r.appType + + r.appID = "" + r.appSlug = "" + r.appType = "" + + // Call parent prerun (will setup APIs but skip app resolution since app is empty) + if originalPreRun != nil { + if err := originalPreRun(cmd, args); err != nil { + // Restore values + r.appID = savedAppID + r.appSlug = savedAppSlug + r.appType = savedAppType + return err + } + } + + // Keep app cleared - we'll load from config in releaseCreate + return nil + } + + // Non-config flow: use normal parent prerun + if originalPreRun != nil { + return originalPreRun(cmd, args) + } + return nil + } + cmd.RunE = r.releaseCreate return nil } @@ -168,7 +232,53 @@ func (r *runners) releaseCreate(cmd *cobra.Command, args []string) (err error) { log.Silence() } - if !r.hasApp() { + // Check if we should use config-based flow (no explicit source flags provided) + useConfigFlow := r.args.createReleaseYaml == "" && + r.args.createReleaseYamlFile == "" && + r.args.createReleaseYamlDir == "" && + r.args.createReleaseChart == "" + + var config *tools.Config + var stagingDir string + + if useConfigFlow { + // Try to find and parse .replicated config + parser := tools.NewConfigParser() + var configErr error + config, configErr = parser.FindAndParseConfig(".") + if configErr != nil { + return errors.Wrap(configErr, "failed to find or parse .replicated config file") + } + + // Check if config has any charts or manifests configured + if len(config.Charts) == 0 && len(config.Manifests) == 0 { + return errors.New("no charts or manifests configured in .replicated config file. Either configure them in .replicated or use --yaml-dir flag") + } + + // Validate app ID/slug from config vs CLI flag + if err := r.validateAppFromConfig(config); err != nil { + return err + } + + // After loading app from config, we need to re-resolve the app type + // because the prerun might have run with a different app (from cache/env) before we loaded the config + // Clear the appType to force a fresh resolution + r.appType = "" + if err := r.resolveAppType(); err != nil { + return errors.Wrap(err, "resolve app type from config") + } + + // Defer cleanup of staging directory + defer func() { + if stagingDir != "" { + os.RemoveAll(stagingDir) + } + }() + } + + // When using config flow, we've already loaded and resolved the app + // For non-config flow, check if app was provided + if !useConfigFlow && !r.hasApp() { return errors.New("no app specified") } @@ -253,6 +363,16 @@ Prepared to create release with defaults: log.FinishSpinner() } + // Handle config-based flow + if useConfigFlow && config != nil { + fmt.Fprintln(r.w) + var err error + stagingDir, r.args.createReleaseYaml, err = r.createReleaseFromConfig(config, log) + if err != nil { + return errors.Wrap(err, "create release from config") + } + } + if r.args.createReleaseChart != "" { fmt.Fprint(r.w, "You are creating a release that will only be installable with the helm CLI.\n"+ "For more information, see \n"+ @@ -344,8 +464,9 @@ func (r *runners) validateReleaseCreateParams() error { } } + // If no sources specified, config-based flow will be used (validated elsewhere) if numSources == 0 { - return errors.New("one of --yaml, --yaml-file, --yaml-dir, or --chart is required") + return nil } if numSources > 1 { @@ -548,3 +669,274 @@ func isSupportedExt(ext string) bool { return false } } + +// packageChart runs helm dependency update and helm package on a chart directory +// Returns the path to the packaged .tgz file +func packageChart(chartPath string) (string, error) { + absChartPath, err := filepath.Abs(chartPath) + if err != nil { + return "", errors.Wrapf(err, "resolve absolute path for chart %s", chartPath) + } + + // Check if chartPath is a directory containing Chart.yaml + chartYAMLPath := filepath.Join(absChartPath, "Chart.yaml") + if _, err := os.Stat(chartYAMLPath); err != nil { + return "", errors.Wrapf(err, "chart directory %s must contain Chart.yaml", chartPath) + } + + // Run helm dependency update + depCmd := exec.Command("helm", "dependency", "update") + depCmd.Dir = absChartPath + depOutput, err := depCmd.CombinedOutput() + if err != nil { + return "", errors.Wrapf(err, "helm dependency update failed in %s: %s", chartPath, string(depOutput)) + } + + // Run helm package to create .tgz in the chart directory + pkgCmd := exec.Command("helm", "package", ".") + pkgCmd.Dir = absChartPath + pkgOutput, err := pkgCmd.CombinedOutput() + if err != nil { + return "", errors.Wrapf(err, "helm package failed in %s: %s", chartPath, string(pkgOutput)) + } + + // Parse output to find the packaged .tgz filename + // Output format: "Successfully packaged chart and saved it to: /path/to/chart-version.tgz" + outputStr := string(pkgOutput) + lines := strings.Split(outputStr, "\n") + var tgzPath string + for _, line := range lines { + if strings.Contains(line, "Successfully packaged chart") && strings.Contains(line, ".tgz") { + parts := strings.Split(line, ": ") + if len(parts) >= 2 { + tgzPath = strings.TrimSpace(parts[len(parts)-1]) + break + } + } + } + + if tgzPath == "" { + return "", errors.Errorf("could not determine packaged chart path from helm output: %s", outputStr) + } + + // Verify the file exists + if _, err := os.Stat(tgzPath); err != nil { + return "", errors.Wrapf(err, "packaged chart file not found at %s", tgzPath) + } + + return tgzPath, nil +} + +// collectManifests resolves glob patterns and returns a list of manifest file paths +func collectManifests(patterns []string) ([]string, error) { + var manifestPaths []string + seenPaths := make(map[string]bool) + + for _, pattern := range patterns { + // Resolve glob pattern + matches, err := doublestar.FilepathGlob(pattern) + if err != nil { + return nil, errors.Wrapf(err, "invalid glob pattern %s", pattern) + } + + for _, match := range matches { + // Skip directories, only include files + info, err := os.Stat(match) + if err != nil { + continue + } + if info.IsDir() { + continue + } + + // Deduplicate + absPath, err := filepath.Abs(match) + if err != nil { + continue + } + if !seenPaths[absPath] { + seenPaths[absPath] = true + manifestPaths = append(manifestPaths, absPath) + } + } + } + + return manifestPaths, nil +} + +// createReleaseFromConfig creates a release from .replicated config file +// Returns the staging directory path for cleanup and the release YAML string +func (r *runners) createReleaseFromConfig(config *tools.Config, log *logger.Logger) (stagingDir string, releaseYAML string, err error) { + // Create temporary staging directory + stagingDir = filepath.Join(os.TempDir(), fmt.Sprintf("replicated-release-%s", uuid.New().String())) + if err = os.MkdirAll(stagingDir, 0755); err != nil { + return "", "", errors.Wrapf(err, "create staging directory %s", stagingDir) + } + + // Package all charts + log.ActionWithSpinner("Packaging charts") + var packagedCharts []string + for _, chart := range config.Charts { + tgzPath, err := packageChart(chart.Path) + if err != nil { + log.FinishSpinnerWithError() + return stagingDir, "", errors.Wrapf(err, "package chart %s", chart.Path) + } + packagedCharts = append(packagedCharts, tgzPath) + } + log.FinishSpinner() + + // Copy packaged charts to staging directory + if len(packagedCharts) > 0 { + log.ActionWithSpinner("Copying packaged charts to staging directory") + for _, tgzPath := range packagedCharts { + destPath := filepath.Join(stagingDir, filepath.Base(tgzPath)) + if err := copyFile(tgzPath, destPath); err != nil { + log.FinishSpinnerWithError() + return stagingDir, "", errors.Wrapf(err, "copy chart %s to staging", tgzPath) + } + } + log.FinishSpinner() + } + + // Collect and copy manifest files + if len(config.Manifests) > 0 { + log.ActionWithSpinner("Collecting manifest files") + manifestPaths, err := collectManifests(config.Manifests) + if err != nil { + log.FinishSpinnerWithError() + return stagingDir, "", errors.Wrap(err, "collect manifests") + } + log.FinishSpinner() + + if len(manifestPaths) > 0 { + log.ActionWithSpinner("Copying manifests to staging directory") + for _, manifestPath := range manifestPaths { + destPath := filepath.Join(stagingDir, filepath.Base(manifestPath)) + if err := copyFile(manifestPath, destPath); err != nil { + log.FinishSpinnerWithError() + return stagingDir, "", errors.Wrapf(err, "copy manifest %s to staging", manifestPath) + } + } + log.FinishSpinner() + } + } + + // Generate release YAML from staging directory + log.ActionWithSpinner("Reading manifests from staging directory") + releaseYAML, err = makeReleaseFromDir(stagingDir) + if err != nil { + log.FinishSpinnerWithError() + return stagingDir, "", errors.Wrap(err, "make release from staging directory") + } + log.FinishSpinner() + + return stagingDir, releaseYAML, nil +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return errors.Wrapf(err, "open source file %s", src) + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return errors.Wrapf(err, "create destination file %s", dst) + } + defer destFile.Close() + + if _, err := destFile.ReadFrom(sourceFile); err != nil { + return errors.Wrapf(err, "copy file from %s to %s", src, dst) + } + + return nil +} + +// resolveAppType resolves the app type by querying the API with the current appID or appSlug +func (r *runners) resolveAppType() error { + if r.appID == "" && r.appSlug == "" { + return nil // nothing to resolve + } + + appSlugOrID := r.appSlug + if appSlugOrID == "" { + appSlugOrID = r.appID + } + + app, appType, err := r.api.GetAppType(context.Background(), appSlugOrID, true) + if err != nil { + return errors.Wrapf(err, "get app type for %q", appSlugOrID) + } + + r.appType = appType + r.appID = app.ID + r.appSlug = app.Slug + + return nil +} + +// validateAppFromConfig validates that the app from config doesn't conflict with CLI --app flag +func (r *runners) validateAppFromConfig(config *tools.Config) error { + configAppSlug := config.AppSlug + configAppId := config.AppId + + // If config has an app configured + if configAppSlug != "" || configAppId != "" { + // If CLI --app flag was provided, validate it matches config + if r.appID != "" || r.appSlug != "" { + // Allow if CLI flag matches either the config's appId or appSlug + // This handles cases where user passes appId and config has appSlug (or vice versa) + // as long as they refer to the same app + cliMatchesConfig := false + + // Check if CLI appID matches config appId + if r.appID != "" && configAppId != "" && r.appID == configAppId { + cliMatchesConfig = true + } + + // Check if CLI appSlug matches config appSlug + if r.appSlug != "" && configAppSlug != "" && r.appSlug == configAppSlug { + cliMatchesConfig = true + } + + // Check if CLI appID matches config appSlug (or vice versa) + // We need to resolve this via API to check if they're the same app + if r.appID != "" && configAppSlug != "" && !cliMatchesConfig { + // The appID from CLI might be the ID for the slug in config + // We'll allow this and let the API resolve it + cliMatchesConfig = true + } + + if r.appSlug != "" && configAppId != "" && !cliMatchesConfig { + // The appSlug from CLI might be the slug for the ID in config + // We'll allow this and let the API resolve it + cliMatchesConfig = true + } + + // If we couldn't match, show error with both values + if !cliMatchesConfig { + configValue := configAppSlug + if configValue == "" { + configValue = configAppId + } + cliValue := r.appSlug + if cliValue == "" { + cliValue = r.appID + } + return errors.Errorf("app mismatch: .replicated config specifies app %q but --app flag specifies %q. Remove --app flag or update .replicated config", configValue, cliValue) + } + } else { + // No CLI flag provided, use app from config + if config.AppSlug != "" { + r.appSlug = config.AppSlug + } else { + r.appID = config.AppId + } + } + } + + return nil +} diff --git a/cli/cmd/release_download.go b/cli/cmd/release_download.go index 98fe04d0c..8133b61cc 100644 --- a/cli/cmd/release_download.go +++ b/cli/cmd/release_download.go @@ -1,29 +1,99 @@ package cmd import ( + "archive/tar" + "compress/gzip" + "context" "fmt" + "io" "os" + "path/filepath" "strconv" "github.com/pkg/errors" kotsrelease "github.com/replicatedhq/replicated/pkg/kots/release" "github.com/replicatedhq/replicated/pkg/logger" + "github.com/replicatedhq/replicated/pkg/tools" "github.com/spf13/cobra" ) func (r *runners) InitReleaseDownload(parent *cobra.Command) { cmd := &cobra.Command{ - Use: "download RELEASE_SEQUENCE", - Short: "Download application manifests for a release.", - Long: `Download application manifests for a release to a specified directory. + Use: "download [RELEASE_SEQUENCE]", + Short: "Download application manifests for a release.", + SilenceUsage: true, + Long: `Download application manifests for a release to a specified file or directory. -For non-KOTS applications, this is equivalent to the 'release inspect' command.`, - Example: `replicated release download 1 --dest ./manifests`, - Args: cobra.ExactArgs(1), +For KOTS applications: + - Downloads release as a .tgz file if no RELEASE_SEQUENCE specified + - Can specify --channel to download the current release from that channel + - Auto-generates filename as app-slug.tgz if --dest not provided + +For non-KOTS applications, this is equivalent to the 'release inspect' command. + +If no app is specified via --app flag, the app slug will be loaded from the .replicated config file.`, + Example: `# Download latest release as autoci.tgz +replicated release download + +# Download specific sequence +replicated release download 42 --dest my-release.tgz + +# Download current release from Unstable channel +replicated release download --channel Unstable + +# Download to directory (KOTS only with sequence) +replicated release download 1 --dest ./manifests`, + Args: cobra.MaximumNArgs(1), } parent.AddCommand(cmd) + + // Similar to release create, handle config-based flow in PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + // Check if --app flag was explicitly provided by the user + appFlagProvided := cmd.Flags().Changed("app") + + // Check if we should use config-based flow (no --app flag was provided) + // Note: Parent's PersistentPreRunE runs BEFORE our PreRunE, so appID/appSlug + // may already be set from cache/env even if user didn't provide --app flag + usingConfigFlow := false + if !appFlagProvided { + parser := tools.NewConfigParser() + config, err := parser.FindAndParseConfig(".") + if err == nil && (config.AppSlug != "" || config.AppId != "") { + usingConfigFlow = true + // Set app from config + if config.AppSlug != "" { + r.appSlug = config.AppSlug + } else { + r.appID = config.AppId + } + } + } + + if usingConfigFlow { + // The parent's PersistentPreRunE already ran and may have set wrong app from cache + // We need to override it with the app from config and re-resolve + + // Clear the wrong app state that parent set + r.appID = "" + r.appType = "" + // r.appSlug is already set from config above + + // Resolve the app using the correct profile's API + if err := r.resolveAppTypeForDownload(); err != nil { + return errors.Wrap(err, "resolve app type from config") + } + + return nil + } + + // Normal flow - --app flag was provided, parent prerun already handled it + return nil + } + cmd.RunE = r.releaseDownload - cmd.Flags().StringVarP(&r.args.releaseDownloadDest, "dest", "d", "", "Directory to which release manifests should be downloaded") + cmd.Flags().StringVarP(&r.args.releaseDownloadDest, "dest", "d", "", "File or directory to which release should be downloaded. Auto-generated if not specified.") + cmd.Flags().StringVarP(&r.args.releaseDownloadChannel, "channel", "c", "", "Download the current release from this channel (case sensitive)") } func (r *runners) releaseDownload(command *cobra.Command, args []string) error { @@ -35,31 +105,235 @@ func (r *runners) releaseDownload(command *cobra.Command, args []string) error { return r.releaseInspect(command, args) } - seq, err := strconv.ParseInt(args[0], 10, 64) + log := logger.NewLogger(os.Stdout) + + // Determine sequence to download + var seq int64 + var err error + + if r.args.releaseDownloadChannel != "" { + // Download from channel + if len(args) > 0 { + return errors.New("cannot specify both sequence and --channel flag") + } + + log.ActionWithSpinner("Finding channel %q", r.args.releaseDownloadChannel) + channel, err := r.api.GetChannelByName(r.appID, r.appType, r.args.releaseDownloadChannel) + if err != nil { + log.FinishSpinnerWithError() + return errors.Wrapf(err, "get channel %q", r.args.releaseDownloadChannel) + } + + if channel.ReleaseSequence == 0 { + log.FinishSpinnerWithError() + return errors.Errorf("channel %q has no releases", r.args.releaseDownloadChannel) + } + + seq = channel.ReleaseSequence + log.FinishSpinner() + log.ActionWithoutSpinner("Channel %q is at sequence %d", r.args.releaseDownloadChannel, seq) + } else if len(args) > 0 { + // Use provided sequence + seq, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("failed to parse sequence argument %q", args[0]) + } + } else { + // Download latest release + log.ActionWithSpinner("Finding latest release") + channels, err := r.api.ListChannels(r.appID, r.appType, "") + if err != nil { + log.FinishSpinnerWithError() + return errors.Wrap(err, "list channels to find latest release") + } + + var latestSeq int64 + for _, channel := range channels { + if channel.ReleaseSequence > latestSeq { + latestSeq = channel.ReleaseSequence + } + } + + if latestSeq == 0 { + log.FinishSpinnerWithError() + return errors.New("no releases found") + } + + seq = latestSeq + log.FinishSpinner() + log.ActionWithoutSpinner("Latest release is sequence %d", seq) + } + + // Determine destination + dest := r.args.releaseDownloadDest + if dest == "" { + // Auto-generate filename + dest = r.generateDownloadFilename() + } + + // Check if dest is a directory or file + destInfo, statErr := os.Stat(dest) + isDir := statErr == nil && destInfo.IsDir() + + if isDir { + // Legacy behavior: unpack to directory + log.ActionWithSpinner("Fetching Release %d", seq) + release, err := r.api.GetRelease(r.appID, r.appType, seq) + if err != nil { + log.FinishSpinnerWithError() + return errors.Wrap(err, "get release") + } + log.FinishSpinner() + + log.ActionWithoutSpinner("Writing files to %s", dest) + err = kotsrelease.Save(dest, release, log) + if err != nil { + return errors.Wrap(err, "save release") + } + } else { + // Download as .tgz file + log.ActionWithSpinner("Downloading Release %d as %s", seq, dest) + if err := r.downloadReleaseArchive(seq, dest); err != nil { + log.FinishSpinnerWithError() + return errors.Wrap(err, "download release archive") + } + log.FinishSpinner() + log.ActionWithoutSpinner("Release %d downloaded to %s", seq, dest) + } + + return nil +} + +// resolveAppTypeForDownload resolves the app type for download command +func (r *runners) resolveAppTypeForDownload() error { + if r.appID == "" && r.appSlug == "" { + return nil + } + + appSlugOrID := r.appSlug + if appSlugOrID == "" { + appSlugOrID = r.appID + } + + app, appType, err := r.api.GetAppType(context.Background(), appSlugOrID, true) if err != nil { - return fmt.Errorf("Failed to parse sequence argument %q", args[0]) + return errors.Wrapf(err, "get app type for %q", appSlugOrID) } - if r.args.releaseDownloadDest == "" { - return errors.New("Downloading a release for a KOTS application requires a --dest directory to unpack the manifests, e.g. \"./manifests\"") + r.appType = appType + r.appID = app.ID + r.appSlug = app.Slug + + return nil +} + +// generateDownloadFilename generates a filename like app-slug.tgz or app-slug-2.tgz if it exists +func (r *runners) generateDownloadFilename() string { + base := r.appSlug + if base == "" { + base = r.appID + } + + filename := fmt.Sprintf("%s.tgz", base) + if _, err := os.Stat(filename); err == nil { + // File exists, try with incrementing number + for i := 2; i < 1000; i++ { + filename = fmt.Sprintf("%s-%d.tgz", base, i) + if _, err := os.Stat(filename); err != nil { + break + } + } } + + return filename +} - log := logger.NewLogger(os.Stdout) - log.ActionWithSpinner("Fetching Release %d", seq) +// downloadReleaseArchive downloads the release archive (.tgz) from the API +func (r *runners) downloadReleaseArchive(seq int64, dest string) error { + // Get release to find the download URL release, err := r.api.GetRelease(r.appID, r.appType, seq) if err != nil { - log.FinishSpinnerWithError() return errors.Wrap(err, "get release") } - log.FinishSpinner() - log.ActionWithoutSpinner("Writing files to %s", r.args.releaseDownloadDest) + // The release config is base64 encoded JSON, we need to get the raw archive + // For now, we'll use the kotsrelease.Save to a temp dir then tar it up + // TODO: Look for a direct archive download endpoint + + tempDir, err := os.MkdirTemp("", "replicated-download-*") + if err != nil { + return errors.Wrap(err, "create temp directory") + } + defer os.RemoveAll(tempDir) + + log := logger.NewLogger(os.Stdout) + if err := kotsrelease.Save(tempDir, release, log); err != nil { + return errors.Wrap(err, "save release to temp dir") + } - err = kotsrelease.Save(r.args.releaseDownloadDest, release, log) + // Create tar.gz from the temp directory + return tarDirectory(tempDir, dest) +} + +// tarDirectory creates a .tgz archive from a directory +func tarDirectory(srcDir, destFile string) error { + // Create the destination file + outFile, err := os.Create(destFile) if err != nil { - return errors.Wrap(err, "save release") + return errors.Wrapf(err, "create output file %s", destFile) } + defer outFile.Close() - return nil + // Create gzip writer + gzipWriter := gzip.NewWriter(outFile) + defer gzipWriter.Close() + + // Create tar writer + tarWriter := tar.NewWriter(gzipWriter) + defer tarWriter.Close() + + // Walk the source directory + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return errors.Wrapf(err, "get relative path for %s", path) + } + + // Skip the root directory itself + if relPath == "." { + return nil + } + + // Create tar header + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return errors.Wrapf(err, "create tar header for %s", path) + } + header.Name = relPath + + // Write header + if err := tarWriter.WriteHeader(header); err != nil { + return errors.Wrapf(err, "write tar header for %s", path) + } + + // If it's a file, write its contents + if !info.IsDir() { + file, err := os.Open(path) + if err != nil { + return errors.Wrapf(err, "open file %s", path) + } + defer file.Close() + + if _, err := io.Copy(tarWriter, file); err != nil { + return errors.Wrapf(err, "write file %s to tar", path) + } + } + return nil + }) } diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index a3847929a..7c5b9f617 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -92,6 +92,7 @@ type runnerArgs struct { createReleaseAutoDefaultsAccept bool releaseDownloadDest string + releaseDownloadChannel string createInstallerAutoDefaults bool createInstallerAutoDefaultsAccept bool