diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index 3e10e4bb3..66fb3b901 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -54,6 +54,9 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) error { 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") + // output format + cmd.Flags().StringVarP(&r.outputFormat, "output", "o", "table", "The output format to use. One of: json|table") + // not supported for KOTS cmd.Flags().MarkHidden("required") cmd.Flags().MarkHidden("yaml-file") @@ -160,6 +163,10 @@ func (r *runners) releaseCreate(cmd *cobra.Command, args []string) (err error) { }() log := logger.NewLogger(r.w) + if r.outputFormat == "json" { + // suppress log lines for machine-readable output + log.Silence() + } if !r.hasApp() { return errors.New("no app specified") @@ -283,7 +290,21 @@ Prepared to create release with defaults: } log.FinishSpinner() - log.ChildActionWithoutSpinner("SEQUENCE: %d", release.Sequence) + if r.outputFormat == "json" { + type createReleaseOutput struct { + Sequence int64 `json:"sequence"` + AppID string `json:"appId,omitempty"` + Charts []types.Chart `json:"charts,omitempty"` + } + out := createReleaseOutput{Sequence: release.Sequence, AppID: release.AppID, Charts: release.Charts} + enc := json.NewEncoder(r.w) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + return errors.Wrap(err, "encode json output") + } + } else { + log.ChildActionWithoutSpinner("SEQUENCE: %d", release.Sequence) + } if promoteChanID != "" { log.ActionWithSpinner("Promoting") diff --git a/pkg/integration/api_test.go b/pkg/integration/api_test.go index 62d130f51..b33c5e61c 100644 --- a/pkg/integration/api_test.go +++ b/pkg/integration/api_test.go @@ -127,7 +127,9 @@ func TestAPI(t *testing.T) { out, err := cmd.CombinedOutput() if tt.wantError != "" { + assert.Regexp(t, `^Error:`, string(out)) assert.Contains(t, string(out), tt.wantError) + return } else { assert.NoError(t, err) } diff --git a/pkg/integration/release_test.go b/pkg/integration/release_test.go new file mode 100644 index 000000000..eb81a9b3a --- /dev/null +++ b/pkg/integration/release_test.go @@ -0,0 +1,116 @@ +package integration + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReleaseCreate(t *testing.T) { + tests := []struct { + name string + cliArgs []string + wantFormat format + wantLines int + wantOutput string + setup func(t *testing.T) *httptest.Server + }{ + { + name: "release create table", + cliArgs: []string{"release", "create"}, + wantFormat: FormatTable, + wantLines: 2, // Creating Release ✓ + SEQUENCE line + setup: func(t *testing.T) *httptest.Server { + r := mux.NewRouter() + + // List apps used by GetAppType("test-app") path; our CLI resolves app via API + r.Methods(http.MethodGet).Path("/v1/apps").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"app":{"id":"app-123","name":"test-app","slug":"test-app","scheduler":"native"}}]`)) + }) + + // Create release + r.Methods(http.MethodPost).Path("/v1/app/app-123/release").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"appId":"app-123","sequence":42}`)) + }) + + // Update release YAML after creation + r.Methods(http.MethodPut).Path("/v1/app/app-123/42/raw").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Contains(t, string(body), "apiVersion:") + w.WriteHeader(http.StatusOK) + }) + + return httptest.NewServer(r) + }, + }, + { + name: "release create json", + cliArgs: []string{"release", "create", "--output", "json"}, + wantFormat: FormatJSON, + wantLines: 0, + wantOutput: `{ + "sequence": 43, + "appId": "app-123" +} +`, + setup: func(t *testing.T) *httptest.Server { + r := mux.NewRouter() + + r.Methods(http.MethodGet).Path("/v1/apps").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"app":{"id":"app-123","name":"test-app","slug":"test-app","scheduler":"native"}}]`)) + }) + + r.Methods(http.MethodPost).Path("/v1/app/app-123/release").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"appId":"app-123","sequence":43}`)) + }) + + r.Methods(http.MethodPut).Path("/v1/app/app-123/43/raw").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + return httptest.NewServer(r) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := tt.setup(t) + defer server.Close() + + // Create a temporary yaml-dir with a simple manifest and pass it + tempDir := t.TempDir() + manifest := "apiVersion: kots.io/v1beta1\nkind: Application\nmetadata:\n name: test-app\nspec:\n title: Test App\n" + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "app.yaml"), []byte(manifest), 0644)) + // Append flag at the end to avoid changing per-test cliArgs above + tt.cliArgs = append(tt.cliArgs, "--yaml-file", filepath.Join(tempDir, "app.yaml")) + + // Set REPLICATED_APP to allow resolving the app + cmd := getCommand(tt.cliArgs, server) + cmd.Env = append(cmd.Env, "REPLICATED_APP=test-app") + cmd.Env = append(cmd.Env, "HOME="+tempDir) + + out, err := cmd.CombinedOutput() + assert.NoError(t, err) + + if tt.wantOutput != "" { + require.Equal(t, tt.wantOutput, string(out)) + return + } + + AssertCLIOutput(t, string(out), tt.wantFormat, tt.wantLines) + }) + } +} diff --git a/pkg/integration/util_test.go b/pkg/integration/util_test.go index 107c807a2..51ca1a0cc 100644 --- a/pkg/integration/util_test.go +++ b/pkg/integration/util_test.go @@ -8,6 +8,8 @@ import ( "os/exec" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func getCommand(cliArgs []string, server *httptest.Server) *exec.Cmd { @@ -46,6 +48,9 @@ func AssertCLIOutput(t *testing.T, got string, wantFormat format, wantLines int) t.Errorf("got %d lines, want %d:\n%s", len(gotLines), wantLines, got) } } + + // require that the output does not start with "Error:" + assert.NotRegexp(t, `^Error:`, got) } func AssertAPIRequests(t *testing.T, wantAPIRequests []string, apiCallLogFilename string) {