diff --git a/cli/cmd/lint.go b/cli/cmd/lint.go index 1b4662d44..ad2b2c2c2 100644 --- a/cli/cmd/lint.go +++ b/cli/cmd/lint.go @@ -21,16 +21,6 @@ import ( // release-validation-v2 feature flag. The runLint function below is still used // internally by the release lint command. -// getToolVersion extracts a tool version from config, defaulting to "latest" if not found. -func getToolVersion(config *tools.Config, tool string) string { - if config.ReplLint.Tools != nil { - if v, ok := config.ReplLint.Tools[tool]; ok { - return v - } - } - return "latest" -} - // resolveToolVersion extracts and resolves a tool version from config. // If the version is "latest" or empty, it resolves to an actual version using the resolver. // Falls back to the provided default version if resolution fails. diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index b167823d5..338a932bb 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -76,6 +76,8 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) error { cmd.Flags().BoolVar(&r.args.createReleasePromoteEnsureChannel, "ensure-channel", false, "When used with --promote , will create the channel if it doesn't exist") cmd.Flags().BoolVar(&r.args.createReleaseAutoDefaults, "auto", false, "generate default values for use in CI") cmd.Flags().BoolVarP(&r.args.createReleaseAutoDefaultsAccept, "confirm-auto", "y", false, "auto-accept the configuration generated by the --auto flag") + cmd.Flags().StringVar(&r.args.createReleaseOutputDir, "output-dir", "", "Stage the release artifacts (packaged charts and manifests) to this directory. Existing contents of the directory are removed before each run. The directory is preserved after the command completes.") + cmd.Flags().BoolVar(&r.args.createReleaseNoUpload, "no-upload", false, "Build the release locally but do not upload it. Use with --output-dir to inspect or reuse the staged artifacts. Cannot be used with --promote.") // output format cmd.Flags().StringVarP(&r.outputFormat, "output", "o", "table", "The output format to use. One of: json|table") @@ -274,9 +276,11 @@ func (r *runners) releaseCreate(cmd *cobra.Command, args []string) (err error) { return errors.Wrap(err, "resolve app type from config") } - // Defer cleanup of staging directory + // Defer cleanup of staging directory unless the user asked for the + // artifacts to be persisted (--output-dir) or to skip upload (--no-upload), + // in which case they likely want to inspect the staged files. defer func() { - if stagingDir != "" { + if stagingDir != "" && r.args.createReleaseOutputDir == "" && !r.args.createReleaseNoUpload { os.RemoveAll(stagingDir) } }() @@ -393,6 +397,37 @@ Prepared to create release with defaults: log.FinishSpinner() } + // If --output-dir was given for a non-config flow, write the release payload + // there so it can be reused via --yaml. Config flow writes its own staging + // content into output-dir already (see createReleaseFromConfig). + if r.args.createReleaseOutputDir != "" { + outDir, absErr := filepath.Abs(r.args.createReleaseOutputDir) + if absErr != nil { + outDir = r.args.createReleaseOutputDir + } + if !useConfigFlow { + if err := resetOutputDir(outDir); err != nil { + return errors.Wrapf(err, "reset output-dir %s", outDir) + } + payloadPath := filepath.Join(outDir, "release.json") + if err := os.WriteFile(payloadPath, []byte(r.args.createReleaseYaml), 0644); err != nil { + return errors.Wrapf(err, "write release payload to %s", payloadPath) + } + log.ChildActionWithoutSpinner("Release payload written to %s", payloadPath) + } else { + log.ChildActionWithoutSpinner("Release artifacts staged in %s", outDir) + } + } + + // --no-upload: stop here without calling the API. + if r.args.createReleaseNoUpload { + if r.args.createReleaseOutputDir == "" && stagingDir != "" { + log.ChildActionWithoutSpinner("Release artifacts staged in %s", stagingDir) + } + log.ChildActionWithoutSpinner("Skipping upload (--no-upload set)") + return nil + } + // if the --promote param was used make sure it identifies exactly one // channel before proceeding var promoteChanID string @@ -475,6 +510,16 @@ func (r *runners) validateReleaseCreateParams() error { return errors.New("--required can only be used with --promote ") } + // --no-upload skips the upload, so promotion flags don't make sense + if r.args.createReleaseNoUpload { + if r.args.createReleasePromote != "" { + return errors.New("--no-upload cannot be used with --promote (no release is uploaded)") + } + if r.args.createReleasePromoteEnsureChannel { + return errors.New("--no-upload cannot be used with --ensure-channel (no release is uploaded)") + } + } + // If no sources specified, config-based flow will be used (validated elsewhere) if numSources == 0 { return nil @@ -776,12 +821,23 @@ func collectManifests(patterns []string) ([]string, error) { } // createReleaseFromConfig creates a release from .replicated config file -// Returns the staging directory path for cleanup and the release YAML string +// Returns the staging directory path for cleanup and the release YAML string. +// If r.args.createReleaseOutputDir is set, that directory is used (and +// populated) instead of a temporary directory. 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) + if r.args.createReleaseOutputDir != "" { + stagingDir, err = filepath.Abs(r.args.createReleaseOutputDir) + if err != nil { + return "", "", errors.Wrapf(err, "resolve output-dir %s", r.args.createReleaseOutputDir) + } + if err = resetOutputDir(stagingDir); err != nil { + return "", "", errors.Wrapf(err, "reset output-dir %s", stagingDir) + } + } else { + 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 @@ -845,6 +901,22 @@ func (r *runners) createReleaseFromConfig(config *tools.Config, log *logger.Logg return stagingDir, releaseYAML, nil } +// resetOutputDir removes any existing contents of dir and re-creates it empty, +// so each run produces a clean staging directory. If dir does not exist it is +// created. +func resetOutputDir(dir string) error { + if dir == "" { + return errors.New("output-dir path is empty") + } + if err := os.RemoveAll(dir); err != nil { + return errors.Wrapf(err, "remove existing output-dir %s", dir) + } + if err := os.MkdirAll(dir, 0755); err != nil { + return errors.Wrapf(err, "create output-dir %s", dir) + } + return nil +} + // copyFile copies a file from src to dst func copyFile(src, dst string) error { sourceFile, err := os.Open(src) diff --git a/cli/cmd/release_create_test.go b/cli/cmd/release_create_test.go index 05ed05ca2..94c444981 100644 --- a/cli/cmd/release_create_test.go +++ b/cli/cmd/release_create_test.go @@ -1,9 +1,12 @@ package cmd import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestUseConfigFlow_WithAutoFlag tests that --auto flag prevents config-based flow @@ -281,3 +284,117 @@ func TestRequiredFlagRequiresPromoteInConfigBasedFlow(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "--required can only be used with --promote") } + +func TestNoUploadRejectsPromote(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseNoUpload: true, + createReleasePromote: "Unstable", + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.Error(t, err) + assert.Contains(t, err.Error(), "--no-upload cannot be used with --promote") +} + +func TestNoUploadRejectsEnsureChannel(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseNoUpload: true, + createReleasePromoteEnsureChannel: true, + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.Error(t, err) + assert.Contains(t, err.Error(), "--no-upload cannot be used with --ensure-channel") +} + +func TestNoUploadAloneIsValid(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseNoUpload: true, + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.NoError(t, err) +} + +func TestOutputDirAloneIsValid(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseOutputDir: "./out", + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.NoError(t, err) +} + +func TestResetOutputDirCreatesMissingDir(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "out") + + err := resetOutputDir(target) + require.NoError(t, err) + + info, err := os.Stat(target) + require.NoError(t, err) + assert.True(t, info.IsDir(), "expected %s to be a directory", target) + + entries, err := os.ReadDir(target) + require.NoError(t, err) + assert.Empty(t, entries, "expected freshly created output-dir to be empty") +} + +func TestResetOutputDirClearsExistingContents(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "out") + require.NoError(t, os.MkdirAll(filepath.Join(target, "nested"), 0755)) + stale := filepath.Join(target, "stale.txt") + require.NoError(t, os.WriteFile(stale, []byte("old"), 0644)) + + err := resetOutputDir(target) + require.NoError(t, err) + + info, err := os.Stat(target) + require.NoError(t, err) + assert.True(t, info.IsDir()) + + entries, err := os.ReadDir(target) + require.NoError(t, err) + assert.Empty(t, entries, "expected output-dir contents to be cleared before each run") + + _, err = os.Stat(stale) + assert.True(t, os.IsNotExist(err), "expected previous file %s to be removed", stale) +} + +func TestResetOutputDirRejectsEmptyPath(t *testing.T) { + err := resetOutputDir("") + assert.Error(t, err) +} + +func TestOutputDirWithPromoteIsValid(t *testing.T) { + // --output-dir on its own is orthogonal to upload; it must coexist with --promote. + r := &runners{ + args: runnerArgs{ + createReleaseOutputDir: "./out", + createReleasePromote: "Unstable", + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.NoError(t, err) +} diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index 1f54411a4..7e026ebdd 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -91,6 +91,8 @@ type runnerArgs struct { createReleaseAutoDefaults bool createReleaseAutoDefaultsAccept bool + createReleaseOutputDir string + createReleaseNoUpload bool releaseDownloadDest string releaseDownloadChannel string