Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ bin/
/.agents/plans/
HANDOVER.md
.vscode/
.worktrees/
94 changes: 31 additions & 63 deletions cmd/stackdome/addon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,49 +14,36 @@ import (
"time"

"github.com/Stackdome/stackdome-cli/internal/config"
"github.com/spf13/cobra"
)

const postgresAddonJSON = `{"id":"pg-1","name":"demo","spec":{"version":{"major":16},"instances":{"count":1},"storage":{"size":"10Gi"},"databases":[{"name":"demo"}]},"status":{"state":"Pending"}}`

func TestRootRegistersPostgresShortcutWithSameHelpAndCreateFlags(t *testing.T) {
func TestRootRegistersPostgresVerbFirstCommands(t *testing.T) {
root := newRootCmd()
shortcut, _, err := root.Find([]string{"postgres"})
if err != nil || shortcut == root || shortcut.CommandPath() != "stackdome postgres" {
t.Fatalf("find top-level postgres = %v, %v; want registered shortcut", shortcut, err)
create, _, err := root.Find([]string{"create", "postgres-addon", "demo"})
if err != nil || create.CommandPath() != "stackdome create postgres-addon" {
t.Fatalf("find create postgres-addon = %v, %v", create, err)
}
legacy, _, err := root.Find([]string{"addon", "postgres"})
if err != nil || legacy == root || legacy.CommandPath() != "stackdome addon postgres" {
t.Fatalf("find legacy postgres = %v, %v; want retained path", legacy, err)
}

for _, command := range []*cobra.Command{shortcut, legacy} {
create, _, err := command.Find([]string{"create"})
if err != nil {
t.Fatalf("find %s create: %v", command.CommandPath(), err)
}
for _, flag := range []string{"database", "superuser", "version", "instances", "storage", "wait", "timeout"} {
if create.Flags().Lookup(flag) == nil {
t.Errorf("%s create missing --%s", command.CommandPath(), flag)
}
for _, flag := range []string{"database", "superuser", "version", "instances", "storage", "wait", "timeout"} {
if create.Flags().Lookup(flag) == nil {
t.Errorf("%s missing --%s", create.CommandPath(), flag)
}
}

for _, args := range [][]string{{"postgres", "--help"}, {"addon", "postgres", "--help"}} {
var stdout, stderr bytes.Buffer
code := runWithWriters(args, &stdout, &stderr)
if code != 0 {
t.Fatalf("%v help exit = %d, stderr = %s", args, code, stderr.String())
}
for _, subcommand := range []string{"create", "list", "info", "delete", "credentials", "backup", "backups"} {
if !strings.Contains(stdout.String(), subcommand) {
t.Errorf("%v help omitted %q:\n%s", args, subcommand, stdout.String())
}
for _, path := range [][]string{
{"get", "postgres-addons"}, {"list", "postgres-addons"},
{"get", "postgres-addon", "demo"}, {"describe", "postgres-addon", "demo"},
{"get", "postgres-credentials", "demo", "app"},
{"get", "postgres-backups", "demo"}, {"list", "postgres-backups", "demo"},
{"backup", "postgres-addon", "demo"}, {"delete", "postgres-addon", "demo"},
} {
cmd, _, err := root.Find(path)
if err != nil || !cmd.Runnable() {
t.Errorf("path %v did not resolve to a runnable command: %v, %v", path, cmd, err)
}
}
}

func TestPostgresShortcutAndLegacyPathsRouteCreateAndListEquivalently(t *testing.T) {
func TestPostgresGetAndListRouteEquivalently(t *testing.T) {
type request struct {
method string
path string
Expand Down Expand Up @@ -84,48 +71,29 @@ func TestPostgresShortcutAndLegacyPathsRouteCreateAndListEquivalently(t *testing
defer server.Close()
configurePostgresCLI(t, server.URL)

paths := [][]string{{"postgres"}, {"addon", "postgres"}}
var createOutputs, listOutputs []string
for _, path := range paths {
createArgs := append(append([]string{}, path...), "create", "demo", "--database", "demo", "--version", "16", "--instances", "1", "--storage", "10Gi", "-o", "json")
stdout, stderr, code := runPostgresCLI(createArgs)
if code != 0 {
t.Fatalf("%v exit = %d, stderr = %s", createArgs, code, stderr)
}
createOutputs = append(createOutputs, stdout)

listArgs := append(append([]string{}, path...), "list", "-o", "json")
stdout, stderr, code = runPostgresCLI(listArgs)
var listOutputs []string
for _, listArgs := range [][]string{{"get", "postgres-addons", "-o", "json"}, {"list", "postgres-addons", "-o", "json"}} {
stdout, stderr, code := runPostgresCLI(listArgs)
if code != 0 {
t.Fatalf("%v exit = %d, stderr = %s", listArgs, code, stderr)
}
listOutputs = append(listOutputs, stdout)
}
if createOutputs[0] != createOutputs[1] {
t.Errorf("create outputs differ:\nshortcut: %s\nlegacy: %s", createOutputs[0], createOutputs[1])
}
if listOutputs[0] != listOutputs[1] {
t.Errorf("list outputs differ:\nshortcut: %s\nlegacy: %s", listOutputs[0], listOutputs[1])
t.Errorf("get/list outputs differ:\nget: %s\nlist: %s", listOutputs[0], listOutputs[1])
}

mu.Lock()
defer mu.Unlock()
if len(requests) != 4 {
t.Fatalf("requests = %#v, want four", requests)
if len(requests) != 2 {
t.Fatalf("requests = %#v, want two", requests)
}
const endpoint = "/api/v1/organizations/org-1/projects/proj-1/addons/postgres"
for i, got := range requests {
wantMethod := http.MethodPost
if i%2 == 1 {
wantMethod = http.MethodGet
}
if got.method != wantMethod || got.path != endpoint {
t.Errorf("request %d = %s %s, want %s %s", i, got.method, got.path, wantMethod, endpoint)
if got.method != http.MethodGet || got.path != endpoint {
t.Errorf("request %d = %s %s, want GET %s", i, got.method, got.path, endpoint)
}
}
if requests[0].body != requests[2].body {
t.Errorf("create request bodies differ:\nshortcut: %s\nlegacy: %s", requests[0].body, requests[2].body)
}
}

func TestPostgresCreateWaitSucceedsForHealthyTerminalStatesOnBothPaths(t *testing.T) {
Expand All @@ -134,8 +102,8 @@ func TestPostgresCreateWaitSucceedsForHealthyTerminalStatesOnBothPaths(t *testin
path []string
state string
}{
{name: "shortcut ready", path: []string{"postgres"}, state: "Ready"},
{name: "legacy running", path: []string{"addon", "postgres"}, state: "Running"},
{name: "ready", path: []string{"create", "postgres-addon"}, state: "Ready"},
{name: "running", path: []string{"create", "postgres-addon"}, state: "Running"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -147,7 +115,7 @@ func TestPostgresCreateWaitSucceedsForHealthyTerminalStatesOnBothPaths(t *testin
defer server.Close()
configurePostgresCLI(t, server.URL)

args := append(append([]string{}, tt.path...), "create", "demo", "--wait", "--timeout", "1s", "-o", "json")
args := append(append([]string{}, tt.path...), "demo", "--wait", "--timeout", "1s", "-o", "json")
stdout, stderr, code := runPostgresCLI(args)
if code != 0 {
t.Fatalf("exit = %d, stderr = %s", code, stderr)
Expand Down Expand Up @@ -179,7 +147,7 @@ func TestPostgresCreateWaitFailsForTerminalFailureStates(t *testing.T) {
defer server.Close()
configurePostgresCLI(t, server.URL)

stdout, stderr, code := runPostgresCLI([]string{"postgres", "create", "demo", "--wait", "--timeout", "1s", "-o", "json"})
stdout, stderr, code := runPostgresCLI([]string{"create", "postgres-addon", "demo", "--wait", "--timeout", "1s", "-o", "json"})
if code == 0 {
t.Fatal("exit = 0, want failure")
}
Expand All @@ -201,7 +169,7 @@ func TestPostgresCreateWaitTimeoutIsBounded(t *testing.T) {
configurePostgresCLI(t, server.URL)

started := time.Now()
stdout, stderr, code := runPostgresCLI([]string{"postgres", "create", "demo", "--wait", "--timeout", "20ms", "-o", "json"})
stdout, stderr, code := runPostgresCLI([]string{"create", "postgres-addon", "demo", "--wait", "--timeout", "20ms", "-o", "json"})
if code == 0 {
t.Fatal("exit = 0, want timeout")
}
Expand Down
65 changes: 65 additions & 0 deletions cmd/stackdome/apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package main

import (
"fmt"

"github.com/Stackdome/stackdome-cli/internal/cmdutil"
"github.com/Stackdome/stackdome-cli/internal/stackfile"
openapi "github.com/Stackdome/stackdome/pkg/api/openapi"
"github.com/spf13/cobra"
)

type applyOptions struct {
File string
Name string
}

func newApplyCmd() *cobra.Command {
var opts applyOptions

cmd := &cobra.Command{
Use: "apply",
Short: "Save a stack definition without releasing it",
Long: `Create or update the saved stack definition from a Stackfile or stack JSON.

Apply does not create a release or change the running workload. Use
` + "`stackdome create release`" + ` to release the saved definition, or use
` + "`stackdome deploy`" + ` to apply and release in one command.`,
Example: " stackdome apply -f stackfile.yaml\n stackdome apply -f stack.json --name demo -o json",
Args: cobra.NoArgs,
RunE: cmdutil.WithContext(cmdutil.RequireAuth(func(ctx *cmdutil.CommandContext, cmd *cobra.Command, _ []string) error {
result, err := applyStackDefinition(ctx, cmd, opts)
if err != nil {
return err
}
if !ctx.Formatter.IsTable() {
return ctx.Formatter.PrintStructured(result)
}
fmt.Fprintf(cmd.ErrOrStderr(), "Stack %q saved. No release was created.\n", result.Name)
fmt.Fprintf(cmd.ErrOrStderr(), "Release it with: stackdome create release --stack %s\n", result.Name)
return nil
})),
}

cmd.Flags().StringVarP(&opts.File, "file", "f", "stackfile.yaml", "Path to stackfile or stack JSON")
cmd.Flags().StringVar(&opts.Name, "name", "", "Override stack name")
return cmd
}

func applyStackDefinition(ctx *cmdutil.CommandContext, cmd *cobra.Command, opts applyOptions) (*openapi.Stack, error) {
stack, err := loadStack(opts.File, opts.Name)
if err != nil {
return nil, err
}
if err := stackfile.ResolveStack(cmd.Context(), stack, &apiResolver{c: ctx.Client}); err != nil {
return nil, err
}
result, err := ctx.Client.ApplyStack(cmd.Context(), *stack)
if err != nil {
return nil, err
}
if err := ctx.Config.SetCurrentStack(result.GetId()); err != nil {
return nil, err
}
return result, nil
}
130 changes: 130 additions & 0 deletions cmd/stackdome/apply_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package main

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"

"github.com/Stackdome/stackdome-cli/internal/config"
)

func TestApplySavesStackWithoutCreatingRelease(t *testing.T) {
var calls []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPut && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/apply" {
_, _ = w.Write([]byte(`{"id":"stack-1","name":"basic-stack","spec":{}}`))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()

configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv("STACKDOME_CONFIG", configPath)
cfg := &config.Config{
ServerURL: ts.URL,
AccessToken: "sdm_test",
OrganizationID: "org-1",
ProjectName: "proj-1",
}
if err := cfg.Save(); err != nil {
t.Fatalf("save config: %v", err)
}

stackfilePath, err := filepath.Abs(filepath.Join("..", "..", "internal", "stackfile", "testdata", "basic_image.yaml"))
if err != nil {
t.Fatalf("absolute stackfile path: %v", err)
}
var stdout, stderr bytes.Buffer
code := runWithWriters([]string{"apply", "--file", stackfilePath, "--output", "json"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("apply exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if len(calls) != 1 || calls[0] != "PUT /api/v1/organizations/org-1/projects/proj-1/stacks/apply" {
t.Fatalf("requests = %v, want one stack apply and no release", calls)
}
var got struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not a stack JSON object: %v\nstdout: %s", err, stdout.String())
}
if got.ID != "stack-1" || got.Name != "basic-stack" {
t.Errorf("apply result = %#v", got)
}

reloaded, err := config.Load()
if err != nil {
t.Fatalf("reload config: %v", err)
}
if reloaded.CurrentStack != "stack-1" {
t.Errorf("current stack = %q, want stack-1", reloaded.CurrentStack)
}
}

func TestCreateReleaseUsesSavedStackWithoutApplying(t *testing.T) {
var calls []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks":
_, _ = w.Write([]byte(`{"items":[{"id":"stack-1","name":"demo","spec":{}}]}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"release-1","stack_id":"stack-1","sequence":1,"state":"Pending"}`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()

configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv("STACKDOME_CONFIG", configPath)
cfg := &config.Config{
ServerURL: ts.URL,
AccessToken: "sdm_test",
OrganizationID: "org-1",
ProjectName: "proj-1",
}
if err := cfg.Save(); err != nil {
t.Fatalf("save config: %v", err)
}

var stdout, stderr bytes.Buffer
code := runWithWriters([]string{"create", "release", "--stack", "demo", "--output", "json"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("create release exit code = %d, want 0; stderr: %s", code, stderr.String())
}
wantCalls := []string{
"GET /api/v1/organizations/org-1/projects/proj-1/stacks",
"POST /api/v1/organizations/org-1/projects/proj-1/stacks/stack-1/releases",
}
if len(calls) != len(wantCalls) {
t.Fatalf("requests = %v, want %v", calls, wantCalls)
}
for i := range wantCalls {
if calls[i] != wantCalls[i] {
t.Errorf("request %d = %q, want %q", i, calls[i], wantCalls[i])
}
}
var got struct {
Release struct {
ID string `json:"id"`
} `json:"release"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not release JSON: %v\nstdout: %s", err, stdout.String())
}
if got.Release.ID != "release-1" {
t.Errorf("release ID = %q, want release-1", got.Release.ID)
}
}
2 changes: 1 addition & 1 deletion cmd/stackdome/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ func TestBuildLogsUnavailableForPendingBuildDoesNotClaimPruning(t *testing.T) {
// runtime logs; prose from the callback would corrupt the document.
func TestBuildLogsJSONServerErrorIsSingleRootDocument(t *testing.T) {
if os.Getenv("STACKDOME_TEST_BUILD_LOG_ERROR_HELPER") == "1" {
os.Exit(runWithWriters([]string{"build", "logs", "build-1", "--stack", "app", "-o", "json"}, os.Stdout, os.Stderr))
os.Exit(runWithWriters([]string{"logs", "build", "build-1", "--stack", "app", "-o", "json"}, os.Stdout, os.Stderr))
}

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading