Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions cli/cmd/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
banjoh marked this conversation as resolved.
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.
Expand Down
89 changes: 82 additions & 7 deletions cli/cmd/release_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <channel>, 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")
Expand Down Expand Up @@ -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)
}
}()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -475,6 +510,19 @@ func (r *runners) validateReleaseCreateParams() error {
return errors.New("--required can only be used with --promote <channel>")
}

// --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 r.args.createReleasePromoteRequired {
return errors.New("--no-upload cannot be used with --required (no release is uploaded)")
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

// If no sources specified, config-based flow will be used (validated elsewhere)
if numSources == 0 {
return nil
Expand Down Expand Up @@ -776,12 +824,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
Expand Down Expand Up @@ -845,6 +904,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)
Expand Down
133 changes: 133 additions & 0 deletions cli/cmd/release_create_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -281,3 +284,133 @@ 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 TestNoUploadRejectsRequired(t *testing.T) {
r := &runners{
args: runnerArgs{
createReleaseNoUpload: true,
createReleasePromoteRequired: 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 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)
}
2 changes: 2 additions & 0 deletions cli/cmd/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ type runnerArgs struct {

createReleaseAutoDefaults bool
createReleaseAutoDefaultsAccept bool
createReleaseOutputDir string
createReleaseNoUpload bool

releaseDownloadDest string
releaseDownloadChannel string
Expand Down
Loading