From aba65d85f6c3aec34913d48d415f44bf36c59e0a Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:30:56 +0200 Subject: [PATCH 01/25] feat(go): run --wait cancels on interrupt, --timeout, --report, --show-steps Interrupting `run --wait` (Ctrl-C, or a CI runner's SIGTERM) now cancels the run it started before exiting with 130 or 143, so an aborted pipeline no longer leaves an attack running; --keep-running-on-interrupt keeps the previous behaviour. --timeout cancels a run that takes too long. --report writes JUnit (or JSON) with a test case per step, and a Markdown summary goes to $GITHUB_STEP_SUMMARY in GitHub Actions. --- cmd/steadybit/main.go | 2 + internal/cli/experiment.go | 5 + internal/experiment/experiment.go | 117 +++++++++-- internal/experiment/experiment_test.go | 163 +++++++++++++++ internal/experiment/report.go | 267 +++++++++++++++++++++++++ internal/interrupt/interrupt.go | 75 +++++++ internal/interrupt/interrupt_test.go | 25 +++ internal/prompt/prompt.go | 31 +-- 8 files changed, 643 insertions(+), 42 deletions(-) create mode 100644 internal/experiment/report.go create mode 100644 internal/interrupt/interrupt.go create mode 100644 internal/interrupt/interrupt_test.go diff --git a/cmd/steadybit/main.go b/cmd/steadybit/main.go index 89189d8..5c94141 100644 --- a/cmd/steadybit/main.go +++ b/cmd/steadybit/main.go @@ -7,6 +7,8 @@ import ( "os" "github.com/steadybit/cli/internal/cli" + // Imported for its signal handling, which every command needs from the start. + _ "github.com/steadybit/cli/internal/interrupt" ) func main() { diff --git a/internal/cli/experiment.go b/internal/cli/experiment.go index 2e1820d..0a698c3 100644 --- a/internal/cli/experiment.go +++ b/internal/cli/experiment.go @@ -70,6 +70,7 @@ func newExperimentRun() *cobra.Command { Example: examples( "steadybit experiment run -k ADM-1", "steadybit experiment run -f experiment.yml --no-wait", + "steadybit experiment run -f ./experiments -R --yes --timeout 30m --report steadybit.xml", "steadybit experiment run --template d7e65100-1d20-4980-be87-c351704910b8 --team ADM -p CLUSTER=prod", ), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { @@ -86,6 +87,10 @@ func newExperimentRun() *cobra.Command { f.BoolVar(&o.AllowParallel, "allowParallel", false, "Skip the prompt warning about another experiment running and allow always parallel execution.") f.IntVar(&o.Retries, "retries", 0, "Number of retries when the experiment fails validation (e.g., missing targets). 0 means no retry.") f.IntVar(&o.RetryInterval, "retryInterval", 10, "Interval in seconds between retries.") + f.DurationVar(&o.Timeout, "timeout", 0, `With waiting: cancel the run and fail when it has not ended after this long, e.g. "15m".`) + f.BoolVar(&o.KeepRunningOnInterrupt, "keep-running-on-interrupt", false, "With waiting: leave the run going when the CLI is interrupted, instead of cancelling it.") + f.BoolVar(&o.ShowSteps, "show-steps", false, "With waiting: print each step's state as it changes.") + f.StringVar(&o.Report, "report", "", `With waiting: write a JUnit report of the runs to this file, or JSON if it ends in ".json".`) f.Var(executionVariables, "execution-variable", "With --template: a variable for this run only, overriding experiment and environment variables. Repeat for more.") addTemplateFlags(cmd, &o.TemplateOptions) cmd.MarkFlagsMutuallyExclusive("key", "file") diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index 00d15d9..0b1c5dd 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -24,6 +24,7 @@ import ( "time" "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/interrupt" "github.com/steadybit/cli/internal/output" "github.com/steadybit/cli/internal/platform" "github.com/steadybit/cli/internal/prompt" @@ -245,6 +246,9 @@ type RunOptions struct { AllowParallel bool Retries int RetryInterval int + WaitOptions + // Report is a file to write a JUnit (or, for .json, JSON) report of the runs to. + Report string TemplateOptions } @@ -288,21 +292,42 @@ func Run(ctx context.Context, c *platform.Client, o RunOptions) error { return errors.New("Either --key, --file or --template must be specified.") } + if o.Report != "" && !o.Wait { + return errors.New("--report needs to wait for the runs to end; remove --no-wait.") + } + o.WaitOptions.Steps = o.Report != "" || os.Getenv("GITHUB_STEP_SUMMARY") != "" + + var finished []*RunResult + // The report and summary cover the runs up to and including the first one that + // failed, which ends the command. + report := func() error { + if o.Report != "" { + if err := WriteReport(o.Report, finished); err != nil { + return fmt.Errorf("Failed to write the report to %s: %w", o.Report, err) + } + } + return WriteGitHubSummary(finished) + } for _, run := range runs { result, err := withRetries(o, run) if err != nil { - return err + return errors.Join(err, report()) } fmt.Println("Executing experiment:", result.Key) fmt.Println("Experiment run API:", result.APILocation) fmt.Println("Experiment run UI:", result.UILocation) if o.Wait && result.APILocation != "" { - if err := wait(ctx, c, result.APILocation); err != nil { - return err + done, err := wait(ctx, c, result.APILocation, o.WaitOptions) + if done != nil { + done.UILocation = result.UILocation + finished = append(finished, done) + } + if err != nil { + return errors.Join(err, report()) } } } - return nil + return report() } // withRetries retries validation errors, which clear up once targets appear, and offers @@ -427,30 +452,86 @@ var PollInterval = 5 * time.Second var terminal = map[string]bool{"FAILED": true, "ERRORED": true, "CANCELED": true, "COMPLETED": true} -// wait polls the run until it ends. A run that did not complete exits non-zero, which is -// what lets a pipeline fail on it. -func wait(ctx context.Context, c *platform.Client, location string) error { +// WaitOptions shape what `run --wait` does besides waiting. +type WaitOptions struct { + // Timeout cancels the run once it has taken this long; zero waits indefinitely. + Timeout time.Duration + // KeepRunningOnInterrupt leaves the run going when the CLI is interrupted, as the + // TypeScript CLI did. By default it is cancelled: an aborted pipeline should not + // leave an attack running on its own. + KeepRunningOnInterrupt bool + // ShowSteps prints each step's state as it changes. + ShowSteps bool + // Steps asks the platform for the steps of the run, which reports need. + Steps bool +} + +// ErrTimedOut is returned when --timeout cancelled the run. +var ErrTimedOut = errors.New("timed out") + +// wait polls the run until it ends. A run that did not complete is an error, which is +// what lets a pipeline fail on it. The finished run is returned for reports. +func wait(ctx context.Context, c *platform.Client, location string, o WaitOptions) (*RunResult, error) { path := location if i := strings.Index(location, "/api/"); i >= 0 { path = location[i:] } + if o.Steps || o.ShowSteps { + separator := "?" + if strings.Contains(path, "?") { + separator = "&" + } + path += separator + "fields=steps" + } + + var runID int64 + cancel := func(why string) { + if runID == 0 { + return + } + fmt.Fprintf(os.Stderr, "%s, canceling experiment run %d.\n", why, runID) + cancelCtx, done := context.WithTimeout(context.Background(), 30*time.Second) + defer done() + if _, _, err := platform.Read(c.CancelExperimentExecution(cancelCtx, runID)); err != nil { + fmt.Fprintf(os.Stderr, "Failed to cancel experiment run %d: %s\n", runID, err) + } + } + if !o.KeepRunningOnInterrupt { + pop := interrupt.Push(func(os.Signal) { cancel("Interrupted") }) + defer pop() + } + + var deadline time.Time + if o.Timeout > 0 { + deadline = time.Now().Add(o.Timeout) + } + shown := map[string]string{} for { time.Sleep(PollInterval) body, _, err := platform.Read(c.Get(ctx, path)) if err != nil { - return platform.Failed(err, "Failed to get experiment run ") + return nil, platform.Failed(err, "Failed to get experiment run ") } - var run struct { - ID int64 `json:"id"` - Key string `json:"key"` - State string `json:"state"` - Reason string `json:"reason"` - } - if err := json.Unmarshal(body, &run); err != nil { - return err + run, err := parseRun(body) + if err != nil { + return nil, err } + runID = run.ID fmt.Println("Current run state:", strings.ToLower(run.State)) + if o.ShowSteps { + for i, step := range run.Steps { + id := fmt.Sprint(i) + if shown[id] != step.State { + shown[id] = step.State + fmt.Printf(" step %d/%d %s: %s\n", i+1, len(run.Steps), step.Name, strings.ToLower(step.State)) + } + } + } if !terminal[run.State] { + if !deadline.IsZero() && time.Now().After(deadline) { + cancel(fmt.Sprintf("Experiment run %d did not end within %s", run.ID, o.Timeout)) + return run, fmt.Errorf("Experiment %s (#%d) did not end within %s and was canceled: %w", run.Key, run.ID, o.Timeout, ErrTimedOut) + } continue } if run.State != "COMPLETED" { @@ -458,8 +539,8 @@ func wait(ctx context.Context, c *platform.Client, location string) error { if run.Reason != "" { reason = ", reason: " + run.Reason } - return fmt.Errorf("Experiment %s (#%d) %s%s", run.Key, run.ID, strings.ToLower(run.State), reason) + return run, fmt.Errorf("Experiment %s (#%d) %s%s", run.Key, run.ID, strings.ToLower(run.State), reason) } - return nil + return run, nil } } diff --git a/internal/experiment/experiment_test.go b/internal/experiment/experiment_test.go index b3cd232..36c5b24 100644 --- a/internal/experiment/experiment_test.go +++ b/internal/experiment/experiment_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/steadybit/cli/internal/experiment" + "github.com/steadybit/cli/internal/interrupt" "github.com/steadybit/cli/internal/jsyaml" "github.com/steadybit/cli/internal/platform" "github.com/steadybit/cli/internal/platformtest" @@ -306,3 +307,165 @@ func TestDumpRefusesAnUnknownTeam(t *testing.T) { assert.EqualError(t, err, "No accessible team with key NOPE. Available: A, B") } + +func finished(state, reason string) platformtest.Reply { + return platformtest.Reply{JSON: map[string]any{ + "id": 1, "key": "TST-1", "name": "Verify TTR", "state": state, "reason": reason, + "started": "2026-09-25T10:00:00Z", "ended": "2026-09-25T10:00:42Z", + "steps": []any{ + map[string]any{"stepType": "wait", "state": "COMPLETED", "parameters": map[string]any{"duration": "10s"}, "started": "2026-09-25T10:00:00Z", "ended": "2026-09-25T10:00:10Z"}, + map[string]any{"stepType": "action", "actionId": "com.steadybit.extension_http.check", "state": state, "reason": reason, "started": "2026-09-25T10:00:10Z", "ended": "2026-09-25T10:00:42Z"}, + }, + }} +} + +func TestRunWritesAJUnitReport(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/TST-1/execute", started(p, "TST-1", 1)) + p.Reply("GET /api/experiments/executions/1", finished("FAILED", "HTTP check failed")) + report := filepath.Join(t.TempDir(), "report.xml") + + _, err := platformtest.Stdout(t, func() error { + return experiment.Run(ctx, p.Client, experiment.RunOptions{Key: "TST-1", Yes: true, Wait: true, Report: report}) + }) + + assert.EqualError(t, err, "Experiment TST-1 (#1) failed, reason: HTTP check failed") + content, _ := os.ReadFile(report) + assert.Equal(t, ` + + + + + + + + + HTTP check failed + + + +`, string(content)) + // Only a report asks the platform for the steps. + assert.Equal(t, []string{"steps"}, p.Requests("GET /api/experiments/executions/1")[0].Query["fields"]) +} + +func TestRunWritesAJSONReportAndAGitHubSummary(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/TST-1/execute", started(p, "TST-1", 1)) + p.Reply("GET /api/experiments/executions/1", finished("COMPLETED", "")) + dir := t.TempDir() + summary := filepath.Join(dir, "summary.md") + t.Setenv("GITHUB_STEP_SUMMARY", summary) + + _, err := platformtest.Stdout(t, func() error { + return experiment.Run(ctx, p.Client, experiment.RunOptions{Key: "TST-1", Yes: true, Wait: true, Report: filepath.Join(dir, "r.json")}) + }) + + require.NoError(t, err) + report, _ := os.ReadFile(filepath.Join(dir, "r.json")) + assert.Contains(t, string(report), `"state": "COMPLETED"`) + content, _ := os.ReadFile(summary) + assert.Equal(t, "### ✅ Steadybit experiment TST-1 · Verify TTR\n\nRun [#1](https://ui/TST-1) completed after 42s\n\n| # | Step | State | Duration |\n|---|---|---|---|\n| 1 | wait 10s | completed | 10s |\n| 2 | com.steadybit.extension_http.check | completed | 32s |\n\n", string(content)) +} + +func TestWaitingWithoutAReportDoesNotAskForSteps(t *testing.T) { + p := platformtest.New(t) + t.Setenv("GITHUB_STEP_SUMMARY", "") + p.Reply("POST /api/experiments/TST-1/execute", started(p, "TST-1", 1)) + p.Reply("GET /api/experiments/executions/1", finished("COMPLETED", "")) + + _, err := platformtest.Stdout(t, func() error { + return experiment.Run(ctx, p.Client, experiment.RunOptions{Key: "TST-1", Yes: true, Wait: true}) + }) + + require.NoError(t, err) + assert.Nil(t, p.Requests("GET /api/experiments/executions/1")[0].Query["fields"]) +} + +func TestATimeoutCancelsTheRun(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/TST-1/execute", started(p, "TST-1", 1)) + p.Reply("GET /api/experiments/executions/1", platformtest.Reply{JSON: map[string]any{"id": 1, "key": "TST-1", "state": "RUNNING"}}) + p.Reply("POST /api/experiments/executions/1/cancel", platformtest.Reply{Status: http.StatusAccepted}) + + _, err := platformtest.Stdout(t, func() error { + return experiment.Run(ctx, p.Client, experiment.RunOptions{Key: "TST-1", Yes: true, Wait: true, WaitOptions: experiment.WaitOptions{Timeout: time.Nanosecond}}) + }) + + assert.ErrorIs(t, err, experiment.ErrTimedOut) + assert.ErrorContains(t, err, "Experiment TST-1 (#1) did not end within 1ns and was canceled") + assert.Len(t, p.Requests("POST /api/experiments/executions/1/cancel"), 1) +} + +func TestShowStepsPrintsEachChange(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/TST-1/execute", started(p, "TST-1", 1)) + p.Reply("GET /api/experiments/executions/1", finished("COMPLETED", "")) + + out, err := platformtest.Stdout(t, func() error { + return experiment.Run(ctx, p.Client, experiment.RunOptions{Key: "TST-1", Yes: true, Wait: true, WaitOptions: experiment.WaitOptions{ShowSteps: true}}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Current run state: completed\n step 1/2 wait 10s: completed\n step 2/2 com.steadybit.extension_http.check: completed\n") +} + +func TestAReportNeedsWaiting(t *testing.T) { + err := experiment.Run(ctx, nil, experiment.RunOptions{Key: "TST-1", Yes: true, Report: "r.xml"}) + + assert.EqualError(t, err, "--report needs to wait for the runs to end; remove --no-wait.") +} + +// An aborted pipeline must not leave the attack it started running on its own. +func TestAnInterruptCancelsTheRunItWaitsFor(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/TST-1/execute", started(p, "TST-1", 1)) + polled := make(chan struct{}, 100) + var canceled atomic.Bool + p.Handle("GET /api/experiments/executions/1", func(platformtest.Request) platformtest.Reply { + polled <- struct{}{} + if canceled.Load() { + return platformtest.Reply{JSON: map[string]any{"id": 1, "key": "TST-1", "state": "CANCELED"}} + } + return platformtest.Reply{JSON: map[string]any{"id": 1, "key": "TST-1", "state": "RUNNING"}} + }) + p.Handle("POST /api/experiments/executions/1/cancel", func(platformtest.Request) platformtest.Reply { + canceled.Store(true) + return platformtest.Reply{Status: http.StatusAccepted} + }) + go func() { + <-polled + <-polled // the run id is known once the second poll has been answered + interrupt.RunHandlers(os.Interrupt) + }() + + _, err := platformtest.Stdout(t, func() error { + return experiment.Run(ctx, p.Client, experiment.RunOptions{Key: "TST-1", Yes: true, Wait: true}) + }) + + assert.EqualError(t, err, "Experiment TST-1 (#1) canceled") + assert.Len(t, p.Requests("POST /api/experiments/executions/1/cancel"), 1) +} + +func TestKeepRunningOnInterruptLeavesTheRunAlone(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/TST-1/execute", started(p, "TST-1", 1)) + var polls atomic.Int32 + p.Handle("GET /api/experiments/executions/1", func(platformtest.Request) platformtest.Reply { + if polls.Add(1) == 2 { + interrupt.RunHandlers(os.Interrupt) + } + state := "RUNNING" + if polls.Load() >= 3 { + state = "COMPLETED" + } + return platformtest.Reply{JSON: map[string]any{"id": 1, "key": "TST-1", "state": state}} + }) + + _, err := platformtest.Stdout(t, func() error { + return experiment.Run(ctx, p.Client, experiment.RunOptions{Key: "TST-1", Yes: true, Wait: true, WaitOptions: experiment.WaitOptions{KeepRunningOnInterrupt: true}}) + }) + + require.NoError(t, err) + assert.Empty(t, p.Requests("POST /api/experiments/executions/1/cancel")) +} diff --git a/internal/experiment/report.go b/internal/experiment/report.go new file mode 100644 index 0000000..c437860 --- /dev/null +++ b/internal/experiment/report.go @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package experiment + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "os" + "strings" + "time" +) + +// RunResult is a finished (or abandoned) experiment run, as reports describe it. +type RunResult struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` + Started time.Time `json:"started"` + Ended time.Time `json:"ended"` + UILocation string `json:"uiLocation,omitempty"` + Steps []Step `json:"steps"` +} + +type Step struct { + Name string `json:"name"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` + Started time.Time `json:"started"` + Ended time.Time `json:"ended"` +} + +func (r RunResult) Duration() time.Duration { return duration(r.Started, r.Ended) } +func (s Step) Duration() time.Duration { return duration(s.Started, s.Ended) } + +func duration(from, to time.Time) time.Duration { + if from.IsZero() || to.IsZero() || to.Before(from) { + return 0 + } + return to.Sub(from) +} + +func parseRun(body []byte) (*RunResult, error) { + var raw struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + State string `json:"state"` + Reason string `json:"reason"` + Started time.Time `json:"started"` + Ended time.Time `json:"ended"` + Steps []struct { + StepType string `json:"stepType"` + ActionID string `json:"actionId"` + CustomLabel string `json:"customLabel"` + State string `json:"state"` + Reason string `json:"reason"` + Started time.Time `json:"started"` + Ended time.Time `json:"ended"` + Parameters map[string]any `json:"parameters"` + } `json:"steps"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + run := &RunResult{ID: raw.ID, Key: raw.Key, Name: raw.Name, State: raw.State, Reason: raw.Reason, Started: raw.Started, Ended: raw.Ended} + for _, s := range raw.Steps { + name := s.CustomLabel + switch { + case name != "": + case s.ActionID != "": + name = s.ActionID + case s.StepType == "wait": + name = fmt.Sprintf("wait %v", s.Parameters["duration"]) + default: + name = s.StepType + } + run.Steps = append(run.Steps, Step{Name: name, State: s.State, Reason: s.Reason, Started: s.Started, Ended: s.Ended}) + } + return run, nil +} + +// WriteReport writes the runs as JUnit XML, which CI systems show as test results, or +// as JSON, chosen by the file's extension. +func WriteReport(file string, runs []*RunResult) error { + var content []byte + var err error + if strings.HasSuffix(strings.ToLower(file), ".json") { + content, err = json.MarshalIndent(runs, "", " ") + } else { + content, err = junit(runs) + } + if err != nil { + return err + } + return os.WriteFile(file, append(content, '\n'), 0o644) +} + +type junitSuites struct { + XMLName xml.Name `xml:"testsuites"` + Name string `xml:"name,attr"` + Tests int `xml:"tests,attr"` + Failures int `xml:"failures,attr"` + Errors int `xml:"errors,attr"` + Time string `xml:"time,attr"` + Suites []junitSuite `xml:"testsuite"` +} + +type junitSuite struct { + Name string `xml:"name,attr"` + Tests int `xml:"tests,attr"` + Failures int `xml:"failures,attr"` + Errors int `xml:"errors,attr"` + Skipped int `xml:"skipped,attr"` + Time string `xml:"time,attr"` + Timestamp string `xml:"timestamp,attr,omitempty"` + Properties []junitProperty `xml:"properties>property,omitempty"` + Cases []junitCase `xml:"testcase"` +} + +type junitProperty struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +} + +type junitCase struct { + ClassName string `xml:"classname,attr"` + Name string `xml:"name,attr"` + Time string `xml:"time,attr"` + Failure *junitProblem `xml:"failure,omitempty"` + Error *junitProblem `xml:"error,omitempty"` + Skipped *struct{} `xml:"skipped,omitempty"` +} + +type junitProblem struct { + Message string `xml:"message,attr"` + Type string `xml:"type,attr"` + Text string `xml:",chardata"` +} + +func seconds(d time.Duration) string { return fmt.Sprintf("%.3f", d.Seconds()) } + +// A failed step is a failure (the hypothesis did not hold), an errored one an error +// (the run could not do what it should); steps that never ran are skipped. +func junitCaseFor(className, name, state, reason string, d time.Duration) junitCase { + c := junitCase{ClassName: className, Name: name, Time: seconds(d)} + message := strings.ToLower(state) + if reason != "" { + message += ": " + reason + } + switch state { + case "FAILED": + c.Failure = &junitProblem{Message: message, Type: state, Text: reason} + case "ERRORED": + c.Error = &junitProblem{Message: message, Type: state, Text: reason} + case "CANCELED", "SKIPPED", "CREATED", "PREPARED", "": + c.Skipped = &struct{}{} + } + return c +} + +func junit(runs []*RunResult) ([]byte, error) { + suites := junitSuites{Name: "steadybit"} + var total time.Duration + for _, run := range runs { + suite := junitSuite{Name: strings.TrimSpace(run.Key + " " + run.Name), Time: seconds(run.Duration())} + if !run.Started.IsZero() { + suite.Timestamp = run.Started.UTC().Format(time.RFC3339) + } + suite.Properties = append(suite.Properties, junitProperty{Name: "executionId", Value: fmt.Sprint(run.ID)}) + if run.UILocation != "" { + suite.Properties = append(suite.Properties, junitProperty{Name: "uiLocation", Value: run.UILocation}) + } + // A run that has no steps to report still has to show up, as one case. + if len(run.Steps) == 0 { + suite.Cases = append(suite.Cases, junitCaseFor(run.Key, run.Key, run.State, run.Reason, run.Duration())) + } + for i, step := range run.Steps { + suite.Cases = append(suite.Cases, junitCaseFor(run.Key, fmt.Sprintf("%d. %s", i+1, step.Name), step.State, step.Reason, step.Duration())) + } + for _, c := range suite.Cases { + suite.Tests++ + switch { + case c.Failure != nil: + suite.Failures++ + case c.Error != nil: + suite.Errors++ + case c.Skipped != nil: + suite.Skipped++ + } + } + // A run that ended badly without any step to blame, a canceled or timed-out run + // for instance, still fails its suite. + if run.State != "COMPLETED" && suite.Failures == 0 && suite.Errors == 0 { + suite.Cases = append(suite.Cases, junitCaseFor(run.Key, "run", run.State, run.Reason, run.Duration())) + suite.Tests++ + if run.State == "FAILED" { + suite.Failures++ + } else { + suite.Errors++ + } + } + suites.Tests += suite.Tests + suites.Failures += suite.Failures + suites.Errors += suite.Errors + total += run.Duration() + suites.Suites = append(suites.Suites, suite) + } + suites.Time = seconds(total) + out, err := xml.MarshalIndent(suites, "", " ") + return append([]byte(xml.Header), out...), err +} + +// WriteGitHubSummary appends a Markdown summary of the runs to the job summary when the +// CLI runs in GitHub Actions, which names the file in GITHUB_STEP_SUMMARY. +func WriteGitHubSummary(runs []*RunResult) error { + file := os.Getenv("GITHUB_STEP_SUMMARY") + if file == "" || len(runs) == 0 { + return nil + } + var b strings.Builder + for _, run := range runs { + icon := "✅" + if run.State != "COMPLETED" { + icon = "❌" + } + title := run.Key + if run.Name != "" { + title += " · " + run.Name + } + fmt.Fprintf(&b, "### %s Steadybit experiment %s\n\n", icon, title) + link := fmt.Sprintf("#%d", run.ID) + if run.UILocation != "" { + link = fmt.Sprintf("[#%d](%s)", run.ID, run.UILocation) + } + fmt.Fprintf(&b, "Run %s %s after %s", link, strings.ToLower(run.State), run.Duration().Round(time.Second)) + if run.Reason != "" { + fmt.Fprintf(&b, ": %s", run.Reason) + } + b.WriteString("\n\n") + if len(run.Steps) > 0 { + b.WriteString("| # | Step | State | Duration |\n|---|---|---|---|\n") + for i, step := range run.Steps { + state := strings.ToLower(step.State) + if step.Reason != "" { + state += ": " + step.Reason + } + fmt.Fprintf(&b, "| %d | %s | %s | %s |\n", i+1, escapeTable(step.Name), escapeTable(state), step.Duration().Round(time.Second)) + } + b.WriteString("\n") + } + } + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(b.String()) + return err +} + +func escapeTable(s string) string { + return strings.NewReplacer("|", `\|`, "\n", " ").Replace(s) +} diff --git a/internal/interrupt/interrupt.go b/internal/interrupt/interrupt.go new file mode 100644 index 0000000..23a6538 --- /dev/null +++ b/internal/interrupt/interrupt.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package interrupt decides what Ctrl-C and SIGTERM do. By default they end the CLI with +// 130 or 143 and no stack trace. Code that must clean up first, such as `run --wait` +// cancelling the experiment it started, pushes a handler for as long as it runs. +package interrupt + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" +) + +var ( + mu sync.Mutex + handlers []*func(os.Signal) +) + +func init() { + signals := make(chan os.Signal, 2) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + go func() { + first := <-signals + // A second signal while cleaning up ends the CLI at once: someone pressing + // Ctrl-C twice wants out, whatever the cleanup was doing. + go func() { + <-signals + os.Exit(code(first)) + }() + RunHandlers(first) + fmt.Fprintln(os.Stderr) + os.Exit(code(first)) + }() +} + +// RunHandlers runs what is registered for sig, most recent first, without exiting. The +// signal handler calls it before exiting; tests call it to stand in for Ctrl-C. +func RunHandlers(sig os.Signal) { + mu.Lock() + pending := make([]*func(os.Signal), len(handlers)) + copy(pending, handlers) + mu.Unlock() + for i := len(pending) - 1; i >= 0; i-- { + (*pending[i])(sig) + } +} + +func code(s os.Signal) int { + if s == syscall.SIGTERM { + return 143 + } + return 130 +} + +// Push runs fn when the CLI is interrupted, before it exits, until the returned pop is +// called. Handlers run most recent first. +func Push(fn func(os.Signal)) (pop func()) { + mu.Lock() + defer mu.Unlock() + h := &fn + handlers = append(handlers, h) + return func() { + mu.Lock() + defer mu.Unlock() + for i, existing := range handlers { + if existing == h { + handlers = append(handlers[:i], handlers[i+1:]...) + return + } + } + } +} diff --git a/internal/interrupt/interrupt_test.go b/internal/interrupt/interrupt_test.go new file mode 100644 index 0000000..2281ff2 --- /dev/null +++ b/internal/interrupt/interrupt_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package interrupt + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRunsHandlersMostRecentFirstUntilPopped(t *testing.T) { + var calls []string + popFirst := Push(func(os.Signal) { calls = append(calls, "first") }) + popSecond := Push(func(os.Signal) { calls = append(calls, "second") }) + + RunHandlers(os.Interrupt) + popSecond() + RunHandlers(os.Interrupt) + popFirst() + RunHandlers(os.Interrupt) + + assert.Equal(t, []string{"second", "first", "first"}, calls) +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index a305016..da1caa9 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -9,32 +9,13 @@ import ( "bufio" "fmt" "os" - "os/signal" "strings" - "syscall" + "github.com/steadybit/cli/internal/interrupt" "golang.org/x/term" ) -var ( - reader = bufio.NewReader(os.Stdin) - // Set while a password is being read, which turns echo off. An interrupt then has - // to turn it back on, or the user is left with a terminal that shows nothing typed. - restoreTerminal func() -) - -func init() { - interrupts := make(chan os.Signal, 1) - signal.Notify(interrupts, os.Interrupt, syscall.SIGTERM) - go func() { - <-interrupts - if restoreTerminal != nil { - restoreTerminal() - } - fmt.Println() - os.Exit(130) - }() -} +var reader = bufio.NewReader(os.Stdin) type Validator func(string) error @@ -86,10 +67,12 @@ func Password(message string, validate Validator) (string, error) { if err != nil { return "", err } - restoreTerminal = func() { _ = term.Restore(fd, state) } + // Reading a password turns echo off. An interrupt meanwhile has to turn it back + // on, or the user is left with a terminal that shows nothing typed. + pop := interrupt.Push(func(os.Signal) { _ = term.Restore(fd, state) }) bytes, err := term.ReadPassword(fd) - restoreTerminal() - restoreTerminal = nil + _ = term.Restore(fd, state) + pop() fmt.Println() if err != nil { return "", err From 2d69069fe4f0bdd6d69eb537c840f5655b0b717e Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:34:22 +0200 Subject: [PATCH 02/25] feat(go): -t json|yaml on every listing and a global --jq filter Listings print the platform's items, in its order, with -t json or yaml instead of a table. --jq filters whatever JSON a command prints, strings raw and values as JSON like gh --jq; jq itself is not needed. --- go.mod | 9 ++- go.sum | 14 +++- internal/cli/execution.go | 7 +- internal/cli/root.go | 1 + internal/cli/schedule.go | 4 +- internal/cli/service.go | 6 +- internal/cli/template.go | 2 + internal/execution/execution.go | 16 +++- internal/experiment/experiment.go | 4 + internal/experiment/experiment_test.go | 13 ++++ internal/output/jq.go | 89 +++++++++++++++++++++++ internal/platform/response.go | 6 ++ internal/resource/list.go | 69 ++++++++++++++++++ internal/resource/list_test.go | 51 +++++++++++++ internal/resource/resource.go | 4 + internal/schedule/schedule.go | 11 ++- internal/service/service.go | 22 +++++- internal/serviceprofile/serviceprofile.go | 10 ++- internal/template/template.go | 23 ++++-- 19 files changed, 337 insertions(+), 24 deletions(-) create mode 100644 internal/output/jq.go create mode 100644 internal/resource/list.go create mode 100644 internal/resource/list_test.go diff --git a/go.mod b/go.mod index e4fba85..7e91795 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,11 @@ go 1.26.2 tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen require ( + github.com/itchyny/gojq v0.12.19 + github.com/mattn/go-runewidth v0.0.30 github.com/oapi-codegen/runtime v1.7.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.12.1 go.yaml.in/yaml/v3 v3.0.5 golang.org/x/term v0.46.0 @@ -14,21 +17,21 @@ require ( require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/getkin/kin-openapi v0.142.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/swag/jsonname v0.26.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-runewidth v0.0.30 // indirect + github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect github.com/oasdiff/yaml v0.1.1 // indirect github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/speakeasy-api/jsonpath v0.6.3 // indirect github.com/speakeasy-api/openapi v1.24.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/sync v0.22.0 // indirect diff --git a/go.sum b/go.sum index 4d09c8e..36282e2 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,10 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -40,8 +42,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -49,6 +51,10 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= +github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= +github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= +github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/internal/cli/execution.go b/internal/cli/execution.go index 841a985..45309e9 100644 --- a/internal/cli/execution.go +++ b/internal/cli/execution.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/execution" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" ) func runID(cmd *cobra.Command, id *int64) { @@ -88,16 +89,18 @@ func newExecution() *cobra.Command { artifact := &cobra.Command{Use: "artifact", Short: "List and download the artifacts of an experiment run."} var listID int64 + var listType string list := &cobra.Command{ Use: "list", Short: "List the artifacts that the actions of an experiment run attached.", Args: cobra.NoArgs, - Example: examples("steadybit execution artifact list -i 1234"), + Example: examples("steadybit execution artifact list -i 1234", "steadybit execution artifact list -i 1234 --jq '.[].artifactId'"), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { - return execution.ListArtifacts(ctx, c, listID) + return execution.ListArtifacts(ctx, c, listID, listType) }), } runID(list, &listID) + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) var d execution.DownloadOptions download := &cobra.Command{ Use: "download", diff --git a/internal/cli/root.go b/internal/cli/root.go index 0812837..f570770 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -67,6 +67,7 @@ func newRoot() *cobra.Command { }, } root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") + root.PersistentFlags().StringVar(&output.JQ, "jq", "", "Filter the JSON a command prints with a jq expression; strings are printed raw.") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") root.AddCommand(newAdvice(), newConfig(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTemplate()) diff --git a/internal/cli/schedule.go b/internal/cli/schedule.go index 8682bbb..11a91cb 100644 --- a/internal/cli/schedule.go +++ b/internal/cli/schedule.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/schedule" ) @@ -51,11 +52,12 @@ func newSchedule() *cobra.Command { Use: "list", Short: "List experiment schedules.", Args: cobra.NoArgs, - Example: examples("steadybit schedule list", "steadybit schedule list --team ADM --experiment ADM-1 ADM-2"), + Example: examples("steadybit schedule list", "steadybit schedule list --team ADM --experiment ADM-1 ADM-2", "steadybit schedule list --jq '.[] | select(.enabled) | .experimentKey'"), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return schedule.List(ctx, c, l) }), } list.Flags().StringArrayVar(&l.Teams, "team", nil, "Only list schedules of these teams, by team key.") list.Flags().StringArrayVar(&l.Experiments, "experiment", nil, "Only list schedules of these experiments, by experiment key.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) variadic(list, "team", "experiment") var g schedule.GetOptions diff --git a/internal/cli/service.go b/internal/cli/service.go index 9990d77..ffdffc6 100644 --- a/internal/cli/service.go +++ b/internal/cli/service.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/service" "github.com/steadybit/cli/internal/serviceprofile" ) @@ -30,12 +31,13 @@ func newService() *cobra.Command { Use: "list", Short: "List services. Filters of the same kind match any of the given values.", Args: cobra.NoArgs, - Example: examples("steadybit service list", "steadybit service list --team ADM --environment Global"), + Example: examples("steadybit service list", "steadybit service list --team ADM --environment Global", "steadybit service list --jq '.[].id'"), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return service.List(ctx, c, l) }), } list.Flags().StringArrayVar(&l.Teams, "team", nil, "Only list services of these teams, by team key.") list.Flags().StringArrayVar(&l.Environments, "environment", nil, "Only list services in these environments.") list.Flags().StringArrayVar(&l.Experiments, "experiment", nil, "Only list services these experiments are linked to.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) variadic(list, "team", "environment", "experiment") var g service.GetOptions @@ -110,6 +112,7 @@ func newService() *cobra.Command { idFlag(elist, &el.ID, "The service id.") elist.Flags().StringArrayVar(&el.Categories, "category", nil, "Only list experiments in these categories.") elist.Flags().StringArrayVar(&el.Types, "type", nil, `Only list "provided" or "custom" experiments.`) + elist.Flags().StringVar(&el.Type, "output", "", resource.ListTypeHelp) variadic(elist, "category", "type") var p service.ProvideOptions @@ -211,6 +214,7 @@ func newServiceProfile() *cobra.Command { list.Flags().StringVar(&l.Name, "name", "", "Only list profiles whose name contains this.") list.Flags().StringArrayVar(&l.Origins, "origin", nil, `Only list "provided" or "custom" profiles.`) list.Flags().BoolVar(&l.Default, "default", false, "Only list the default profile.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) variadic(list, "origin") var g serviceprofile.GetOptions diff --git a/internal/cli/template.go b/internal/cli/template.go index fd90864..745dede 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/template" ) @@ -31,6 +32,7 @@ func newTemplate() *cobra.Command { list.Flags().StringArrayVar(&l.TargetTypes, "target-type", nil, "Only list templates targeting one of these target types.") list.Flags().StringArrayVar(&l.Actions, "action", nil, "Only list templates using one of these actions.") list.Flags().StringArrayVar(&l.Search, "search", nil, "Only list templates whose title or description match.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) variadic(list, "tag", "target-type", "action", "search") var g template.GetOptions diff --git a/internal/execution/execution.go b/internal/execution/execution.go index 682c30d..da85823 100644 --- a/internal/execution/execution.go +++ b/internal/execution/execution.go @@ -143,7 +143,10 @@ func reportProperty(err error, operation string, o PropertyOptions) error { } type Artifact struct { - Step, Target, TargetExecutionID, ArtifactID string + Step string `json:"step"` + Target string `json:"target"` + TargetExecutionID string `json:"targetExecutionId"` + ArtifactID string `json:"artifactId"` } func str(m *jsyaml.Map, key string) string { @@ -198,12 +201,21 @@ func Collect(run *jsyaml.Map) []Artifact { return artifacts } -func ListArtifacts(ctx context.Context, c *platform.Client, id int64) error { +func ListArtifacts(ctx context.Context, c *platform.Client, id int64, explicitType string) error { doc, err := Fetch(ctx, c, id) if err != nil { return err } artifacts := Collect(doc.Value()) + if resource.Machine(explicitType) { + raw := []byte("[]") + if len(artifacts) > 0 { + if raw, err = json.Marshal(artifacts); err != nil { + return err + } + } + return resource.PrintJSONValue(raw, explicitType) + } if len(artifacts) == 0 { fmt.Printf("Experiment run %d has no artifacts.\n", id) return nil diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index 0b1c5dd..ba4052f 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -25,6 +25,7 @@ import ( "github.com/steadybit/cli/api" "github.com/steadybit/cli/internal/interrupt" + "github.com/steadybit/cli/internal/jsyaml" "github.com/steadybit/cli/internal/output" "github.com/steadybit/cli/internal/platform" "github.com/steadybit/cli/internal/prompt" @@ -65,6 +66,9 @@ func Get(ctx context.Context, c *platform.Client, o GetOptions) error { if err != nil { return err } + if output.JQ != "" && o.File == "" { + return output.ApplyJQ(os.Stdout, jsyaml.CompactJSON(document.Value()), output.JQ) + } datatype, err := output.ResolveDatatype(o.Type, o.File) if err != nil { return err diff --git a/internal/experiment/experiment_test.go b/internal/experiment/experiment_test.go index 36c5b24..07b18b8 100644 --- a/internal/experiment/experiment_test.go +++ b/internal/experiment/experiment_test.go @@ -15,6 +15,7 @@ import ( "github.com/steadybit/cli/internal/experiment" "github.com/steadybit/cli/internal/interrupt" "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" "github.com/steadybit/cli/internal/platform" "github.com/steadybit/cli/internal/platformtest" "github.com/stretchr/testify/assert" @@ -469,3 +470,15 @@ func TestKeepRunningOnInterruptLeavesTheRunAlone(t *testing.T) { require.NoError(t, err) assert.Empty(t, p.Requests("POST /api/experiments/executions/1/cancel")) } + +func TestJQFiltersWhatGetPrints(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/experiments/TST-1", platformtest.Reply{Body: design}) + output.JQ = ".lanes[0].steps[0].parameters.duration" + t.Cleanup(func() { output.JQ = "" }) + + out, err := platformtest.Stdout(t, func() error { return experiment.Get(ctx, p.Client, experiment.GetOptions{Key: "TST-1"}) }) + + require.NoError(t, err) + assert.Equal(t, "10s\n", out) +} diff --git a/internal/output/jq.go b/internal/output/jq.go new file mode 100644 index 0000000..a0f0e37 --- /dev/null +++ b/internal/output/jq.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package output + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/itchyny/gojq" +) + +// JQ is the --jq expression, applied to whatever JSON a command prints. Set once from the +// root command's flags. +var JQ string + +// ApplyJQ filters JSON through the --jq expression and writes each result on its own +// line: strings raw, everything else as indented JSON, as `gh --jq` does. jq itself is +// not needed, the expression runs in-process. +func ApplyJQ(w io.Writer, jsonText string, expression string) error { + query, err := gojq.Parse(expression) + if err != nil { + return fmt.Errorf("Invalid --jq expression: %w", err) + } + code, err := gojq.Compile(query, gojq.WithEnvironLoader(func() []string { return nil })) + if err != nil { + return fmt.Errorf("Invalid --jq expression: %w", err) + } + var input any + decoder := json.NewDecoder(strings.NewReader(jsonText)) + decoder.UseNumber() + if err := decoder.Decode(&input); err != nil { + return err + } + iter := code.Run(normalizeNumbers(input)) + for { + v, ok := iter.Next() + if !ok { + return nil + } + if err, isErr := v.(error); isErr { + var halt *gojq.HaltError + if errors.As(err, &halt) && halt.Value() == nil { + return nil + } + return fmt.Errorf("--jq: %w", err) + } + if s, isString := v.(string); isString { + fmt.Fprintln(w, s) + continue + } + out, err := gojq.Marshal(v) + if err != nil { + return err + } + var pretty bytes.Buffer + if err := json.Indent(&pretty, out, "", " "); err != nil { + return err + } + fmt.Fprintln(w, pretty.String()) + } +} + +// gojq works on float64 and big numbers, not json.Number. +func normalizeNumbers(v any) any { + switch x := v.(type) { + case json.Number: + if i, err := x.Int64(); err == nil { + return int(i) + } + f, _ := x.Float64() + return f + case map[string]any: + for k, item := range x { + x[k] = normalizeNumbers(item) + } + return x + case []any: + for i, item := range x { + x[i] = normalizeNumbers(item) + } + return x + } + return v +} diff --git a/internal/platform/response.go b/internal/platform/response.go index ba06d38..e8ef33e 100644 --- a/internal/platform/response.go +++ b/internal/platform/response.go @@ -122,3 +122,9 @@ func AllPages[T any](fetch func(page, size int32) (*http.Response, error)) ([]T, page = *body.NextPage } } + +// AllPagesRaw is AllPages keeping each item as the platform sent it, for commands that +// print the items themselves with -t json or --jq. +func AllPagesRaw(fetch func(page, size int32) (*http.Response, error)) ([]json.RawMessage, error) { + return AllPages[json.RawMessage](fetch) +} diff --git a/internal/resource/list.go b/internal/resource/list.go new file mode 100644 index 0000000..80d6258 --- /dev/null +++ b/internal/resource/list.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package resource + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" +) + +// ListTypeHelp describes the -t flag of listings. +const ListTypeHelp = `Print the platform's items as "json" or "yaml" instead of a table.` + +// Machine reports whether a listing prints its items rather than a table: with -t, or +// when --jq filters them. +func Machine(explicitType string) bool { + return explicitType != "" || output.JQ != "" +} + +// List prints items, the platform's objects as it sent them, as JSON or YAML, or hands +// over to table when a person is reading. +func List(items []json.RawMessage, explicitType string, table func() error) error { + if !Machine(explicitType) { + return table() + } + parts := make([]string, len(items)) + for i, item := range items { + parts[i] = string(item) + } + return PrintJSONValue([]byte("["+strings.Join(parts, ",")+"]"), explicitType) +} + +// PrintJSONValue prints any JSON value as -t and --jq ask: through the jq expression, +// or as YAML, or as indented JSON. +func PrintJSONValue(raw []byte, explicitType string) error { + value, err := output.ParseValue(raw) + if err != nil { + return err + } + if output.JQ != "" { + return output.ApplyJQ(os.Stdout, jsyaml.CompactJSON(value), output.JQ) + } + if explicitType == "yaml" { + fmt.Print(jsyaml.Dump(value)) + return nil + } + if explicitType != "" && explicitType != "json" { + return fmt.Errorf("Unsupported output format '%s'. Use \"json\" or \"yaml\".", explicitType) + } + fmt.Println(jsyaml.JSON(value)) + return nil +} + +// DecodeEach decodes raw items into typed ones, for the table. +func DecodeEach[T any](raw []json.RawMessage, into *[]T) error { + for _, item := range raw { + var v T + if err := json.Unmarshal(item, &v); err != nil { + return err + } + *into = append(*into, v) + } + return nil +} diff --git a/internal/resource/list_test.go b/internal/resource/list_test.go new file mode 100644 index 0000000..5d7f1b5 --- /dev/null +++ b/internal/resource/list_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package resource_test + +import ( + "encoding/json" + "testing" + + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var items = []json.RawMessage{json.RawMessage(`{"id":"b","name":"Beta","count":2}`), json.RawMessage(`{"id":"a","name":"Alpha","count":1}`)} + +func TestListPrintsTheTableForPeople(t *testing.T) { + out, err := platformtest.Stdout(t, func() error { + return resource.List(items, "", func() error { print("TABLE"); return nil }) + }) + require.NoError(t, err) + assert.Empty(t, out) // the table callback ran instead +} + +func TestListPrintsItemsInThePlatformsOrder(t *testing.T) { + out, err := platformtest.Stdout(t, func() error { return resource.List(items, "yaml", nil) }) + + require.NoError(t, err) + assert.Equal(t, "- id: b\n name: Beta\n count: 2\n- id: a\n name: Alpha\n count: 1\n", out) +} + +func TestJQPrintsStringsRawAndValuesAsJSON(t *testing.T) { + output.JQ = `.[] | select(.count > 1) | .name, {id}` + t.Cleanup(func() { output.JQ = "" }) + + out, err := platformtest.Stdout(t, func() error { return resource.List(items, "", nil) }) + + require.NoError(t, err) + assert.Equal(t, "Beta\n{\n \"id\": \"b\"\n}\n", out) +} + +func TestJQReportsABadExpression(t *testing.T) { + output.JQ = `.[` + t.Cleanup(func() { output.JQ = "" }) + + _, err := platformtest.Stdout(t, func() error { return resource.List(items, "", nil) }) + + assert.ErrorContains(t, err, "Invalid --jq expression") +} diff --git a/internal/resource/resource.go b/internal/resource/resource.go index f7183b8..a97199a 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -19,6 +19,10 @@ import ( // Output writes to the file when one is given and to stdout otherwise, as JSON // indented by two, or YAML. func Output(doc *output.Document, file, explicitType string) error { + // --jq filters what would be printed; a file is written as asked. + if output.JQ != "" && file == "" { + return output.ApplyJQ(os.Stdout, jsyaml.CompactJSON(doc.Value()), output.JQ) + } datatype, err := output.ResolveDatatype(explicitType, file) if err != nil { return err diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index 02089e1..0a353ba 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -7,6 +7,7 @@ package schedule import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -42,6 +43,7 @@ func optional(values []string) *[]string { type ListOptions struct { Teams, Experiments []string + Type string } func List(ctx context.Context, c *platform.Client, o ListOptions) error { @@ -55,9 +57,16 @@ func List(ctx context.Context, c *platform.Client, o ListOptions) error { AllowParallel *bool `json:"allowParallel"` } resp, err := c.GetAllSchedulesV2(ctx, &api.GetAllSchedulesV2Params{Team: optional(o.Teams), Experiment: optional(o.Experiments)}) - if _, err := platform.Decode(resp, err, &schedules); err != nil { + var raw []json.RawMessage + if _, err := platform.Decode(resp, err, &raw); err != nil { return platform.Failed(err, "Failed to get the experiment schedules") } + if resource.Machine(o.Type) { + return resource.List(raw, o.Type, nil) + } + if err := resource.DecodeEach(raw, &schedules); err != nil { + return err + } if len(schedules) == 0 { fmt.Println("No experiment schedules found.") return nil diff --git a/internal/service/service.go b/internal/service/service.go index 5f14964..061fb21 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -54,13 +54,14 @@ func optional(values []string) *[]string { type ListOptions struct { Teams, Environments, Experiments []string + Type string } func List(ctx context.Context, c *platform.Client, o ListOptions) error { type summary struct { ID, Name, Team, Environment string } - services, err := platform.AllPages[summary](func(page, size int32) (*http.Response, error) { + raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { return c.GetServiceList(ctx, &api.GetServiceListParams{ TeamKey: optional(o.Teams), EnvironmentName: optional(o.Environments), ExperimentKey: optional(o.Experiments), Page: api.PageRequestAO{Page: &page, Size: &size}, @@ -69,6 +70,13 @@ func List(ctx context.Context, c *platform.Client, o ListOptions) error { if err != nil { return platform.Failed(err, "Failed to get the services") } + if resource.Machine(o.Type) { + return resource.List(raw, o.Type, nil) + } + var services []summary + if err := resource.DecodeEach(raw, &services); err != nil { + return err + } if len(services) == 0 { fmt.Println("No services found.") return nil @@ -196,7 +204,7 @@ func Risk(ctx context.Context, c *platform.Client, o RiskOptions) error { riskText = jsyaml.NumberString(n) } - if o.Type != "" { + if resource.Machine(o.Type) { if err := resource.Output(doc, "", o.Type); err != nil { return err } @@ -253,6 +261,7 @@ func number(m *jsyaml.Map, key string) any { type ExperimentListOptions struct { ID string Categories, Types []string + Type string } func ListExperiments(ctx context.Context, c *platform.Client, o ExperimentListOptions) error { @@ -274,12 +283,19 @@ func ListExperiments(ctx context.Context, c *platform.Client, o ExperimentListOp Category string `json:"category"` AssociationType string `json:"associationType"` } - experiments, err := platform.AllPages[entry](func(page, size int32) (*http.Response, error) { + raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { return c.GetServiceExperiments(ctx, id, &api.GetServiceExperimentsParams{Category: optional(o.Categories), Type: types, Page: api.PageRequestAO{Page: &page, Size: &size}}) }) if err != nil { return notFoundOr(err, o.ID, "Failed to get the experiments of service %s") } + if resource.Machine(o.Type) { + return resource.List(raw, o.Type, nil) + } + var experiments []entry + if err := resource.DecodeEach(raw, &experiments); err != nil { + return err + } if len(experiments) == 0 { if len(o.Categories) > 0 || len(o.Types) > 0 { fmt.Printf("Service %s has no matching experiments.\n", o.ID) diff --git a/internal/serviceprofile/serviceprofile.go b/internal/serviceprofile/serviceprofile.go index ffdf1a1..9fc62a1 100644 --- a/internal/serviceprofile/serviceprofile.go +++ b/internal/serviceprofile/serviceprofile.go @@ -44,6 +44,7 @@ type ListOptions struct { Name string Origins []string Default bool + Type string } func List(ctx context.Context, c *platform.Client, o ListOptions) error { @@ -70,7 +71,7 @@ func List(ctx context.Context, c *platform.Client, o ListOptions) error { if o.Default { params.DefaultProfile = &o.Default } - profiles, err := platform.AllPages[profile](func(page, size int32) (*http.Response, error) { + raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { p := params p.Page = api.PageRequestAO{Page: &page, Size: &size} return c.GetProfiles(ctx, &p) @@ -78,6 +79,13 @@ func List(ctx context.Context, c *platform.Client, o ListOptions) error { if err != nil { return platform.Failed(err, "Failed to get the service profiles") } + if resource.Machine(o.Type) { + return resource.List(raw, o.Type, nil) + } + var profiles []profile + if err := resource.DecodeEach(raw, &profiles); err != nil { + return err + } if len(profiles) == 0 { fmt.Println("No service profiles found.") return nil diff --git a/internal/template/template.go b/internal/template/template.go index 4e1367a..3488718 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -6,6 +6,7 @@ package template import ( "context" + "encoding/json" "fmt" "net/http" @@ -20,6 +21,7 @@ import ( type ListOptions struct { Tags, TargetTypes, Actions, Search []string + Type string } func optional(values []string) *[]string { @@ -30,18 +32,27 @@ func optional(values []string) *[]string { } func List(ctx context.Context, c *platform.Client, o ListOptions) error { - var summaries struct { - Templates []struct { - ID string `json:"id"` - TemplateTitle string `json:"templateTitle"` - } `json:"templates"` + var raw struct { + Templates []json.RawMessage `json:"templates"` } resp, err := c.GetExperimentTemplates(ctx, &api.GetExperimentTemplatesParams{ Tag: optional(o.Tags), TargetType: optional(o.TargetTypes), Action: optional(o.Actions), FreeTextPhrases: optional(o.Search), }) - if _, err := platform.Decode(resp, err, &summaries); err != nil { + if _, err := platform.Decode(resp, err, &raw); err != nil { return platform.Failed(err, "Failed to get the experiment templates") } + if resource.Machine(o.Type) { + return resource.List(raw.Templates, o.Type, nil) + } + var summaries struct { + Templates []struct { + ID string `json:"id"` + TemplateTitle string `json:"templateTitle"` + } + } + if err := resource.DecodeEach(raw.Templates, &summaries.Templates); err != nil { + return err + } if len(summaries.Templates) == 0 { fmt.Println("No experiment templates found.") return nil From 826bd59552ba04b40f718dc2c4c0502ab3531ed6 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:34:25 +0200 Subject: [PATCH 03/25] feat(go): apply, delete and import experiment templates --- CHANGELOG.md | 4 +- internal/cli/template.go | 65 +++++++++++++++++++-- internal/resource/resource.go | 37 +++++++++++- internal/template/template.go | 92 +++++++++++++++++++++++++++++- internal/template/template_test.go | 78 +++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d89a48b..f8a588f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ step, with `--execution-variable` for values that apply to that run only. `--wait`, `--retries` and `--allowParallel` work as for any other run. - `template list` and `template get` find templates and their placeholders; - `template get --placeholders` writes a placeholders file to fill in. + `template get --placeholders` writes a placeholders file to fill in. `template apply` + and `template delete` manage templates as files in Git, and `template import` imports + templates from a connected hub. - `execution` commands for experiment runs: `get`, `cancel`, `property set` and `property add` to annotate a run, and `artifact list` and `artifact download` for the files its actions attached. diff --git a/internal/cli/template.go b/internal/cli/template.go index fd90864..860f282 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -11,10 +11,29 @@ import ( "github.com/steadybit/cli/internal/template" ) -const typeHelp = `The output format ("json" or "yaml"). (default: "json" if the file ends in ".json", "yaml" otherwise.)` +const ( + typeHelp = `The output format ("json" or "yaml"). (default: "json" if the file ends in ".json", "yaml" otherwise.)` + yesHelp = "Skip the confirmation prompt. Not necessary when no TTY is attached." + templateID = "d7e65100-1d20-4980-be87-c351704910b8" + hubID = "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a10" +) + +// fileFlags adds the flags every `apply` takes. +func fileFlags(cmd *cobra.Command, files *[]string, recursive *bool, what string) { + cmd.Flags().StringArrayVarP(files, "file", "f", nil, "The path to the "+what+" file or a directory containing multiple files.") + cmd.Flags().BoolVarP(recursive, "recursive", "R", false, "Process the directory used in -f, --file recursively.") + _ = cmd.MarkFlagRequired("file") + variadic(cmd, "file") +} + +// outputFlags adds the flags every `get` takes. +func outputFlags(cmd *cobra.Command, file, datatype *string, what string) { + cmd.Flags().StringVarP(file, "file", "f", "", "The path to write the "+what+" to.") + cmd.Flags().StringVarP(datatype, "type", "t", "", typeHelp) +} func newTemplate() *cobra.Command { - cmd := &cobra.Command{Use: "template", Short: "Find experiment templates to create experiments from."} + cmd := &cobra.Command{Use: "template", Short: "Manage the experiment templates to create experiments from."} var l template.ListOptions list := &cobra.Command{ @@ -39,8 +58,8 @@ func newTemplate() *cobra.Command { Short: "Get an experiment template. Output is written to file or stdout.", Args: cobra.NoArgs, Example: examples( - "steadybit template get -i d7e65100-1d20-4980-be87-c351704910b8", - "steadybit template get -i d7e65100-1d20-4980-be87-c351704910b8 --placeholders -f values.yml", + "steadybit template get -i "+templateID+" -f template.yml", + "steadybit template get -i "+templateID+" --placeholders -f values.yml", ), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return template.Get(ctx, c, g) }), } @@ -50,6 +69,42 @@ func newTemplate() *cobra.Command { get.Flags().BoolVar(&g.Placeholders, "placeholders", false, "Only output the template placeholders, as a file to fill in and pass to --placeholders.") _ = get.MarkFlagRequired("id") - cmd.AddCommand(list, get) + var a template.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update experiment templates from files. A file without an id creates a template, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples("steadybit template apply -f template.yml", "steadybit template apply -f ./templates -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return template.Apply(ctx, c, a) }), + } + fileFlags(apply, &a.Files, &a.Recursive, "template") + + var d template.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete an experiment template. Service profiles lose it, and the experiments they provided from it are deleted.", + Args: cobra.NoArgs, + Example: examples("steadybit template delete -i " + templateID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return template.Delete(ctx, c, d) }), + } + idFlag(del, &d.ID, "The experiment template id.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + var im template.ImportOptions + imp := &cobra.Command{ + Use: "import", + Short: "Import experiment templates from a connected hub.", + Args: cobra.NoArgs, + Example: examples("steadybit template import --hub " + hubID + " --template " + templateID + " --overwrite"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return template.Import(ctx, c, im) }), + } + imp.Flags().StringVar(&im.Hub, "hub", "", "The id of the hub, see `steadybit hub list`.") + imp.Flags().StringArrayVar(&im.Templates, "template", nil, "The ids of the hub's templates to import.") + imp.Flags().BoolVar(&im.Overwrite, "overwrite", false, "Replace templates that exist already. Without it, the import fails if any does.") + _ = imp.MarkFlagRequired("hub") + _ = imp.MarkFlagRequired("template") + variadic(imp, "template") + + cmd.AddCommand(list, get, apply, del, imp) return cmd } diff --git a/internal/resource/resource.go b/internal/resource/resource.go index f7183b8..414d1ab 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -7,13 +7,17 @@ package resource import ( + "bytes" "fmt" + "io" "os" "strings" + openapi_types "github.com/oapi-codegen/runtime/types" "github.com/steadybit/cli/internal/experiment" "github.com/steadybit/cli/internal/jsyaml" "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/prompt" ) // Output writes to the file when one is given and to stdout otherwise, as JSON @@ -103,7 +107,8 @@ func ApplyFiles(paths []string, recursive bool, what string, upsert func(file st if err != nil { return err } - if existingID == nil || existingID == "" { + // Resources named by a key, like teams, have no id to write back. + if (existingID == nil || existingID == "") && result.ID != "" { doc.Value().SetFirst("id", result.ID) if err := os.WriteFile(file, []byte(format(doc, datatype)), 0o644); err != nil { return err @@ -119,3 +124,33 @@ func CreatedOrUpdated(created bool) string { } return "updated" } + +// Body sends a document or value as the JSON the platform expects. +func Body(value any) io.Reader { return bytes.NewReader([]byte(jsyaml.CompactJSON(value))) } + +// Optional leaves an empty filter out of the request. +func Optional(values []string) *[]string { + if len(values) == 0 { + return nil + } + return &values +} + +// UUID parses an id; a malformed one cannot name anything, so callers report it as not found. +func UUID(id string) (openapi_types.UUID, bool) { + var u openapi_types.UUID + return u, u.UnmarshalText([]byte(id)) == nil +} + +// Confirmed asks before something that cannot be undone, unless --yes was given. +// Without a terminal, as in a pipeline, it goes ahead, as `experiment run` does. +func Confirmed(yes bool, question string) (bool, error) { + if yes { + return true, nil + } + ok, err := prompt.Confirm(question, false, true) + if err == nil && !ok { + fmt.Println("Aborted.") + } + return ok, err +} diff --git a/internal/template/template.go b/internal/template/template.go index 4e1367a..bf9c247 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Steadybit GmbH -// Package template implements `template list` and `template get`. +// Package template implements the `template` commands. package template import ( "context" + "errors" "fmt" "net/http" @@ -18,6 +19,18 @@ import ( "github.com/steadybit/cli/internal/table" ) +// Who created and edited a template cannot be sent back. The version is dropped as +// `service get` drops it, so that an edit in the UI does not turn the next apply into a +// conflict. +var readOnly = []string{"created", "createdBy", "edited", "editedBy", "version"} + +func notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Experiment template %s not found.", id) + } + return platform.Failed(err, format, id) +} + type ListOptions struct { Tags, TargetTypes, Actions, Search []string } @@ -94,7 +107,7 @@ func Get(ctx context.Context, c *platform.Client, o GetOptions) error { } doc = output.NewDocument(values) } - if err := resource.Output(doc, o.File, o.Type); err != nil { + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { return err } if o.File != "" { @@ -102,3 +115,78 @@ func Get(ctx context.Context, c *platform.Client, o GetOptions) error { } return nil } + +type ApplyOptions struct { + Files []string + Recursive bool +} + +func Apply(ctx context.Context, c *platform.Client, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "template", func(file string, doc *output.Document) (resource.Applied, error) { + title, _ := doc.Get("templateTitle") + if title == "" { + return resource.Applied{}, fmt.Errorf("Template file '%s' does not name a templateTitle.", file) + } + var saved struct{ ID, TemplateTitle string } + resp, err := c.UpsertExperimentTemplateWithBody(ctx, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save experiment template %s", title) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Experiment template %s (%s) %s.\n", saved.TemplateTitle, saved.ID, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteOptions struct { + ID string + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + id, ok := resource.UUID(o.ID) + if !ok { + return fmt.Errorf("Experiment template %s not found.", o.ID) + } + // Deleting a template also deletes the experiments service profiles provided from it. + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Delete experiment template %s, and the service experiments provided from it?", o.ID)); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteExperimentTemplate(ctx, id)); err != nil { + return notFoundOr(err, o.ID, "Failed to delete experiment template %s") + } + fmt.Printf("Experiment template %s deleted.\n", o.ID) + return nil +} + +type ImportOptions struct { + Hub string + Templates []string + Overwrite bool +} + +func Import(ctx context.Context, c *platform.Client, o ImportOptions) error { + hub, ok := resource.UUID(o.Hub) + if !ok { + return fmt.Errorf("Hub %s not found.", o.Hub) + } + ids := make([]openapi_types.UUID, len(o.Templates)) + for i, t := range o.Templates { + if ids[i], ok = resource.UUID(t); !ok { + return fmt.Errorf("Experiment template %s not found.", t) + } + } + request := api.ExperimentTemplatesImportAO{HubId: hub, TemplateIds: &ids} + _, _, err := platform.Read(c.ImportFromHub(ctx, &api.ImportFromHubParams{Overwrite: &o.Overwrite}, request)) + switch { + case platform.IsStatus(err, http.StatusConflict): + return errors.New("Some of the templates exist already. Pass --overwrite to replace them.") + case platform.IsStatus(err, http.StatusNotFound): + return fmt.Errorf("Hub %s not found.", o.Hub) + case err != nil: + return platform.Failed(err, "Failed to import experiment templates from hub %s", o.Hub) + } + fmt.Printf("%d experiment template(s) imported from hub %s.\n", len(o.Templates), o.Hub) + return nil +} diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 72c7f37..093ca2a 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -6,6 +6,8 @@ package template_test import ( "context" "net/http" + "os" + "path/filepath" "testing" "github.com/steadybit/cli/internal/platformtest" @@ -49,3 +51,79 @@ func TestGetReportsAMissingTemplate(t *testing.T) { assert.EqualError(t, template.Get(context.Background(), p.Client, template.GetOptions{ID: id}), "Experiment template "+id+" not found.") } + +func TestGetAndApplyRoundTrip(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/experiments/templates/"+id, platformtest.Reply{Body: `{"id":"` + id + `","version":2,"templateTitle":"Shop survives","templateDescription":"d","lanes":[],"futureField":1,"created":"c","createdBy":{},"edited":"e","editedBy":{}}`}) + p.Reply("POST /api/experiments/templates", platformtest.Reply{JSON: map[string]any{"id": id, "templateTitle": "Shop survives"}}) + file := filepath.Join(t.TempDir(), "template.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := template.Get(context.Background(), p.Client, template.GetOptions{ID: id, File: file}); err != nil { + return err + } + return template.Apply(context.Background(), p.Client, template.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Experiment template Shop survives ("+id+") updated.") + content, _ := os.ReadFile(file) + assert.Equal(t, "id: "+id+"\ntemplateTitle: Shop survives\ntemplateDescription: d\nlanes: []\nfutureField: 1\n", string(content)) + // A field the spec does not know yet is sent back, not dropped. + assert.Equal(t, map[string]any{"id": id, "templateTitle": "Shop survives", "templateDescription": "d", "lanes": []any{}, "futureField": float64(1)}, + p.Requests("POST /api/experiments/templates")[0].JSON(t)) +} + +func TestApplyCreatesAndWritesTheIdBack(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/templates", platformtest.Reply{Status: http.StatusCreated, JSON: map[string]any{"id": id, "templateTitle": "New"}}) + dir := t.TempDir() + file := filepath.Join(dir, "new.yml") + require.NoError(t, os.WriteFile(file, []byte("templateTitle: New\nlanes: []\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untitled.yml"), []byte("lanes: []\n"), 0o644)) + + out, err := platformtest.Stdout(t, func() error { + return template.Apply(context.Background(), p.Client, template.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Equal(t, "Experiment template New ("+id+") created.\n", out) + content, _ := os.ReadFile(file) + assert.Equal(t, "id: "+id+"\ntemplateTitle: New\nlanes: []\n", string(content)) + err = template.Apply(context.Background(), p.Client, template.ApplyOptions{Files: []string{filepath.Join(dir, "untitled.yml")}}) + assert.EqualError(t, err, "Template file '"+filepath.Join(dir, "untitled.yml")+"' does not name a templateTitle.") +} + +func TestDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/experiments/templates/"+id, platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + return template.Delete(context.Background(), p.Client, template.DeleteOptions{ID: id, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Experiment template "+id+" deleted.\n", out) + assert.EqualError(t, template.Delete(context.Background(), p.Client, template.DeleteOptions{ID: "nope", Yes: true}), "Experiment template nope not found.") +} + +func TestImportFromAHub(t *testing.T) { + const hub = "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a10" + p := platformtest.New(t) + p.Handle("POST /api/experiments/templates/imports", func(r platformtest.Request) platformtest.Reply { + if r.Query["overwrite"][0] == "false" { + return platformtest.Reply{Status: http.StatusConflict} + } + return platformtest.Reply{} + }) + + out, err := platformtest.Stdout(t, func() error { + return template.Import(context.Background(), p.Client, template.ImportOptions{Hub: hub, Templates: []string{id}, Overwrite: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "1 experiment template(s) imported from hub "+hub+".\n", out) + assert.Equal(t, map[string]any{"hubId": hub, "templateIds": []any{id}}, p.Requests("POST /api/experiments/templates/imports")[0].JSON(t)) + assert.EqualError(t, template.Import(context.Background(), p.Client, template.ImportOptions{Hub: hub, Templates: []string{id}}), + "Some of the templates exist already. Pass --overwrite to replace them.") +} From dafba14e8c9bfd0a8586747f60e10aa10485d54b Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:34:55 +0200 Subject: [PATCH 04/25] feat: a GitHub Action that installs the CLI; CI docs `uses: steadybit/cli@v5` installs the release binary for the runner, checked against checksums.txt. Released versions are verified on Linux, macOS and Windows runners after publishing. The README shows the action, a GitLab job with a JUnit report, and -t/--jq. --- .github/workflows/ci.yml | 21 ++++++++++++++ README.md | 49 +++++++++++++++++++++++++++++++++ action.yml | 59 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 action.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2c6a07..d1ef800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,3 +177,24 @@ jobs: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: command: monitor + + # The action downloads from the release, so it can only be checked once one exists. + verify-action: + if: startsWith(github.ref, 'refs/tags/v') + needs: release + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - id: setup + uses: ./ + with: + version: ${{ github.ref_name }} + - shell: bash + run: | + steadybit --version + test "$(steadybit --version)" = "${GITHUB_REF_NAME#v}" + test "${{ steps.setup.outputs.version }}" = "${GITHUB_REF_NAME#v}" diff --git a/README.md b/README.md index dc06235..b5123c0 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,55 @@ steadybit service-profile list --origin custom steadybit service-profile apply -f profile.yml ``` +## In CI + +`experiment run --wait` fails the job when a run fails, and a few options make it fit +pipelines: + +| Option | Does | +| ----------------------------- | ----------------------------------------------------------------------- | +| `--report steadybit.xml` | A JUnit report, one test case per step; `.json` for JSON | +| `--timeout 30m` | Cancels the run and fails when it has not ended in time | +| `--show-steps` | Prints each step's state as it changes | +| `--keep-running-on-interrupt` | Leaves the run going when the job is cancelled; by default it is stopped | + +In GitHub Actions a summary of every run is added to the job summary. + +### GitHub Actions + +```yaml +- uses: steadybit/cli@v5 +- run: steadybit experiment run -f ./experiments -R --yes --report steadybit.xml + env: + STEADYBIT_TOKEN: ${{ secrets.STEADYBIT_TOKEN }} +- uses: mikepenz/action-junit-report@v5 + if: always() + with: + report_paths: steadybit.xml +``` + +### GitLab CI + +```yaml +chaos: + image: + name: steadybit/cli:5 + entrypoint: [''] + script: + - steadybit experiment run -f ./experiments -R --yes --report steadybit.xml + artifacts: + when: always + reports: + junit: steadybit.xml +``` + +Every listing prints the platform's items with `-t json` or `-t yaml`, and `--jq` filters +whatever JSON a command prints, without jq installed: + +```bash +steadybit service list --team ADM --jq '.[] | "\(.id) \(.name)"' +``` + ## Container Image You can also use the cli via our container image: diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..60e88e2 --- /dev/null +++ b/action.yml @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Steadybit GmbH + +name: Set up the Steadybit CLI +description: Installs the steadybit CLI on the runner, so later steps can run experiments. +author: Steadybit GmbH +branding: + icon: activity + color: green + +inputs: + version: + description: The version to install, like 5.0.0, or latest. + default: latest + +outputs: + version: + description: The installed version. + value: ${{ steps.install.outputs.version }} + +runs: + using: composite + steps: + - id: install + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + case "${RUNNER_OS}" in + Linux) os=linux ;; + macOS) os=darwin ;; + Windows) os=windows ;; + *) echo "::error::Unsupported runner OS ${RUNNER_OS}"; exit 1 ;; + esac + case "${RUNNER_ARCH}" in + X64) arch=amd64 ;; + ARM64) arch=arm64 ;; + *) echo "::error::Unsupported runner architecture ${RUNNER_ARCH}"; exit 1 ;; + esac + ext=tar.gz + [ "$os" = windows ] && ext=zip + archive="steadybit_${os}_${arch}.${ext}" + if [ "$VERSION" = latest ]; then + base="https://github.com/steadybit/cli/releases/latest/download" + else + base="https://github.com/steadybit/cli/releases/download/v${VERSION#v}" + fi + dir="${RUNNER_TOOL_CACHE:-$RUNNER_TEMP}/steadybit-cli/${VERSION}/${arch}" + mkdir -p "$dir" + cd "$dir" + curl -fsSL --retry 3 -o "$archive" "$base/$archive" + curl -fsSL --retry 3 -o checksums.txt "$base/checksums.txt" + # Only the archive's own line; checksums.txt lists every platform. + grep " ${archive}\$" checksums.txt > "${archive}.sha256" + if command -v sha256sum >/dev/null; then sha256sum -c "${archive}.sha256"; else shasum -a 256 -c "${archive}.sha256"; fi + if [ "$ext" = zip ]; then unzip -oq "$archive" steadybit.exe; else tar -xzf "$archive" steadybit; fi + echo "$dir" >> "$GITHUB_PATH" + echo "version=$("$dir/steadybit" --version)" >> "$GITHUB_OUTPUT" From b4dbd01c0fc662d305298172154fdd4e5b8cfee9 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:36:48 +0200 Subject: [PATCH 05/25] feat(go): environments and their variables --- CHANGELOG.md | 2 + internal/cli/environment.go | 93 ++++++++++++ internal/cli/root.go | 2 +- internal/environment/environment.go | 183 +++++++++++++++++++++++ internal/environment/environment_test.go | 110 ++++++++++++++ internal/resource/resource.go | 38 +++++ internal/service/service.go | 37 +---- 7 files changed, 434 insertions(+), 31 deletions(-) create mode 100644 internal/cli/environment.go create mode 100644 internal/environment/environment.go create mode 100644 internal/environment/environment_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a588f..8f337df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ provides (from a profile template) a service's experiments, and `service variable` gets, merges or replaces its variables. - `service-profile` commands to `list`, `get`, `apply` and `delete` service profiles. +- `environment` commands to `list`, `get`, `apply` and `delete` environments, and + `environment variable` to get, merge or replace their variables. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/cli/environment.go b/internal/cli/environment.go new file mode 100644 index 0000000..57e1cc3 --- /dev/null +++ b/internal/cli/environment.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/environment" + "github.com/steadybit/cli/internal/platform" +) + +const environmentID = "0190d7b2-1c5e-7f3a-8e4b-2d6f9a1c3e57" + +func newEnvironment() *cobra.Command { + cmd := &cobra.Command{Use: "environment", Short: "Manage environments and their variables."} + + var l environment.ListOptions + list := &cobra.Command{ + Use: "list", + Short: "List environments.", + Args: cobra.NoArgs, + Example: examples("steadybit environment list", "steadybit environment list --search prod"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.List(ctx, c, l) }), + } + list.Flags().StringVar(&l.Search, "search", "", "Only list environments whose name, or the name or key of a team using them, matches.") + + var g environment.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get an environment. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit environment get -i " + environmentID + " -f environment.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.Get(ctx, c, g) }), + } + idFlag(get, &g.ID, "The environment id.") + outputFlags(get, &g.File, &g.Type, "environment") + + var a environment.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update environments from files. A file without an id creates an environment, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples("steadybit environment apply -f environment.yml", "steadybit environment apply -f ./environments -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.Apply(ctx, c, a) }), + } + fileFlags(apply, &a.Files, &a.Recursive, "environment") + + var d environment.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete an environment.", + Args: cobra.NoArgs, + Example: examples("steadybit environment delete -i " + environmentID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.Delete(ctx, c, d) }), + } + idFlag(del, &d.ID, "The environment id.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + variable := &cobra.Command{Use: "variable", Short: "Manage the variables of an environment."} + var vg environment.VariableGetOptions + vget := &cobra.Command{ + Use: "get", + Short: "Print the variables of an environment.", + Args: cobra.NoArgs, + Example: examples("steadybit environment variable get -i " + environmentID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return environment.GetVariables(ctx, c, vg) + }), + } + idFlag(vget, &vg.ID, "The environment id.") + vget.Flags().StringVarP(&vg.Type, "type", "t", "yaml", `The output format ("json" or "yaml").`) + var vs environment.VariableSetOptions + vset := &cobra.Command{ + Use: "set [KEY=VALUE...]", + Short: "Set variables of an environment, keeping the others. With --replace, the given variables become the only ones.", + Example: examples( + "steadybit environment variable set -i "+environmentID+" region=eu cluster=prod", + "steadybit environment variable set -i "+environmentID+" -f variables.yml --replace", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, args []string) error { + return environment.SetVariables(ctx, c, args, vs) + }), + } + idFlag(vset, &vs.ID, "The environment id.") + vset.Flags().StringVarP(&vs.File, "file", "f", "", "A YAML/JSON file mapping variable names to values, which may be lists or select expressions.") + vset.Flags().BoolVar(&vs.Replace, "replace", false, "Remove every variable not given.") + variable.AddCommand(vget, vset) + + cmd.AddCommand(list, get, apply, del, variable) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0812837..e1624d8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAdvice(), newConfig(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTemplate()) + root.AddCommand(newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTemplate()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/environment/environment.go b/internal/environment/environment.go new file mode 100644 index 0000000..e800766 --- /dev/null +++ b/internal/environment/environment.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package environment implements the `environment` commands. +package environment + +import ( + "context" + "fmt" + "net/http" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// The state is the platform's, and the version is dropped as `service get` drops it: kept +// in a file, it turns every apply after an edit in the UI into a conflict. +var readOnly = []string{"version", "state"} + +func uuid(id string) (openapi_types.UUID, error) { + u, ok := resource.UUID(id) + if !ok { + return u, fmt.Errorf("Environment %s not found.", id) + } + return u, nil +} + +func notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Environment %s not found.", id) + } + return platform.Failed(err, format, id) +} + +type ListOptions struct { + Search string +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + var summaries struct { + Environments []struct { + ID, Name, State, Query string + } `json:"environments"` + } + params := &api.GetEnvironmentsParams{} + if o.Search != "" { + params.Search = &o.Search + } + resp, err := c.GetEnvironments(ctx, params) + if _, err := platform.Decode(resp, err, &summaries); err != nil { + return platform.Failed(err, "Failed to get the environments") + } + if len(summaries.Environments) == 0 { + fmt.Println("No environments found.") + return nil + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "state", Title: "State", Alignment: table.Left}, + ) + for _, e := range summaries.Environments { + t.AddRow(table.Default, table.Cell("id", e.ID), table.Cell("name", e.Name), table.Cell("state", e.State)) + } + t.Print() + return nil +} + +type GetOptions struct { + ID, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, o GetOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + doc, _, err := platform.ReadDocument(c.GetEnvironment(ctx, id)) + if err != nil { + return notFoundOr(err, o.ID, "Failed to get environment %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Environment %s written to %s.\n", o.ID, o.File) + } + return nil +} + +type ApplyOptions struct { + Files []string + Recursive bool +} + +func Apply(ctx context.Context, c *platform.Client, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "environment", func(file string, doc *output.Document) (resource.Applied, error) { + name, _ := doc.Get("name") + if name == "" { + return resource.Applied{}, fmt.Errorf("Environment file '%s' does not name the environment.", file) + } + var saved struct{ ID, Name string } + resp, err := c.UpsertEnvironmentWithBody(ctx, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save environment %s", name) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Environment %s (%s) %s.\n", saved.Name, saved.ID, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteOptions struct { + ID string + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Delete environment %s?", o.ID)); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteEnvironment(ctx, id)); err != nil { + return notFoundOr(err, o.ID, "Failed to delete environment %s") + } + fmt.Printf("Environment %s deleted.\n", o.ID) + return nil +} + +type VariableGetOptions struct { + ID, Type string +} + +func GetVariables(ctx context.Context, c *platform.Client, o VariableGetOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + // The spec declares a string; the platform answers with the map of variables. + doc, _, err := platform.ReadDocument(c.GetEnvironmentVariables(ctx, id)) + if err != nil { + return notFoundOr(err, o.ID, "Failed to get the variables of environment %s") + } + return resource.Output(doc, "", o.Type) +} + +type VariableSetOptions struct { + ID string + File string + Replace bool +} + +// SetVariables merges KEY=VALUE arguments over a file's variables with PUT; with +// --replace, POST makes them the only ones. +func SetVariables(ctx context.Context, c *platform.Client, pairs []string, o VariableSetOptions) error { + variables, err := resource.Variables(pairs, o.File, o.Replace) + if err != nil { + return err + } + id, err := uuid(o.ID) + if err != nil { + return err + } + body := resource.Body(variables) + if o.Replace { + _, _, err = platform.Read(c.SetEnvironmentVariablesWithBody(ctx, id, "application/json", body)) + } else { + _, _, err = platform.Read(c.UpdateEnvironmentVariablesWithBody(ctx, id, "application/json", body)) + } + if err != nil { + return notFoundOr(err, o.ID, "Failed to update the variables of environment %s") + } + fmt.Printf("%d variable(s) of environment %s %s.\n", variables.Len(), o.ID, resource.VariablesOutcome(o.Replace)) + return nil +} diff --git a/internal/environment/environment_test.go b/internal/environment/environment_test.go new file mode 100644 index 0000000..a5011da --- /dev/null +++ b/internal/environment/environment_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package environment_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/environment" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "0190d7b2-1c5e-7f3a-8e4b-2d6f9a1c3e57" + +func TestListSearches(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/environments", platformtest.Reply{JSON: map[string]any{"environments": []any{map[string]any{"id": id, "name": "Prod", "state": "READY"}}}}) + + out, err := platformtest.Stdout(t, func() error { return environment.List(ctx, p.Client, environment.ListOptions{Search: "pro"}) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ Prod │ READY │") + assert.Equal(t, []string{"pro"}, p.Requests("GET /api/environments")[0].Query["search"]) +} + +func TestListSaysWhenThereIsNone(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/environments", platformtest.Reply{JSON: map[string]any{"environments": []any{}}}) + + out, err := platformtest.Stdout(t, func() error { return environment.List(ctx, p.Client, environment.ListOptions{}) }) + + require.NoError(t, err) + assert.Equal(t, "No environments found.\n", out) + assert.NotContains(t, p.Requests("GET /api/environments")[0].Query, "search") +} + +func TestGetAndApplyRoundTrip(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/environments/"+id, platformtest.Reply{Body: `{"id":"` + id + `","name":"Prod","version":4,"query":"k8s.cluster-name=\"prod\"","state":"READY"}`}) + p.Reply("POST /api/environments", platformtest.Reply{JSON: map[string]any{"id": id, "name": "Prod"}}) + file := filepath.Join(t.TempDir(), "environment.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := environment.Get(ctx, p.Client, environment.GetOptions{ID: id, File: file}); err != nil { + return err + } + return environment.Apply(ctx, p.Client, environment.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Environment Prod ("+id+") updated.") + content, _ := os.ReadFile(file) + assert.Equal(t, "id: "+id+"\nname: Prod\nquery: k8s.cluster-name=\"prod\"\n", string(content)) + assert.Equal(t, map[string]any{"id": id, "name": "Prod", "query": `k8s.cluster-name="prod"`}, p.Requests("POST /api/environments")[0].JSON(t)) +} + +func TestApplyCreatesAndWritesTheIdBack(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/environments", platformtest.Reply{Status: http.StatusCreated, JSON: map[string]any{"id": id, "name": "New"}}) + file := filepath.Join(t.TempDir(), "new.json") + require.NoError(t, os.WriteFile(file, []byte(`{"name":"New","query":"x"}`), 0o644)) + + out, err := platformtest.Stdout(t, func() error { return environment.Apply(ctx, p.Client, environment.ApplyOptions{Files: []string{file}}) }) + + require.NoError(t, err) + assert.Equal(t, "Environment New ("+id+") created.\n", out) + content, _ := os.ReadFile(file) + assert.Equal(t, "{\n \"id\": \""+id+"\",\n \"name\": \"New\",\n \"query\": \"x\"\n}", string(content)) +} + +func TestDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/environments/"+id, platformtest.Reply{Status: http.StatusNotFound}) + + err := environment.Delete(ctx, p.Client, environment.DeleteOptions{ID: id, Yes: true}) + + assert.EqualError(t, err, "Environment "+id+" not found.") + assert.EqualError(t, environment.Delete(ctx, p.Client, environment.DeleteOptions{ID: "Prod", Yes: true}), "Environment Prod not found.") +} + +func TestVariablesMergeUnlessReplaced(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/environments/"+id+"/variables", platformtest.Reply{Body: `{"region":"eu"}`}) + p.Reply("PUT /api/environments/"+id+"/variables", platformtest.Reply{}) + p.Reply("POST /api/environments/"+id+"/variables", platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + if err := environment.GetVariables(ctx, p.Client, environment.VariableGetOptions{ID: id, Type: "json"}); err != nil { + return err + } + if err := environment.SetVariables(ctx, p.Client, []string{"region=us"}, environment.VariableSetOptions{ID: id}); err != nil { + return err + } + return environment.SetVariables(ctx, p.Client, nil, environment.VariableSetOptions{ID: id, Replace: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "{\n \"region\": \"eu\"\n}\n1 variable(s) of environment "+id+" set.\n0 variable(s) of environment "+id+" set, all others removed.\n", out) + assert.Equal(t, map[string]any{"region": "us"}, p.Requests("PUT /api/environments/" + id + "/variables")[0].JSON(t)) + assert.Equal(t, map[string]any{}, p.Requests("POST /api/environments/" + id + "/variables")[0].JSON(t)) + assert.EqualError(t, environment.SetVariables(ctx, p.Client, nil, environment.VariableSetOptions{ID: id}), "No variables given. Pass KEY=VALUE arguments or --file.") +} diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 414d1ab..b4521d8 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -8,6 +8,7 @@ package resource import ( "bytes" + "errors" "fmt" "io" "os" @@ -154,3 +155,40 @@ func Confirmed(yes bool, question string) (bool, error) { } return ok, err } + +// Variables merges KEY=VALUE arguments, always strings, over a file's variables, which +// may be lists or select expressions. Nothing given is only allowed when replacing, where +// it removes every variable. +func Variables(pairs []string, file string, replace bool) (*jsyaml.Map, error) { + given := jsyaml.NewMap() + for _, pair := range pairs { + i := strings.Index(pair, "=") + if i <= 0 { + return nil, fmt.Errorf("'%s' is not in the form KEY=VALUE.", pair) + } + given.Set(pair[:i], pair[i+1:]) + } + variables := jsyaml.NewMap() + if file != "" { + doc, _, err := Read(file, "variables") + if err != nil { + return nil, fmt.Errorf("Variables file '%s' must be a map of variable names to values.", file) + } + variables = doc.Value() + } + for _, k := range given.Keys() { + v, _ := given.Get(k) + variables.Set(k, v) + } + if variables.Len() == 0 && !replace { + return nil, errors.New("No variables given. Pass KEY=VALUE arguments or --file.") + } + return variables, nil +} + +func VariablesOutcome(replace bool) string { + if replace { + return "set, all others removed" + } + return "set" +} diff --git a/internal/service/service.go b/internal/service/service.go index 5f14964..fb77a43 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -393,37 +393,18 @@ type VariableSetOptions struct { Replace bool } -// SetVariables merges KEY=VALUE arguments, always strings, over a file's variables, -// which may be lists or select expressions. With --replace, the result is all there is. +// SetVariables merges KEY=VALUE arguments over a file's variables. With --replace, the +// result is all there is. func SetVariables(ctx context.Context, c *platform.Client, pairs []string, o VariableSetOptions) error { - given := jsyaml.NewMap() - for _, pair := range pairs { - i := strings.Index(pair, "=") - if i <= 0 { - return fmt.Errorf("'%s' is not in the form KEY=VALUE.", pair) - } - given.Set(pair[:i], pair[i+1:]) - } - variables := jsyaml.NewMap() - if o.File != "" { - doc, _, err := resource.Read(o.File, "variables") - if err != nil { - return fmt.Errorf("Variables file '%s' must be a map of variable names to values.", o.File) - } - variables = doc.Value() - } - for _, k := range given.Keys() { - v, _ := given.Get(k) - variables.Set(k, v) - } - if variables.Len() == 0 && !o.Replace { - return errors.New("No variables given. Pass KEY=VALUE arguments or --file.") + variables, err := resource.Variables(pairs, o.File, o.Replace) + if err != nil { + return err } id, err := uuid(o.ID) if err != nil { return err } - body := bytes.NewReader([]byte(jsyaml.CompactJSON(variables))) + body := resource.Body(variables) if o.Replace { _, _, err = platform.Read(c.SetServiceVariablesWithBody(ctx, id, "application/json", body)) } else { @@ -432,10 +413,6 @@ func SetVariables(ctx context.Context, c *platform.Client, pairs []string, o Var if err != nil { return notFoundOr(err, o.ID, "Failed to update the variables of service %s") } - outcome := "set" - if o.Replace { - outcome = "set, all others removed" - } - fmt.Printf("%d variable(s) of service %s %s.\n", variables.Len(), o.ID, outcome) + fmt.Printf("%d variable(s) of service %s %s.\n", variables.Len(), o.ID, resource.VariablesOutcome(o.Replace)) return nil } From cc39fbaaede4cd6f9762b8269f893fcebd7b51f4 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:38:39 +0200 Subject: [PATCH 06/25] feat(go): diff and apply --dry-run for experiments, schedules, services and profiles ` diff -f files` prints a unified diff between each file and the platform and exits with 2 when they differ, so a pipeline can detect drift. Fields the platform fills in with defaults, and the key or id of a file matched by externalId or name, are not reported as differences. `apply --dry-run` reports what an apply would create or update. --- internal/cli/experiment.go | 5 +- internal/cli/gitops.go | 54 +++++++++ internal/cli/root.go | 4 + internal/cli/schedule.go | 4 +- internal/cli/service.go | 7 +- internal/gitops/commands.go | 116 ++++++++++++++++++ internal/gitops/compare.go | 145 +++++++++++++++++++++++ internal/gitops/gitops_test.go | 130 ++++++++++++++++++++ internal/gitops/kinds.go | 209 +++++++++++++++++++++++++++++++++ internal/output/output.go | 4 + 10 files changed, 674 insertions(+), 4 deletions(-) create mode 100644 internal/cli/gitops.go create mode 100644 internal/gitops/commands.go create mode 100644 internal/gitops/compare.go create mode 100644 internal/gitops/gitops_test.go create mode 100644 internal/gitops/kinds.go diff --git a/internal/cli/experiment.go b/internal/cli/experiment.go index 0a698c3..d8a19d0 100644 --- a/internal/cli/experiment.go +++ b/internal/cli/experiment.go @@ -11,13 +11,15 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/experiment" + "github.com/steadybit/cli/internal/gitops" "github.com/steadybit/cli/internal/jsyaml" "github.com/steadybit/cli/internal/platform" ) func newExperiment() *cobra.Command { cmd := &cobra.Command{Use: "experiment", Short: "Check and run experiments."} - cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDump()) + cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDump(), + newDiff(gitops.Experiment, "experiment", "experiment.yml")) return cmd } @@ -144,6 +146,7 @@ func newExperimentApply() *cobra.Command { cmd.Flags().BoolVarP(&o.Recursive, "recursive", "R", false, "Process the directory used in -f, --file recursively.") addTemplateFlags(cmd, &t) variadic(cmd, "file") + dryRun(cmd, gitops.Experiment, &o.Files, &o.Recursive) return cmd } diff --git a/internal/cli/gitops.go b/internal/cli/gitops.go new file mode 100644 index 0000000..14ef037 --- /dev/null +++ b/internal/cli/gitops.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + "errors" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/gitops" + "github.com/steadybit/cli/internal/platform" +) + +// newDiff is the `diff` command of a kind of file: `experiment diff`, `schedule diff`... +func newDiff(k gitops.Kind, group, example string) *cobra.Command { + var files []string + var recursive bool + cmd := &cobra.Command{ + Use: "diff", + Short: "Show how " + k.Name + " files differ from the platform. Exits with 2 when they do, so a pipeline can detect drift.", + Args: cobra.NoArgs, + Example: examples( + "steadybit "+group+" diff -f "+example, + "steadybit "+group+" diff -f ./"+group+"s -R || echo drift", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return gitops.DiffFiles(ctx, c, k, files, recursive) + }), + } + cmd.Flags().StringArrayVarP(&files, "file", "f", nil, "The path to the file or a directory containing multiple files.") + cmd.Flags().BoolVarP(&recursive, "recursive", "R", false, "Process the directory used in -f, --file recursively.") + _ = cmd.MarkFlagRequired("file") + variadic(cmd, "file") + return cmd +} + +// dryRun adds --dry-run to an apply command: report what would change, change nothing. +func dryRun(cmd *cobra.Command, k gitops.Kind, files *[]string, recursive *bool) { + var enabled bool + cmd.Flags().BoolVar(&enabled, "dry-run", false, "Report what applying the files would create or update, without changing anything.") + run := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if !enabled { + return run(cmd, args) + } + if len(*files) == 0 { + return errors.New("--dry-run compares files with the platform; pass them with -f.") + } + return withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return gitops.DryRun(ctx, c, k, *files, *recursive) + })(cmd, args) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f570770..6b04e97 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/experiment" + "github.com/steadybit/cli/internal/gitops" "github.com/steadybit/cli/internal/output" "github.com/steadybit/cli/internal/platform" ) @@ -113,6 +114,9 @@ func Execute() int { if errors.Is(err, experiment.ErrIncomplete) { return 1 // already reported, with what was missing } + if errors.Is(err, gitops.ErrDifferent) { + return 2 // the differences were the output + } if errors.Is(err, platform.ErrNoAccessToken) { fmt.Fprintln(os.Stderr, platform.MissingTokenHelp()) } else { diff --git a/internal/cli/schedule.go b/internal/cli/schedule.go index 11a91cb..2ae498e 100644 --- a/internal/cli/schedule.go +++ b/internal/cli/schedule.go @@ -7,6 +7,7 @@ import ( "context" "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/gitops" "github.com/steadybit/cli/internal/platform" "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/schedule" @@ -84,6 +85,7 @@ func newSchedule() *cobra.Command { apply.Flags().BoolVarP(&a.Recursive, "recursive", "R", false, "Process the directory used in -f, --file recursively.") _ = apply.MarkFlagRequired("file") variadic(apply, "file") + dryRun(apply, gitops.Schedule, &a.Files, &a.Recursive) var cr schedule.CreateOptions create := &cobra.Command{ @@ -124,7 +126,7 @@ func newSchedule() *cobra.Command { scheduleIDFlag(c, &id) return c } - cmd.AddCommand(list, get, apply, create, update, + cmd.AddCommand(list, get, apply, newDiff(gitops.Schedule, "schedule", "schedule.yml"), create, update, idCommand("enable", "Enable an experiment schedule.", func(ctx context.Context, c *platform.Client, id string) error { return schedule.SetEnabled(ctx, c, id, true) }), diff --git a/internal/cli/service.go b/internal/cli/service.go index ffdffc6..9ac2241 100644 --- a/internal/cli/service.go +++ b/internal/cli/service.go @@ -7,6 +7,7 @@ import ( "context" "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/gitops" "github.com/steadybit/cli/internal/platform" "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/service" @@ -65,6 +66,7 @@ func newService() *cobra.Command { apply.Flags().BoolVar(&a.DeleteExperiments, "delete-experiments", false, "When the service profile changes, delete provided experiments whose templates the new profile does not contain. Without it, such a change is refused.") _ = apply.MarkFlagRequired("file") variadic(apply, "file") + dryRun(apply, gitops.Service, &a.Files, &a.Recursive) var deleteID string del := &cobra.Command{ @@ -196,7 +198,7 @@ func newService() *cobra.Command { vset.Flags().BoolVar(&vs.Replace, "replace", false, "Remove every variable not given.") variable.AddCommand(vget, vset) - cmd.AddCommand(list, get, apply, del, risk, experiments, variable) + cmd.AddCommand(list, get, apply, newDiff(gitops.Service, "service", "service.yml"), del, risk, experiments, variable) return cmd } @@ -244,6 +246,7 @@ func newServiceProfile() *cobra.Command { apply.Flags().BoolVar(&a.DeleteExperiments, "delete-experiments", false, "Delete the provided experiments of services that use templates removed from the profile.") _ = apply.MarkFlagRequired("file") variadic(apply, "file") + dryRun(apply, gitops.ServiceProfile, &a.Files, &a.Recursive) var deleteID string del := &cobra.Command{ @@ -257,6 +260,6 @@ func newServiceProfile() *cobra.Command { } idFlag(del, &deleteID, "The service profile id.") - cmd.AddCommand(list, get, apply, del) + cmd.AddCommand(list, get, apply, newDiff(gitops.ServiceProfile, "service-profile", "profile.yml"), del) return cmd } diff --git a/internal/gitops/commands.go b/internal/gitops/commands.go new file mode 100644 index 0000000..b0a0ce4 --- /dev/null +++ b/internal/gitops/commands.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package gitops + +import ( + "context" + "errors" + "fmt" + + "github.com/steadybit/cli/internal/experiment" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +// ErrDifferent ends `diff` with exit status 2 when files and platform disagree, so a +// pipeline can tell drift (2) from a failure (1). +var ErrDifferent = errors.New("files differ from the platform") + +func compareAll(ctx context.Context, c *platform.Client, k Kind, paths []string, recursive bool) ([]Result, error) { + files, err := experiment.ResolveFiles(paths, recursive) + if err != nil { + return nil, err + } + results := make([]Result, 0, len(files)) + for _, file := range files { + doc, _, err := resource.Read(file, k.Name) + if err != nil { + return nil, err + } + r, err := Compare(ctx, c, k, file, doc) + if err != nil { + return nil, err + } + results = append(results, r) + } + return results, nil +} + +// DiffFiles prints how each file differs from the platform: a unified diff for changes, +// a line for files that would create something new. +func DiffFiles(ctx context.Context, c *platform.Client, k Kind, paths []string, recursive bool) error { + results, err := compareAll(ctx, c, k, paths, recursive) + if err != nil { + return err + } + different := 0 + for _, r := range results { + switch r.State { + case Changed: + different++ + fmt.Print(colorDiff(r.Diff)) + case New: + different++ + fmt.Println(r.Describe(k)) + } + } + if different == 0 { + fmt.Printf("%d %s file(s) match the platform.\n", len(results), k.Name) + return nil + } + fmt.Printf("%d of %d %s file(s) differ from the platform.\n", different, len(results), k.Name) + return ErrDifferent +} + +// DryRun reports what applying the files would do, without applying anything. +func DryRun(ctx context.Context, c *platform.Client, k Kind, paths []string, recursive bool) error { + results, err := compareAll(ctx, c, k, paths, recursive) + if err != nil { + return err + } + for _, r := range results { + fmt.Println(r.Describe(k)) + } + return nil +} + +func colorDiff(diff string) string { + if !output.ColorsEnabled() { + return diff + } + var out []byte + for _, line := range splitKeep(diff) { + switch { + case len(line) > 3 && (line[:3] == "---" || line[:3] == "+++"): + out = append(out, output.Bold(line)...) + case len(line) > 0 && line[0] == '+': + out = append(out, output.Green(line)...) + case len(line) > 0 && line[0] == '-': + out = append(out, output.Red(line)...) + default: + out = append(out, line...) + } + } + return string(out) +} + +// splitKeep splits after each newline, keeping it, so colours never swallow one. +func splitKeep(s string) []string { + var lines []string + for len(s) > 0 { + i := 0 + for i < len(s) && s[i] != '\n' { + i++ + } + if i < len(s) { + lines = append(lines, s[:i]+"\n") + s = s[i+1:] + } else { + lines = append(lines, s) + s = "" + } + } + return lines +} diff --git a/internal/gitops/compare.go b/internal/gitops/compare.go new file mode 100644 index 0000000..0ff8eb8 --- /dev/null +++ b/internal/gitops/compare.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package gitops compares files kept in Git with what the platform holds, for `diff` +// and `apply --dry-run`, and moves whole projects between the two. +package gitops + +import ( + "math" + "strings" + + "github.com/pmezard/go-difflib/difflib" + "github.com/steadybit/cli/internal/jsyaml" +) + +// Comparable projects the platform's version of a document onto the file's: every field +// the file sets, and only those fields the file leaves out that hold something. The +// platform fills in defaults (false, empty lists and maps) that a file written by hand +// omits; reporting those would bury every real difference. +func Comparable(local, remote any) any { + lm, lok := local.(*jsyaml.Map) + rm, rok := remote.(*jsyaml.Map) + if lok && rok { + out := jsyaml.NewMap() + for _, k := range lm.Keys() { + lv, _ := lm.Get(k) + if rv, ok := rm.Get(k); ok { + out.Set(k, Comparable(lv, rv)) + } else { + out.Set(k, missing{}) + } + } + for _, k := range rm.Keys() { + if _, ok := lm.Get(k); ok { + continue + } + if rv, _ := rm.Get(k); !isEmpty(rv) { + out.Set(k, rv) + } + } + return out + } + ll, lok := local.([]any) + rl, rok := remote.([]any) + if lok && rok { + out := make([]any, len(rl)) + for i := range rl { + if i < len(ll) { + out[i] = Comparable(ll[i], rl[i]) + } else { + out[i] = rl[i] + } + } + return out + } + return remote +} + +// missing marks a field the file sets and the platform does not have. +type missing struct{} + +// isEmpty is what the platform writes when nothing was set: nothing, false, zero, an +// empty string, list or map. +func isEmpty(v any) bool { + switch x := v.(type) { + case nil: + return true + case bool: + return !x + case float64: + return x == 0 && !math.Signbit(x) + case string: + return x == "" + case []any: + return len(x) == 0 + case *jsyaml.Map: + for _, k := range x.Keys() { + if value, _ := x.Get(k); !isEmpty(value) { + return false + } + } + return true + } + return false +} + +// withoutMissing drops the markers, so a field the platform lacks shows as removed. +func withoutMissing(v any) any { + switch x := v.(type) { + case *jsyaml.Map: + out := jsyaml.NewMap() + for _, k := range x.Keys() { + value, _ := x.Get(k) + if _, gone := value.(missing); !gone { + out.Set(k, withoutMissing(value)) + } + } + return out + case []any: + out := make([]any, len(x)) + for i, item := range x { + out[i] = withoutMissing(item) + } + return out + } + return v +} + +// Diff is a unified diff from the platform's version to the file's, empty when they +// agree. Both sides are rendered as YAML the way `get` writes it. +func Diff(file string, local, remote *jsyaml.Map) (string, error) { + projected := withoutMissing(Comparable(local, remote)).(*jsyaml.Map) + before, after := jsyaml.Dump(projected), jsyaml.Dump(local) + if before == after { + return "", nil + } + return difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: lines(before), + B: lines(after), + FromFile: "platform", + ToFile: file, + Context: 3, + }) +} + +// lines splits a YAML rendering after each newline. difflib's own splitting adds an empty +// last line, which shows up as a stray line of context. +func lines(text string) []string { + split := strings.SplitAfter(text, "\n") + if len(split) > 0 && split[len(split)-1] == "" { + split = split[:len(split)-1] + } + return split +} + +// Changes counts the changed lines of a diff, for dry runs. +func Changes(diff string) int { + n := 0 + for _, line := range strings.Split(diff, "\n") { + if (strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-")) && !strings.HasPrefix(line, "+++") && !strings.HasPrefix(line, "---") { + n++ + } + } + return n +} diff --git a/internal/gitops/gitops_test.go b/internal/gitops/gitops_test.go new file mode 100644 index 0000000..4b77ab3 --- /dev/null +++ b/internal/gitops/gitops_test.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package gitops_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/gitops" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func doc(t *testing.T, text string) *output.Document { + t.Helper() + d, err := output.ParseDocument([]byte(text)) + require.NoError(t, err) + return d +} + +// What the platform fills in with defaults is not a difference; what it holds besides is. +func TestIgnoresPlatformDefaultsButNotRealValues(t *testing.T) { + local := doc(t, "name: x\nlanes:\n - steps:\n - type: wait\n") + remote := doc(t, `{"name":"x","sharedTeams":[],"properties":{},"lanes":[{"steps":[{"type":"wait","ignoreFailure":false}]}]}`) + + diff, err := gitops.Diff("f.yml", local.Value(), remote.Value()) + require.NoError(t, err) + assert.Empty(t, diff) + + remote = doc(t, `{"name":"x","hypothesis":"set in the UI","lanes":[{"steps":[{"type":"wait","ignoreFailure":true}]}]}`) + diff, err = gitops.Diff("f.yml", local.Value(), remote.Value()) + require.NoError(t, err) + assert.Equal(t, `--- platform ++++ f.yml +@@ -2,5 +2,3 @@ + lanes: + - steps: + - type: wait +- ignoreFailure: true +-hypothesis: set in the UI +`, diff) +} + +func TestReportsFieldsTheFileChanges(t *testing.T) { + local := doc(t, "name: new name\nteam: ADM\n") + remote := doc(t, `{"name":"old name","team":"ADM"}`) + + diff, err := gitops.Diff("f.yml", local.Value(), remote.Value()) + + require.NoError(t, err) + assert.Contains(t, diff, "-name: old name\n+name: new name\n") + assert.Equal(t, 2, gitops.Changes(diff)) +} + +func write(t *testing.T, name, content string) string { + t.Helper() + file := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(file, []byte(content), 0o644)) + return file +} + +const stored = `{"key":"TST-1","version":7,"name":"x","team":"TST","created":"c","createdBy":{},"edited":"e","editedBy":{},"lanes":[]}` + +func TestDiffExitsWithDifferentWhenFilesDrift(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/experiments/TST-1", platformtest.Reply{Body: stored}) + same := write(t, "same.yml", "key: TST-1\nname: x\nteam: TST\nlanes: []\n") + changed := write(t, "changed.yml", "key: TST-1\nname: y\nteam: TST\nlanes: []\n") + fresh := write(t, "new.yml", "name: z\n") + + out, err := platformtest.Stdout(t, func() error { return gitops.DiffFiles(ctx, p.Client, gitops.Experiment, []string{same}, false) }) + require.NoError(t, err) + assert.Equal(t, "1 experiment file(s) match the platform.\n", out) + + out, err = platformtest.Stdout(t, func() error { + return gitops.DiffFiles(ctx, p.Client, gitops.Experiment, []string{same, changed, fresh}, false) + }) + assert.ErrorIs(t, err, gitops.ErrDifferent) + assert.Contains(t, out, "-name: x\n+name: y\n") + assert.Contains(t, out, fresh+" would create a new experiment.\n") + assert.Contains(t, out, "2 of 3 experiment file(s) differ from the platform.\n") +} + +func TestDryRunFindsExperimentsByExternalID(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/experiments", platformtest.Reply{JSON: map[string]any{"experiments": []any{map[string]any{"key": "TST-1"}}}}) + p.Reply("GET /api/experiments/TST-1", platformtest.Reply{Body: stored}) + file := write(t, "e.yml", "externalId: shop-latency\nname: renamed\n") + + out, err := platformtest.Stdout(t, func() error { return gitops.DryRun(ctx, p.Client, gitops.Experiment, []string{file}, false) }) + + require.NoError(t, err) + assert.Equal(t, file+" would update experiment TST-1 (4 lines changed).\n", out) + assert.Equal(t, []string{"shop-latency"}, p.Requests("GET /api/experiments")[0].Query["externalId"]) +} + +func TestAScheduleWithAnUnknownIdWouldBeCreated(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/experiments/schedules/gone", platformtest.Reply{Status: http.StatusNotFound}) + file := write(t, "s.yml", "id: gone\nexperimentKey: TST-1\n") + + out, err := platformtest.Stdout(t, func() error { return gitops.DryRun(ctx, p.Client, gitops.Schedule, []string{file}, false) }) + + require.NoError(t, err) + assert.Equal(t, file+" would create a new experiment schedule.\n", out) +} + +func TestProfilesAreFoundByName(t *testing.T) { + p := platformtest.New(t) + id := "019eacd7-fb2c-733a-bed5-99a935323db5" + p.Reply("GET /api/services/profiles", platformtest.Reply{JSON: map[string]any{"items": []any{ + map[string]any{"id": "019eacd7-fb2c-733a-bed5-99a935323db6", "name": "High Redundancy 2"}, + map[string]any{"id": id, "name": "High Redundancy"}, + }}}) + p.Reply("GET /api/services/profiles/"+id, platformtest.Reply{Body: `{"id":"` + id + `","name":"High Redundancy","origin":"CUSTOM","templates":[],"version":3,"defaultProfile":false}`}) + file := write(t, "p.yml", "name: High Redundancy\norigin: CUSTOM\ntemplates: []\n") + + out, err := platformtest.Stdout(t, func() error { return gitops.DryRun(ctx, p.Client, gitops.ServiceProfile, []string{file}, false) }) + + require.NoError(t, err) + assert.Equal(t, file+" matches service profile "+id+".\n", out) +} diff --git a/internal/gitops/kinds.go b/internal/gitops/kinds.go new file mode 100644 index 0000000..e407c04 --- /dev/null +++ b/internal/gitops/kinds.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package gitops + +import ( + "context" + "fmt" + "net/http" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" +) + +// Kind is a type of file kept in Git and how to find its counterpart on the platform. +type Kind struct { + // Name is what messages call it, "experiment" or "service profile". + Name string + // ReadOnly fields are reported by the platform but not part of the file. + ReadOnly []string + // Identity is the field that names it on the platform. A file matched otherwise, by + // an externalId or a name, has none yet, which is not a difference. + Identity string + // Remote finds the platform's version of a file: its id and content, or nil when + // applying the file would create something new. + Remote func(ctx context.Context, c *platform.Client, local *jsyaml.Map) (string, *jsyaml.Map, error) +} + +func str(m *jsyaml.Map, key string) string { + v, _ := m.Get(key) + s, _ := v.(string) + return s +} + +// fetch reads one document; a 404 means there is nothing yet. +func fetch(resp *http.Response, err error) (*jsyaml.Map, error) { + doc, _, err := platform.ReadDocument(resp, err) + if platform.IsStatus(err, http.StatusNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return doc.Value(), nil +} + +func uuid(id string) (openapi_types.UUID, bool) { + var u openapi_types.UUID + return u, u.UnmarshalText([]byte(id)) == nil +} + +var Experiment = Kind{ + Name: "experiment", + ReadOnly: []string{"version", "created", "createdBy", "edited", "editedBy"}, + Identity: "key", + // By its key, or, for a file that has none yet, by the externalId an apply would + // match it with. + Remote: func(ctx context.Context, c *platform.Client, local *jsyaml.Map) (string, *jsyaml.Map, error) { + key := str(local, "key") + if key == "" { + externalID := str(local, "externalId") + if externalID == "" { + return "", nil, nil + } + var list struct { + Experiments []struct { + Key string `json:"key"` + } `json:"experiments"` + } + ids := []string{externalID} + resp, err := c.GetExperiments(ctx, &api.GetExperimentsParams{ExternalId: &ids}) + if _, err := platform.Decode(resp, err, &list); err != nil { + return "", nil, err + } + if len(list.Experiments) == 0 { + return "", nil, nil + } + key = list.Experiments[0].Key + } + remote, err := fetch(c.GetExperiment(ctx, key)) + return key, remote, err + }, +} + +var Schedule = Kind{ + Name: "experiment schedule", + ReadOnly: []string{"editedBy", "lastUpdated", "nextExecution"}, + Identity: "id", + Remote: func(ctx context.Context, c *platform.Client, local *jsyaml.Map) (string, *jsyaml.Map, error) { + id := str(local, "id") + if id == "" { + return "", nil, nil + } + remote, err := fetch(c.GetSchedules(ctx, id)) + return id, remote, err + }, +} + +var Service = Kind{ + Name: "service", + ReadOnly: []string{"version", "created", "createdBy", "edited", "editedBy"}, + Identity: "id", + Remote: func(ctx context.Context, c *platform.Client, local *jsyaml.Map) (string, *jsyaml.Map, error) { + id := str(local, "id") + u, ok := uuid(id) + if !ok { + return "", nil, nil + } + remote, err := fetch(c.GetService(ctx, u)) + return id, remote, err + }, +} + +var ServiceProfile = Kind{ + Name: "service profile", + ReadOnly: []string{"version", "created", "createdBy", "edited", "editedBy", "defaultProfile"}, + Identity: "id", + // By its id, or by its name, which is unique among profiles. + Remote: func(ctx context.Context, c *platform.Client, local *jsyaml.Map) (string, *jsyaml.Map, error) { + id := str(local, "id") + if u, ok := uuid(id); ok { + remote, err := fetch(c.GetProfile(ctx, u)) + return id, remote, err + } + name := str(local, "name") + if name == "" { + return "", nil, nil + } + type profile struct { + ID string `json:"id"` + Name string `json:"name"` + } + profiles, err := platform.AllPages[profile](func(page, size int32) (*http.Response, error) { + return c.GetProfiles(ctx, &api.GetProfilesParams{Name: &name, Page: api.PageRequestAO{Page: &page, Size: &size}}) + }) + if err != nil { + return "", nil, err + } + for _, p := range profiles { + if p.Name == name { + u, _ := uuid(p.ID) + remote, err := fetch(c.GetProfile(ctx, u)) + return p.ID, remote, err + } + } + return "", nil, nil + }, +} + +func strip(m *jsyaml.Map, fields []string) *jsyaml.Map { + c := jsyaml.Clone(m).(*jsyaml.Map) + for _, f := range fields { + c.Delete(f) + } + return c +} + +// State is what applying a file would do. +type State int + +const ( + Unchanged State = iota + Changed + New +) + +type Result struct { + File string + ID string + State State + Diff string +} + +// Compare works out what applying the file would change on the platform. +func Compare(ctx context.Context, c *platform.Client, k Kind, file string, local *output.Document) (Result, error) { + id, remote, err := k.Remote(ctx, c, local.Value()) + if err != nil { + return Result{}, platform.Failed(err, "Failed to get the %s for %s", k.Name, file) + } + if remote == nil { + return Result{File: file, ID: id, State: New}, nil + } + ignored := k.ReadOnly + if _, has := local.Value().Get(k.Identity); !has { + ignored = append(append([]string{}, ignored...), k.Identity) + } + diff, err := Diff(file, strip(local.Value(), ignored), strip(remote, ignored)) + if err != nil { + return Result{}, err + } + if diff == "" { + return Result{File: file, ID: id, State: Unchanged}, nil + } + return Result{File: file, ID: id, State: Changed, Diff: diff}, nil +} + +// Describe is the one-line outcome of a comparison, as a dry run reports it. +func (r Result) Describe(k Kind) string { + switch r.State { + case New: + return fmt.Sprintf("%s would create a new %s.", r.File, k.Name) + case Changed: + return fmt.Sprintf("%s would update %s %s (%d lines changed).", r.File, k.Name, r.ID, Changes(r.Diff)) + } + return fmt.Sprintf("%s matches %s %s.", r.File, k.Name, r.ID) +} diff --git a/internal/output/output.go b/internal/output/output.go index ea1a6a9..8d87984 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -19,6 +19,10 @@ import ( var colorsEnabled = os.Getenv("NO_COLOR") == "" && (os.Getenv("FORCE_COLOR") != "" || term.IsTerminal(int(os.Stdout.Fd()))) +// ColorsEnabled reports whether output is coloured: only on a terminal, unless +// NO_COLOR or FORCE_COLOR say otherwise. +func ColorsEnabled() bool { return colorsEnabled } + func style(code, s string) string { if !colorsEnabled { return s From 32a3ef7772fef8a8b29ada684510772265dc080a Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:39:28 +0200 Subject: [PATCH 07/25] feat(go): teams, their members and environments --- CHANGELOG.md | 3 + internal/cli/root.go | 2 +- internal/cli/team.go | 159 ++++++++++++++++ internal/team/team.go | 367 +++++++++++++++++++++++++++++++++++++ internal/team/team_test.go | 160 ++++++++++++++++ 5 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 internal/cli/team.go create mode 100644 internal/team/team.go create mode 100644 internal/team/team_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f337df..c823489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ - `service-profile` commands to `list`, `get`, `apply` and `delete` service profiles. - `environment` commands to `list`, `get`, `apply` and `delete` environments, and `environment variable` to get, merge or replace their variables. +- `team` commands to `list`, `get`, `apply` and `delete` teams, `team member` to list, add, + remove or replace their members, and `team environment` to do the same with the + environments they may use. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/cli/root.go b/internal/cli/root.go index e1624d8..4ea7d83 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTemplate()) + root.AddCommand(newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/cli/team.go b/internal/cli/team.go new file mode 100644 index 0000000..e3c3432 --- /dev/null +++ b/internal/cli/team.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/team" +) + +func teamKeyFlag(cmd *cobra.Command, key *string) { + cmd.Flags().StringVarP(key, "key", "k", "", "The team key.") + _ = cmd.MarkFlagRequired("key") +} + +func newTeam() *cobra.Command { + cmd := &cobra.Command{Use: "team", Short: "Manage teams, their members and environments."} + + var l team.ListOptions + list := &cobra.Command{ + Use: "list", + Short: "List teams.", + Args: cobra.NoArgs, + Example: examples("steadybit team list", "steadybit team list --search jane@example.com"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.List(ctx, c, l) }), + } + list.Flags().StringVar(&l.Search, "search", "", "Only list teams whose name or key, or a member's name or email, matches.") + + var g team.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a team. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit team get -k ADM -f team.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.Get(ctx, c, g) }), + } + teamKeyFlag(get, &g.Key) + outputFlags(get, &g.File, &g.Type, "team") + + var a team.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update teams from files. The team key names the team to update.", + Args: cobra.NoArgs, + Example: examples("steadybit team apply -f team.yml", "steadybit team apply -f ./teams -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.Apply(ctx, c, a) }), + } + fileFlags(apply, &a.Files, &a.Recursive, "team") + + var d team.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a team. Nothing may be running in it.", + Args: cobra.NoArgs, + Example: examples("steadybit team delete -k OPS", "steadybit team delete -k OPS --purge-experiments --yes"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.Delete(ctx, c, d) }), + } + teamKeyFlag(del, &d.Key) + del.Flags().BoolVar(&d.Experiments, "purge-experiments", false, "Also delete the team's experiments and their runs.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, get, apply, del, newTeamMember(), newTeamEnvironment()) + return cmd +} + +func newTeamMember() *cobra.Command { + cmd := &cobra.Command{Use: "member", Short: "Manage the members of a team."} + + var key string + list := &cobra.Command{ + Use: "list", + Short: "List the members of a team.", + Args: cobra.NoArgs, + Example: examples("steadybit team member list -k ADM"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.ListMembers(ctx, c, key) }), + } + teamKeyFlag(list, &key) + + change := func(use, short string, withRole, confirm bool, example string, run func(context.Context, *platform.Client, team.MemberOptions) error) *cobra.Command { + var o team.MemberOptions + c := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.NoArgs, + Example: examples(example), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return run(ctx, c, o) }), + } + teamKeyFlag(c, &o.Key) + c.Flags().StringArrayVar(&o.Usernames, "username", nil, "Members by username.") + c.Flags().StringArrayVar(&o.Emails, "email", nil, "Members by email.") + variadic(c, "username", "email") + if withRole { + c.Flags().StringVar(&o.Role, "role", "MEMBER", `The role of the given members, "MEMBER" or "OWNER".`) + c.Flags().BoolVar(&o.Validate, "validate", false, "Fail on users the platform does not know, instead of skipping them.") + } + if confirm { + c.Flags().BoolVar(&o.Yes, "yes", false, yesHelp) + } + return c + } + cmd.AddCommand(list, + change("add", "Add members to a team, or change the role of existing ones.", true, false, + "steadybit team member add -k ADM --email jane@example.com joe@example.com --role OWNER", team.AddMembers), + change("remove", "Remove members from a team. They keep their account.", false, true, + "steadybit team member remove -k ADM --email jane@example.com", team.RemoveMembers), + change("set", "Make the given users the only members of a team, removing everyone else.", true, true, + "steadybit team member set -k ADM --email jane@example.com --role OWNER --yes", team.SetMembers), + ) + return cmd +} + +func newTeamEnvironment() *cobra.Command { + cmd := &cobra.Command{Use: "environment", Short: "Manage the environments a team may use."} + + var key string + list := &cobra.Command{ + Use: "list", + Short: "List the environments of a team.", + Args: cobra.NoArgs, + Example: examples("steadybit team environment list -k ADM"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return team.ListEnvironments(ctx, c, key) + }), + } + teamKeyFlag(list, &key) + + change := func(use, short string, validate, confirm bool, example string, run func(context.Context, *platform.Client, team.EnvironmentOptions) error) *cobra.Command { + var o team.EnvironmentOptions + c := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.NoArgs, + Example: examples(example), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return run(ctx, c, o) }), + } + teamKeyFlag(c, &o.Key) + c.Flags().StringArrayVar(&o.Environments, "environment", nil, "Environments by name.") + variadic(c, "environment") + if validate { + c.Flags().BoolVar(&o.Validate, "validate", false, "Fail on environments the platform does not know, instead of skipping them.") + } + if confirm { + c.Flags().BoolVar(&o.Yes, "yes", false, yesHelp) + } + return c + } + cmd.AddCommand(list, + change("add", "Allow a team to use environments.", true, false, + `steadybit team environment add -k ADM --environment Global "Online Shop"`, team.AddEnvironments), + change("remove", "Stop a team from using environments.", false, false, + "steadybit team environment remove -k ADM --environment Global", team.RemoveEnvironments), + change("set", "Make the given environments the only ones a team may use.", true, true, + "steadybit team environment set -k ADM --environment Global --yes", team.SetEnvironments), + ) + return cmd +} diff --git a/internal/team/team.go b/internal/team/team.go new file mode 100644 index 0000000..947c2a1 --- /dev/null +++ b/internal/team/team.go @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package team implements the `team` commands. +package team + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// The version is dropped as `service get` drops it. Members are sent back as the platform +// takes them, by username, email and role; the rest describes the user. +var ( + readOnly = []string{"version"} + memberReadOnly = []string{"name", "pictureUrl", "managedBy"} +) + +func notFoundOr(err error, key, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Team %s not found.", key) + } + return platform.Failed(err, format, key) +} + +type ListOptions struct { + Search string +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + var summaries struct { + Teams []struct { + Key, Name string + AllowedEnvironments []string `json:"allowedEnvironments"` + Members []any `json:"members"` + } `json:"teams"` + } + params := &api.GetTeamsParams{} + if o.Search != "" { + params.Search = &o.Search + } + resp, err := c.GetTeams(ctx, params) + if _, err := platform.Decode(resp, err, &summaries); err != nil { + return platform.Failed(err, "Failed to get the teams") + } + if len(summaries.Teams) == 0 { + fmt.Println("No teams found.") + return nil + } + t := table.New( + table.Column{Name: "key", Title: "Key", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "members", Title: "Members"}, + table.Column{Name: "environments", Title: "Environments"}, + ) + for _, team := range summaries.Teams { + t.AddRow(table.Default, table.Cell("key", team.Key), table.Cell("name", team.Name), table.Cell("members", len(team.Members)), + table.Cell("environments", len(team.AllowedEnvironments))) + } + t.Print() + return nil +} + +type GetOptions struct { + Key, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, o GetOptions) error { + doc, _, err := platform.ReadDocument(c.GetTeam(ctx, o.Key)) + if err != nil { + return notFoundOr(err, o.Key, "Failed to get team %s") + } + resource.Strip(doc, readOnly...) + if members, ok := doc.Value().Get("members"); ok { + list, _ := members.([]any) + for _, m := range list { + if member, ok := m.(*jsyaml.Map); ok { + for _, field := range memberReadOnly { + member.Delete(field) + } + } + } + } + if err := resource.Output(doc, o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Team %s written to %s.\n", o.Key, o.File) + } + return nil +} + +type ApplyOptions struct { + Files []string + Recursive bool +} + +// Apply upserts teams by their key, which is what names a team; there is no id to write back. +func Apply(ctx context.Context, c *platform.Client, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "team", func(file string, doc *output.Document) (resource.Applied, error) { + key, _ := doc.Get("key") + if key == "" { + return resource.Applied{}, fmt.Errorf("Team file '%s' does not name the team key.", file) + } + resp, err := c.UpsertTeamWithBody(ctx, &api.UpsertTeamParams{}, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + _, resp, err = platform.Read(resp, err) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save team %s", key) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Team %s %s.\n", key, resource.CreatedOrUpdated(created)) + return resource.Applied{Created: created}, nil + }) +} + +type DeleteOptions struct { + Key string + Experiments bool + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + question := fmt.Sprintf("Delete team %s?", o.Key) + if o.Experiments { + question = fmt.Sprintf("Delete team %s with all its experiments and their runs?", o.Key) + } + if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteTeam(ctx, o.Key, &api.DeleteTeamParams{PurgeIncludingExperiments: o.Experiments})); err != nil { + return notFoundOr(err, o.Key, "Failed to delete team %s") + } + fmt.Printf("Team %s deleted.\n", o.Key) + return nil +} + +type member struct { + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` +} + +type members struct { + Members []member `json:"members"` +} + +func ListMembers(ctx context.Context, c *platform.Client, key string) error { + var result members + resp, err := c.GetTeamMembers(ctx, key) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, key, "Failed to get the members of team %s") + } + printMembers(key, result.Members) + return nil +} + +func printMembers(key string, list []member) { + if len(list) == 0 { + fmt.Printf("Team %s has no members.\n", key) + return + } + t := table.New( + table.Column{Name: "username", Title: "Username", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "email", Title: "Email", Alignment: table.Left}, + table.Column{Name: "role", Title: "Role", Alignment: table.Left}, + ) + for _, m := range list { + t.AddRow(table.Default, table.Cell("username", m.Username), table.Cell("name", m.Name), table.Cell("email", m.Email), table.Cell("role", m.Role)) + } + t.Print() +} + +// MemberOptions names users by username or email; the platform takes either. +type MemberOptions struct { + Key string + Usernames []string + Emails []string + Role string + Validate bool + Yes bool +} + +func (o MemberOptions) update() (api.TeamMembersUpdateAO, error) { + role := api.MemberUpdateAORole(strings.ToUpper(o.Role)) + if role != api.MemberUpdateAORoleMEMBER && role != api.MemberUpdateAORoleOWNER { + return api.TeamMembersUpdateAO{}, fmt.Errorf("--role must be MEMBER or OWNER, not '%s'.", o.Role) + } + update := api.TeamMembersUpdateAO{Members: []api.MemberUpdateAO{}} + for _, u := range o.Usernames { + update.Members = append(update.Members, api.MemberUpdateAO{Username: &u, Role: role}) + } + for _, e := range o.Emails { + update.Members = append(update.Members, api.MemberUpdateAO{Email: &e, Role: role}) + } + return update, nil +} + +func (o MemberOptions) count() int { return len(o.Usernames) + len(o.Emails) } + +var errNoMembers = errors.New("No members given. Pass --username or --email.") + +func AddMembers(ctx context.Context, c *platform.Client, o MemberOptions) error { + if o.count() == 0 { + return errNoMembers + } + update, err := o.update() + if err != nil { + return err + } + var result members + resp, err := c.AddTeamMembers(ctx, o.Key, &api.AddTeamMembersParams{ValidateMembers: &o.Validate}, update) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to add members to team %s") + } + fmt.Printf("Team %s now has %d member(s).\n", o.Key, len(result.Members)) + return nil +} + +func RemoveMembers(ctx context.Context, c *platform.Client, o MemberOptions) error { + if o.count() == 0 { + return errNoMembers + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Remove %d member(s) from team %s?", o.count(), o.Key)); !ok || err != nil { + return err + } + request := api.TeamMembersRemoveAO{Usernames: resource.Optional(o.Usernames), Emails: resource.Optional(o.Emails)} + var result members + resp, err := c.RemoveTeamMembers(ctx, o.Key, request) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to remove members from team %s") + } + fmt.Printf("Team %s now has %d member(s).\n", o.Key, len(result.Members)) + return nil +} + +// SetMembers replaces the members: everyone not given is removed from the team. +func SetMembers(ctx context.Context, c *platform.Client, o MemberOptions) error { + if o.count() == 0 { + return errNoMembers + } + update, err := o.update() + if err != nil { + return err + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Make these %d the only members of team %s, removing everyone else?", o.count(), o.Key)); !ok || err != nil { + return err + } + var result members + resp, err := c.SetTeamMembers(ctx, o.Key, &api.SetTeamMembersParams{ValidateMembers: &o.Validate}, update) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to set the members of team %s") + } + fmt.Printf("Team %s now has %d member(s).\n", o.Key, len(result.Members)) + return nil +} + +type environments struct { + Environments []struct { + Name string `json:"name"` + } `json:"environments"` +} + +func (e environments) names() []string { + names := make([]string, len(e.Environments)) + for i, env := range e.Environments { + names[i] = env.Name + } + return names +} + +func ListEnvironments(ctx context.Context, c *platform.Client, key string) error { + var result environments + resp, err := c.GetTeamEnvironments(ctx, key) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, key, "Failed to get the environments of team %s") + } + if len(result.Environments) == 0 { + fmt.Printf("Team %s has no environments.\n", key) + return nil + } + t := table.New(table.Column{Name: "name", Title: "Environment", Alignment: table.Left}) + for _, name := range result.names() { + t.AddRow(table.Default, table.Cell("name", name)) + } + t.Print() + return nil +} + +type EnvironmentOptions struct { + Key string + Environments []string + Validate bool + Yes bool +} + +func (o EnvironmentOptions) list() []api.TeamEnvironmentAO { + list := make([]api.TeamEnvironmentAO, len(o.Environments)) + for i, name := range o.Environments { + list[i] = api.TeamEnvironmentAO{Name: name} + } + return list +} + +var errNoEnvironments = errors.New("No environments given. Pass --environment.") + +func reportEnvironments(key string, result environments) { + if len(result.Environments) == 0 { + fmt.Printf("Team %s now has no environments.\n", key) + return + } + fmt.Printf("Team %s now has %d environment(s): %s.\n", key, len(result.Environments), strings.Join(result.names(), ", ")) +} + +func AddEnvironments(ctx context.Context, c *platform.Client, o EnvironmentOptions) error { + if len(o.Environments) == 0 { + return errNoEnvironments + } + var result environments + resp, err := c.AddTeamEnvironments(ctx, o.Key, &api.AddTeamEnvironmentsParams{ValidateEnvironments: &o.Validate}, api.TeamEnvironmentsUpdateAO{Environments: o.list()}) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to add environments to team %s") + } + reportEnvironments(o.Key, result) + return nil +} + +func RemoveEnvironments(ctx context.Context, c *platform.Client, o EnvironmentOptions) error { + if len(o.Environments) == 0 { + return errNoEnvironments + } + var result environments + resp, err := c.RemoveTeamEnvironments(ctx, o.Key, api.TeamEnvironmentsUpdateAO{Environments: o.list()}) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to remove environments from team %s") + } + reportEnvironments(o.Key, result) + return nil +} + +// SetEnvironments replaces the environments a team may use. +func SetEnvironments(ctx context.Context, c *platform.Client, o EnvironmentOptions) error { + if len(o.Environments) == 0 { + return errNoEnvironments + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Make these %d the only environments of team %s?", len(o.Environments), o.Key)); !ok || err != nil { + return err + } + var result environments + resp, err := c.SetTeamEnvironments(ctx, o.Key, &api.SetTeamEnvironmentsParams{ValidateEnvironments: &o.Validate}, api.TeamEnvironmentsAO{Environments: o.list()}) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to set the environments of team %s") + } + reportEnvironments(o.Key, result) + return nil +} diff --git a/internal/team/team_test.go b/internal/team/team_test.go new file mode 100644 index 0000000..9d78185 --- /dev/null +++ b/internal/team/team_test.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package team_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/team" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const stored = `{"id":"0190d7b2-0000-7000-8000-000000000001","key":"OPS","name":"Operations","version":7,"allowedActions":[],"allowedEnvironments":["Global"],"managedBy":"MANUAL",` + + `"members":[{"username":"u-1","name":"Jane","pictureUrl":"p","email":"jane@example.com","role":"OWNER","managedBy":"MANUAL"}]}` + +func TestList(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams", platformtest.Reply{Body: `{"teams":[` + stored + `]}`}) + + out, err := platformtest.Stdout(t, func() error { return team.List(ctx, p.Client, team.ListOptions{Search: "ops"}) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ OPS │ Operations │ 1 │ 1 │") + assert.Equal(t, []string{"ops"}, p.Requests("GET /api/teams")[0].Query["search"]) +} + +func TestGetAndApplyRoundTripByKey(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams/OPS", platformtest.Reply{Body: stored}) + p.Reply("POST /api/teams", platformtest.Reply{Body: stored}) + file := filepath.Join(t.TempDir(), "team.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := team.Get(ctx, p.Client, team.GetOptions{Key: "OPS", File: file}); err != nil { + return err + } + return team.Apply(ctx, p.Client, team.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Team OPS updated.") + before, _ := os.ReadFile(file) + assert.NotContains(t, string(before), "version") + assert.NotContains(t, string(before), "pictureUrl") + sent := p.Requests("POST /api/teams")[0].JSON(t).(map[string]any) + assert.Equal(t, []any{map[string]any{"username": "u-1", "email": "jane@example.com", "role": "OWNER"}}, sent["members"]) + assert.Equal(t, "MANUAL", sent["managedBy"]) +} + +func TestApplyWritesNothingBack(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/teams", platformtest.Reply{Status: http.StatusCreated, Body: stored}) + file := filepath.Join(t.TempDir(), "team.yml") + original := "key: OPS\nname: Operations\nallowedActions: []\nallowedEnvironments: []\n" + require.NoError(t, os.WriteFile(file, []byte(original), 0o644)) + + out, err := platformtest.Stdout(t, func() error { return team.Apply(ctx, p.Client, team.ApplyOptions{Files: []string{file}}) }) + + require.NoError(t, err) + assert.Equal(t, "Team OPS created.\n", out) + content, _ := os.ReadFile(file) + assert.Equal(t, original, string(content)) +} + +func TestDeletePurgesOnlyWhenAsked(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/teams/OPS", platformtest.Reply{Body: stored}) + p.Reply("DELETE /api/teams/NOPE", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + if err := team.Delete(ctx, p.Client, team.DeleteOptions{Key: "OPS", Yes: true}); err != nil { + return err + } + return team.Delete(ctx, p.Client, team.DeleteOptions{Key: "OPS", Experiments: true, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Team OPS deleted.\nTeam OPS deleted.\n", out) + requests := p.Requests("DELETE /api/teams/OPS") + assert.Equal(t, []string{"false"}, requests[0].Query["purgeIncludingExperiments"]) + assert.Equal(t, []string{"true"}, requests[1].Query["purgeIncludingExperiments"]) + assert.EqualError(t, team.Delete(ctx, p.Client, team.DeleteOptions{Key: "NOPE", Yes: true}), "Team NOPE not found.") +} + +func TestMembers(t *testing.T) { + p := platformtest.New(t) + result := platformtest.Reply{Body: `{"members":[{"username":"u-1","name":"Jane","email":"jane@example.com","role":"OWNER"}]}`} + p.Reply("GET /api/teams/OPS/members", result) + p.Reply("POST /api/teams/OPS/members/add", result) + p.Reply("POST /api/teams/OPS/members/remove", result) + p.Reply("PUT /api/teams/OPS/members", result) + + out, err := platformtest.Stdout(t, func() error { + if err := team.ListMembers(ctx, p.Client, "OPS"); err != nil { + return err + } + if err := team.AddMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Usernames: []string{"u-1"}, Emails: []string{"joe@example.com"}, Role: "owner"}); err != nil { + return err + } + if err := team.RemoveMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Emails: []string{"joe@example.com"}, Yes: true}); err != nil { + return err + } + return team.SetMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Usernames: []string{"u-1"}, Role: "OWNER", Validate: true, Yes: true}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ u-1 │ Jane │ jane@example.com │ OWNER │") + assert.Contains(t, out, "Team OPS now has 1 member(s).") + add := p.Requests("POST /api/teams/OPS/members/add")[0] + assert.Equal(t, map[string]any{"members": []any{map[string]any{"username": "u-1", "role": "OWNER"}, map[string]any{"email": "joe@example.com", "role": "OWNER"}}}, add.JSON(t)) + assert.Equal(t, []string{"false"}, add.Query["validateMembers"]) + assert.Equal(t, map[string]any{"emails": []any{"joe@example.com"}}, p.Requests("POST /api/teams/OPS/members/remove")[0].JSON(t)) + set := p.Requests("PUT /api/teams/OPS/members")[0] + assert.Equal(t, map[string]any{"members": []any{map[string]any{"username": "u-1", "role": "OWNER"}}}, set.JSON(t)) + assert.Equal(t, []string{"true"}, set.Query["validateMembers"]) + + assert.EqualError(t, team.AddMembers(ctx, p.Client, team.MemberOptions{Key: "OPS"}), "No members given. Pass --username or --email.") + assert.EqualError(t, team.AddMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Usernames: []string{"x"}, Role: "admin"}), "--role must be MEMBER or OWNER, not 'admin'.") +} + +func TestEnvironments(t *testing.T) { + p := platformtest.New(t) + result := platformtest.Reply{Body: `{"environments":[{"name":"Global"},{"name":"Prod"}]}`} + p.Reply("GET /api/teams/OPS/environments", result) + p.Reply("POST /api/teams/OPS/environments/add", result) + p.Reply("POST /api/teams/OPS/environments/remove", result) + p.Reply("PUT /api/teams/OPS/environments", result) + p.Reply("GET /api/teams/NOPE/environments", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + if err := team.ListEnvironments(ctx, p.Client, "OPS"); err != nil { + return err + } + o := team.EnvironmentOptions{Key: "OPS", Environments: []string{"Prod"}, Yes: true} + if err := team.AddEnvironments(ctx, p.Client, o); err != nil { + return err + } + if err := team.RemoveEnvironments(ctx, p.Client, o); err != nil { + return err + } + return team.SetEnvironments(ctx, p.Client, o) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ Prod │") + assert.Contains(t, out, "Team OPS now has 2 environment(s): Global, Prod.") + want := map[string]any{"environments": []any{map[string]any{"name": "Prod"}}} + assert.Equal(t, want, p.Requests("POST /api/teams/OPS/environments/add")[0].JSON(t)) + assert.Equal(t, want, p.Requests("POST /api/teams/OPS/environments/remove")[0].JSON(t)) + assert.Equal(t, want, p.Requests("PUT /api/teams/OPS/environments")[0].JSON(t)) + assert.EqualError(t, team.ListEnvironments(ctx, p.Client, "NOPE"), "Team NOPE not found.") +} From dcb82059d1f6cad4d13103407e5df7d9b2469a40 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:40:14 +0200 Subject: [PATCH 08/25] feat(go): invite users --- CHANGELOG.md | 1 + internal/cli/root.go | 2 +- internal/cli/user.go | 33 ++++++++++++++++++++++ internal/user/user.go | 56 ++++++++++++++++++++++++++++++++++++++ internal/user/user_test.go | 56 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 internal/cli/user.go create mode 100644 internal/user/user.go create mode 100644 internal/user/user_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c823489..9c1de0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - `team` commands to `list`, `get`, `apply` and `delete` teams, `team member` to list, add, remove or replace their members, and `team environment` to do the same with the environments they may use. +- `user invite` invites users by email, optionally into a team. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/cli/root.go b/internal/cli/root.go index 4ea7d83..edc015d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate()) + root.AddCommand(newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/cli/user.go b/internal/cli/user.go new file mode 100644 index 0000000..435f530 --- /dev/null +++ b/internal/cli/user.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/user" +) + +func newUser() *cobra.Command { + cmd := &cobra.Command{Use: "user", Short: "Manage the users of the tenant."} + + var o user.InviteOptions + invite := &cobra.Command{ + Use: "invite", + Short: "Invite users by email. Each receives an email with a link to join. Needs an admin access token.", + Args: cobra.NoArgs, + Example: examples("steadybit user invite --email jane@example.com joe@example.com --team ADM"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return user.Invite(ctx, c, o) }), + } + invite.Flags().StringArrayVar(&o.Emails, "email", nil, "The email addresses to invite.") + invite.Flags().StringVar(&o.Role, "role", "", `The role in the tenant, "USER" or "ADMIN". (default: the platform's default role)`) + invite.Flags().StringVar(&o.Team, "team", "", "The key of a team to add them to.") + _ = invite.MarkFlagRequired("email") + variadic(invite, "email") + + cmd.AddCommand(invite) + return cmd +} diff --git a/internal/user/user.go b/internal/user/user.go new file mode 100644 index 0000000..aaa9c37 --- /dev/null +++ b/internal/user/user.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package user implements the `user` commands. +package user + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/platform" +) + +type InviteOptions struct { + Emails []string + Role string + Team string +} + +func Invite(ctx context.Context, c *platform.Client, o InviteOptions) error { + if len(o.Emails) == 0 { + return errors.New("No one to invite. Pass --email.") + } + var role *api.InvitationAORole + if o.Role != "" { + r := api.InvitationAORole(strings.ToUpper(o.Role)) + if r != api.InvitationAORoleADMIN && r != api.InvitationAORoleUSER { + return fmt.Errorf("--role must be USER or ADMIN, not '%s'.", o.Role) + } + role = &r + } + request := api.InviteUsersRequestAO{Invitations: make([]api.InvitationAO, len(o.Emails))} + for i, email := range o.Emails { + if _, err := openapi_types.Email(email).MarshalJSON(); err != nil { + return fmt.Errorf("'%s' is not an email address.", email) + } + request.Invitations[i] = api.InvitationAO{Email: openapi_types.Email(email), Role: role} + if o.Team != "" { + request.Invitations[i].TeamKey = &o.Team + } + } + _, _, err := platform.Read(c.InviteUser(ctx, request)) + if platform.IsStatus(err, http.StatusForbidden) { + return errors.New("Inviting users needs an admin access token.") + } + if err != nil { + return platform.Failed(err, "Failed to invite %s", strings.Join(o.Emails, ", ")) + } + fmt.Printf("%d user(s) invited. They receive an email to join.\n", len(o.Emails)) + return nil +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go new file mode 100644 index 0000000..51f9795 --- /dev/null +++ b/internal/user/user_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package user_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/user" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func TestInvite(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/users/invite", platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + return user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane@example.com", "joe@example.com"}, Role: "user", Team: "ADM"}) + }) + + require.NoError(t, err) + assert.Equal(t, "2 user(s) invited. They receive an email to join.\n", out) + assert.Equal(t, map[string]any{"invitations": []any{ + map[string]any{"email": "jane@example.com", "role": "USER", "teamKey": "ADM"}, + map[string]any{"email": "joe@example.com", "role": "USER", "teamKey": "ADM"}, + }}, p.Requests("POST /api/users/invite")[0].JSON(t)) +} + +func TestInviteLeavesOutWhatIsNotGiven(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/users/invite", platformtest.Reply{}) + + _, err := platformtest.Stdout(t, func() error { + return user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane@example.com"}}) + }) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"invitations": []any{map[string]any{"email": "jane@example.com"}}}, p.Requests("POST /api/users/invite")[0].JSON(t)) +} + +func TestInviteRefusals(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/users/invite", platformtest.Reply{Status: http.StatusForbidden}) + + assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane@example.com"}}), "Inviting users needs an admin access token.") + assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane"}}), "'jane' is not an email address.") + assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane@example.com"}, Role: "owner"}), "--role must be USER or ADMIN, not 'owner'.") + assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{}), "No one to invite. Pass --email.") + assert.Len(t, p.Requests("POST /api/users/invite"), 1) +} From c8ec89d8614b0e505a8937f25b1ab955ec36a487 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:42:18 +0200 Subject: [PATCH 09/25] feat(go): access tokens --- CHANGELOG.md | 2 + internal/accesstoken/accesstoken.go | 178 +++++++++++++++++++++++ internal/accesstoken/accesstoken_test.go | 86 +++++++++++ internal/cli/accesstoken.go | 91 ++++++++++++ internal/cli/root.go | 2 +- 5 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 internal/accesstoken/accesstoken.go create mode 100644 internal/accesstoken/accesstoken_test.go create mode 100644 internal/cli/accesstoken.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1de0a..0490e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ remove or replace their members, and `team environment` to do the same with the environments they may use. - `user invite` invites users by email, optionally into a team. +- `access-token` commands to `list`, `create`, `recreate` and `delete` API access tokens. A + new token is printed once; `-t json` prints only its id and value for a script to read. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/accesstoken/accesstoken.go b/internal/accesstoken/accesstoken.go new file mode 100644 index 0000000..bd1a18b --- /dev/null +++ b/internal/accesstoken/accesstoken.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package accesstoken implements the `access-token` commands, on the v2 endpoints only. +package accesstoken + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +func notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Access token %s not found.", id) + } + return platform.Failed(err, format, id) +} + +// ExpiresAt takes a date, meaning its start in UTC, or a full RFC 3339 time. +func ExpiresAt(value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + for _, layout := range []string{time.RFC3339, time.DateOnly} { + if t, err := time.Parse(layout, value); err == nil { + return &t, nil + } + } + return nil, fmt.Errorf("--expires-at '%s' is neither a date like 2026-12-31 nor a time like 2026-12-31T23:59:59Z.", value) +} + +type ListOptions struct { + Name, CreatedBy, Type string + Teams []string + Expired *bool +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + params := api.GetAccessTokens1Params{Teams: resource.Optional(o.Teams), Expired: o.Expired} + if o.Name != "" { + params.Name = &o.Name + } + if o.CreatedBy != "" { + params.CreatedBy = &o.CreatedBy + } + if o.Type != "" { + t := api.GetAccessTokens1ParamsType(strings.ToUpper(o.Type)) + if !t.Valid() { + return fmt.Errorf("--type must be ADMIN, TEAM or WILDCARD, not '%s'.", o.Type) + } + params.Type = &t + } + type summary struct { + ID, Name, Type string + Teams []string + ExpiresAt *string `json:"expiresAt"` + LastUsed *string `json:"lastUsed"` + } + tokens, err := platform.AllPages[summary](func(page, size int32) (*http.Response, error) { + params.PageRequest = api.PageRequestAO{Page: &page, Size: &size} + return c.GetAccessTokens1(ctx, ¶ms) + }) + if err != nil { + return platform.Failed(err, "Failed to get the access tokens") + } + if len(tokens) == 0 { + fmt.Println("No access tokens found.") + return nil + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "type", Title: "Type", Alignment: table.Left}, + table.Column{Name: "teams", Title: "Teams", Alignment: table.Left}, + table.Column{Name: "expiresAt", Title: "Expires", Alignment: table.Left}, + table.Column{Name: "lastUsed", Title: "Last used", Alignment: table.Left}, + ) + or := func(s *string, fallback string) string { + if s == nil { + return fallback + } + return *s + } + for _, token := range tokens { + t.AddRow(table.Default, table.Cell("id", token.ID), table.Cell("name", token.Name), table.Cell("type", token.Type), + table.Cell("teams", strings.Join(token.Teams, ", ")), table.Cell("expiresAt", or(token.ExpiresAt, "never")), table.Cell("lastUsed", or(token.LastUsed, "never"))) + } + t.Print() + return nil +} + +type created struct { + ID string `json:"id"` + Token string `json:"token"` +} + +// printToken shows a new token, the only time it can be seen. With a type, only the id +// and token are printed, for a pipeline to read. +func printToken(token created, datatype, headline string) error { + if datatype != "" { + m := jsyaml.NewMap() + m.Set("id", token.ID) + m.Set("token", token.Token) + return resource.OutputValue(m, "", datatype) + } + fmt.Printf("%s Store it now, it cannot be shown again:\n%s\n", headline, token.Token) + return nil +} + +type CreateOptions struct { + Name, Type, ExpiresAt string + Teams []string + Output string +} + +func Create(ctx context.Context, c *platform.Client, o CreateOptions) error { + kind := api.CreateAccessTokenRequestV2AOType(strings.ToUpper(o.Type)) + if !kind.Valid() { + return fmt.Errorf("--type must be ADMIN, TEAM or WILDCARD, not '%s'.", o.Type) + } + expiresAt, err := ExpiresAt(o.ExpiresAt) + if err != nil { + return err + } + var token created + resp, err := c.CreateAccessToken1(ctx, api.CreateAccessTokenRequestV2AO{Name: o.Name, Type: kind, Teams: resource.Optional(o.Teams), ExpiresAt: expiresAt}) + if _, err := platform.Decode(resp, err, &token); err != nil { + return platform.Failed(err, "Failed to create access token %s", o.Name) + } + return printToken(token, o.Output, fmt.Sprintf("Access token %s (%s) created.", o.Name, token.ID)) +} + +type RecreateOptions struct { + ID, ExpiresAt string + Output string + Yes bool +} + +func Recreate(ctx context.Context, c *platform.Client, o RecreateOptions) error { + expiresAt, err := ExpiresAt(o.ExpiresAt) + if err != nil { + return err + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Recreate access token %s? The current token stops working.", o.ID)); !ok || err != nil { + return err + } + var token created + resp, err := c.RecreateAccessToken(ctx, o.ID, api.RecreateAccessTokenRequestV2AO{ExpiresAt: expiresAt}) + if _, err := platform.Decode(resp, err, &token); err != nil { + return notFoundOr(err, o.ID, "Failed to recreate access token %s") + } + return printToken(token, o.Output, fmt.Sprintf("Access token %s recreated as %s.", o.ID, token.ID)) +} + +type DeleteOptions struct { + ID string + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Delete access token %s? Everything using it loses access.", o.ID)); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteAccessToken1(ctx, o.ID)); err != nil { + return notFoundOr(err, o.ID, "Failed to delete access token %s") + } + fmt.Printf("Access token %s deleted.\n", o.ID) + return nil +} diff --git a/internal/accesstoken/accesstoken_test.go b/internal/accesstoken/accesstoken_test.go new file mode 100644 index 0000000..1271054 --- /dev/null +++ b/internal/accesstoken/accesstoken_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package accesstoken_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/accesstoken" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "Xy12AbCd" + +func TestListWalksEveryPageWithTheFilters(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/access-tokens/v2", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"id": id, "name": "ci", "type": "TEAM", "teams": []any{"ADM", "OPS"}}}, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"id": "b", "name": "admin", "type": "ADMIN", "expiresAt": "2026-12-31T00:00:00Z", "lastUsed": "2026-09-01T10:00:00Z"}}}} + }) + no := false + + out, err := platformtest.Stdout(t, func() error { + return accesstoken.List(ctx, p.Client, accesstoken.ListOptions{Type: "team", Teams: []string{"ADM", "OPS"}, Expired: &no}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ ci │ TEAM │ ADM, OPS │ never │ never │") + assert.Contains(t, out, "│ b │ admin │ ADMIN │ │ 2026-12-31T00:00:00Z │ 2026-09-01T10:00:00Z │") + q := p.Requests("GET /api/access-tokens/v2")[0].Query + assert.Equal(t, []string{"TEAM"}, q["type"]) + assert.Equal(t, []string{"ADM", "OPS"}, q["teams"]) + assert.Equal(t, []string{"false"}, q["expired"]) + assert.Equal(t, []string{"100"}, q["size"]) + assert.EqualError(t, accesstoken.List(ctx, p.Client, accesstoken.ListOptions{Type: "user"}), "--type must be ADMIN, TEAM or WILDCARD, not 'user'.") +} + +func TestCreatePrintsTheTokenOnce(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/access-tokens/v2", platformtest.Reply{JSON: map[string]any{"id": id, "token": "secret-value"}}) + + out, err := platformtest.Stdout(t, func() error { + return accesstoken.Create(ctx, p.Client, accesstoken.CreateOptions{Name: "ci", Type: "team", Teams: []string{"ADM"}, ExpiresAt: "2026-12-31"}) + }) + + require.NoError(t, err) + assert.Equal(t, "Access token ci ("+id+") created. Store it now, it cannot be shown again:\nsecret-value\n", out) + assert.Equal(t, map[string]any{"name": "ci", "type": "TEAM", "teams": []any{"ADM"}, "expiresAt": "2026-12-31T00:00:00Z"}, p.Requests("POST /api/access-tokens/v2")[0].JSON(t)) + + out, err = platformtest.Stdout(t, func() error { + return accesstoken.Create(ctx, p.Client, accesstoken.CreateOptions{Name: "ci", Type: "ADMIN", Output: "json"}) + }) + require.NoError(t, err) + assert.Equal(t, "{\n \"id\": \""+id+"\",\n \"token\": \"secret-value\"\n}\n", out) + assert.Equal(t, map[string]any{"name": "ci", "type": "ADMIN"}, p.Requests("POST /api/access-tokens/v2")[1].JSON(t)) + + assert.EqualError(t, accesstoken.Create(ctx, p.Client, accesstoken.CreateOptions{Name: "ci", Type: "ADMIN", ExpiresAt: "tomorrow"}), + "--expires-at 'tomorrow' is neither a date like 2026-12-31 nor a time like 2026-12-31T23:59:59Z.") +} + +func TestRecreateAndDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/access-tokens/v2/"+id+"/recreate", platformtest.Reply{JSON: map[string]any{"id": "new-id", "token": "new-secret"}}) + p.Reply("DELETE /api/access-tokens/v2/"+id, platformtest.Reply{}) + p.Reply("DELETE /api/access-tokens/v2/nope", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + if err := accesstoken.Recreate(ctx, p.Client, accesstoken.RecreateOptions{ID: id, ExpiresAt: "2027-06-30T12:00:00+02:00", Yes: true}); err != nil { + return err + } + return accesstoken.Delete(ctx, p.Client, accesstoken.DeleteOptions{ID: id, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Access token "+id+" recreated as new-id. Store it now, it cannot be shown again:\nnew-secret\nAccess token "+id+" deleted.\n", out) + assert.Equal(t, map[string]any{"expiresAt": "2027-06-30T12:00:00+02:00"}, p.Requests("POST /api/access-tokens/v2/" + id + "/recreate")[0].JSON(t)) + assert.EqualError(t, accesstoken.Delete(ctx, p.Client, accesstoken.DeleteOptions{ID: "nope", Yes: true}), "Access token nope not found.") +} diff --git a/internal/cli/accesstoken.go b/internal/cli/accesstoken.go new file mode 100644 index 0000000..c6854df --- /dev/null +++ b/internal/cli/accesstoken.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/accesstoken" + "github.com/steadybit/cli/internal/platform" +) + +const ( + accessTokenID = "Xy12AbCd" + tokenTypeHelp = `Print only the id and the token as "json" or "yaml", for a script to read.` +) + +func newAccessToken() *cobra.Command { + cmd := &cobra.Command{Use: "access-token", Short: "Manage the API access tokens of the tenant."} + + var l accesstoken.ListOptions + var expired bool + list := &cobra.Command{ + Use: "list", + Short: "List access tokens. The tokens themselves are never shown.", + Args: cobra.NoArgs, + Example: examples("steadybit access-token list", "steadybit access-token list --type TEAM --team ADM OPS --expired=false"), + PreRun: func(cmd *cobra.Command, _ []string) { + if cmd.Flags().Changed("expired") { + l.Expired = &expired + } + }, + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return accesstoken.List(ctx, c, l) }), + } + list.Flags().StringVar(&l.Name, "name", "", "Only list tokens with this name.") + list.Flags().StringVar(&l.CreatedBy, "created-by", "", "Only list tokens created by this user.") + list.Flags().StringVar(&l.Type, "type", "", `Only list tokens of this type, "ADMIN", "TEAM" or "WILDCARD".`) + list.Flags().StringArrayVar(&l.Teams, "team", nil, "Only list tokens of these teams, by team key.") + list.Flags().BoolVar(&expired, "expired", false, "Only list expired tokens, or with --expired=false those still valid.") + variadic(list, "team") + + var cr accesstoken.CreateOptions + create := &cobra.Command{ + Use: "create", + Short: "Create an access token. The token is printed once and cannot be shown again.", + Args: cobra.NoArgs, + Example: examples( + "steadybit access-token create --name ci --type TEAM --team ADM --expires-at 2026-12-31", + "steadybit access-token create --name ci --type TEAM --team ADM -t json", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return accesstoken.Create(ctx, c, cr) }), + } + create.Flags().StringVar(&cr.Name, "name", "", "The name of the token.") + create.Flags().StringVar(&cr.Type, "type", "", `The type of the token: "ADMIN", "TEAM" for the teams given with --team, or "WILDCARD" for every team.`) + create.Flags().StringArrayVar(&cr.Teams, "team", nil, "The teams of a TEAM token, by team key.") + create.Flags().StringVar(&cr.ExpiresAt, "expires-at", "", "When the token expires, a date or an RFC 3339 time. (default: never)") + create.Flags().StringVarP(&cr.Output, "output", "t", "", tokenTypeHelp) + _ = create.MarkFlagRequired("name") + _ = create.MarkFlagRequired("type") + variadic(create, "team") + + var r accesstoken.RecreateOptions + recreate := &cobra.Command{ + Use: "recreate", + Short: "Replace an access token with a new one of the same name, type and teams. The old token stops working.", + Args: cobra.NoArgs, + Example: examples("steadybit access-token recreate -i " + accessTokenID + " --expires-at 2027-06-30"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return accesstoken.Recreate(ctx, c, r) + }), + } + idFlag(recreate, &r.ID, "The access token id.") + recreate.Flags().StringVar(&r.ExpiresAt, "expires-at", "", "When the new token expires, a date or an RFC 3339 time. (default: never)") + recreate.Flags().StringVarP(&r.Output, "output", "t", "", tokenTypeHelp) + recreate.Flags().BoolVar(&r.Yes, "yes", false, yesHelp) + + var d accesstoken.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete an access token. Everything using it loses access.", + Args: cobra.NoArgs, + Example: examples("steadybit access-token delete -i " + accessTokenID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return accesstoken.Delete(ctx, c, d) }), + } + idFlag(del, &d.ID, "The access token id.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, create, recreate, del) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index edc015d..eb5039c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) + root.AddCommand(newAccessToken(), newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { From 9ea1bbdc0922ce2a5a516be8bb7455d00709fd8f Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:43:00 +0200 Subject: [PATCH 10/25] feat(go): export, apply and diff a team's project `export --team X -d dir` writes a team's experiments, schedules and services, and the custom profiles they use, one kind per directory. `apply -d dir` applies them in dependency order (with --dry-run), and `diff -d dir` reports drift for all of them. Exporting a team from dev and diffing it straight back reports no differences for 107 files. --- README.md | 15 ++ internal/cli/gitops.go | 50 +++++++ internal/cli/root.go | 3 +- internal/gitops/project.go | 243 ++++++++++++++++++++++++++++++++ internal/gitops/project_test.go | 83 +++++++++++ 5 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 internal/gitops/project.go create mode 100644 internal/gitops/project_test.go diff --git a/README.md b/README.md index b5123c0..99abc73 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,21 @@ steadybit service-profile list --origin custom steadybit service-profile apply -f profile.yml ``` +## GitOps + +Keep a team's experiments, schedules, services and custom service profiles in Git: + +```bash +steadybit export --team ADM -d ./chaos # write them as files +steadybit diff -d ./chaos # what differs from the platform; exits with 2 if anything does +steadybit apply -d ./chaos --dry-run # what an apply would create or update +steadybit apply -d ./chaos # profiles, services, experiments, then schedules +``` + +Each kind also has its own `diff`, and its `apply` a `--dry-run`, e.g. +`steadybit experiment diff -f ./experiments -R`. Fields the platform fills in with defaults +are not reported as differences. + ## In CI `experiment run --wait` fails the job when a run fails, and a few options make it fit diff --git a/internal/cli/gitops.go b/internal/cli/gitops.go index 14ef037..ec52906 100644 --- a/internal/cli/gitops.go +++ b/internal/cli/gitops.go @@ -52,3 +52,53 @@ func dryRun(cmd *cobra.Command, k gitops.Kind, files *[]string, recursive *bool) })(cmd, args) } } + +func newExport() *cobra.Command { + var o gitops.ExportOptions + cmd := &cobra.Command{ + Use: "export", + Short: "Write a team's experiments, schedules, services and the custom service profiles they use to a directory, to keep in Git.", + Args: cobra.NoArgs, + Example: examples( + "steadybit export --team ADM -d ./chaos", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return gitops.Export(ctx, c, o) }), + } + cmd.Flags().StringVar(&o.Team, "team", "", "The key of the team to export.") + cmd.Flags().StringVarP(&o.Directory, "directory", "d", ".", "The directory to write the project to.") + _ = cmd.MarkFlagRequired("team") + return cmd +} + +func newApplyProject() *cobra.Command { + var o gitops.ApplyOptions + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply a project written by `export`: service profiles, then services, experiments and schedules.", + Args: cobra.NoArgs, + Example: examples( + "steadybit apply -d ./chaos --dry-run", + "steadybit apply -d ./chaos", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return gitops.ApplyProject(ctx, c, o) }), + } + cmd.Flags().StringVarP(&o.Directory, "directory", "d", ".", "The project directory.") + cmd.Flags().BoolVar(&o.DryRun, "dry-run", false, "Report what applying the project would create or update, without changing anything.") + cmd.Flags().BoolVar(&o.DeleteExperiments, "delete-experiments", false, "Delete provided experiments that changed service profiles no longer provide.") + return cmd +} + +func newDiffProject() *cobra.Command { + var dir string + cmd := &cobra.Command{ + Use: "diff", + Short: "Show how a project written by `export` differs from the platform. Exits with 2 when it does.", + Args: cobra.NoArgs, + Example: examples("steadybit diff -d ./chaos"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return gitops.DiffProject(ctx, c, dir) + }), + } + cmd.Flags().StringVarP(&dir, "directory", "d", ".", "The project directory.") + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 6b04e97..745be99 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -71,7 +71,8 @@ func newRoot() *cobra.Command { root.PersistentFlags().StringVar(&output.JQ, "jq", "", "Filter the JSON a command prints with a jq expression; strings are printed raw.") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAdvice(), newConfig(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTemplate()) + root.AddCommand(newAdvice(), newConfig(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTemplate(), + newExport(), newApplyProject(), newDiffProject()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/gitops/project.go b/internal/gitops/project.go new file mode 100644 index 0000000..e642248 --- /dev/null +++ b/internal/gitops/project.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package gitops + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/experiment" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/schedule" + "github.com/steadybit/cli/internal/service" + "github.com/steadybit/cli/internal/serviceprofile" +) + +// A project is a directory holding what a team keeps in Git, one kind per directory, +// in the order applying them has to follow: services name their profile, schedules +// their experiment. +var projectKinds = []struct { + dir string + kind Kind +}{ + {"service-profiles", ServiceProfile}, + {"services", Service}, + {"experiments", Experiment}, + {"schedules", Schedule}, +} + +var unsafe = regexp.MustCompile(`[^a-z0-9._-]+`) + +// fileName makes a name safe and readable as a file name, and unique within dir. +func fileName(dir, name string, taken map[string]bool) string { + base := strings.Trim(unsafe.ReplaceAllString(strings.ToLower(name), "-"), "-.") + if base == "" { + base = "unnamed" + } + candidate := base + for i := 2; taken[filepath.Join(dir, candidate)]; i++ { + candidate = fmt.Sprintf("%s-%d", base, i) + } + taken[filepath.Join(dir, candidate)] = true + return filepath.Join(dir, candidate+".yaml") +} + +func writeDocument(file string, doc *jsyaml.Map, readOnly []string) error { + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + return err + } + return os.WriteFile(file, []byte(jsyaml.Dump(strip(doc, readOnly))), 0o644) +} + +type ExportOptions struct { + Directory string + Team string +} + +// Export writes a team's experiments, schedules and services, and the custom service +// profiles those services use, as a project. Files are only written, never removed, so +// something deleted on the platform keeps its file until it is removed by hand. +func Export(ctx context.Context, c *platform.Client, o ExportOptions) error { + taken := map[string]bool{} + counts := map[string]int{} + team := []string{o.Team} + + var experiments struct { + Experiments []struct { + Key string `json:"key"` + } `json:"experiments"` + } + resp, err := c.GetExperiments(ctx, &api.GetExperimentsParams{Team: &team}) + if _, err := platform.Decode(resp, err, &experiments); err != nil { + return platform.Failed(err, "Failed to get the experiments of team %s", o.Team) + } + for _, e := range experiments.Experiments { + doc, _, err := platform.ReadDocument(c.GetExperiment(ctx, e.Key)) + if err != nil { + return platform.Failed(err, "Failed to get experiment %s", e.Key) + } + if err := writeDocument(fileName(filepath.Join(o.Directory, "experiments"), e.Key, taken), doc.Value(), Experiment.ReadOnly); err != nil { + return err + } + counts["experiments"]++ + } + + var schedules []json.RawMessage + resp, err = c.GetAllSchedulesV2(ctx, &api.GetAllSchedulesV2Params{Team: &team}) + if _, err := platform.Decode(resp, err, &schedules); err != nil { + return platform.Failed(err, "Failed to get the experiment schedules of team %s", o.Team) + } + for _, raw := range schedules { + doc, err := output.ParseDocument(raw) + if err != nil { + return err + } + key, id := str(doc.Value(), "experimentKey"), str(doc.Value(), "id") + if len(id) > 8 { + id = id[len(id)-8:] + } + if err := writeDocument(fileName(filepath.Join(o.Directory, "schedules"), key+"-"+id, taken), doc.Value(), Schedule.ReadOnly); err != nil { + return err + } + counts["schedules"]++ + } + + type summary struct { + ID string `json:"id"` + } + services, err := platform.AllPages[summary](func(page, size int32) (*http.Response, error) { + return c.GetServiceList(ctx, &api.GetServiceListParams{TeamKey: &team, Page: api.PageRequestAO{Page: &page, Size: &size}}) + }) + if err != nil { + return platform.Failed(err, "Failed to get the services of team %s", o.Team) + } + profiles := map[string]bool{} + for _, s := range services { + u, _ := uuid(s.ID) + doc, _, err := platform.ReadDocument(c.GetService(ctx, u)) + if err != nil { + return platform.Failed(err, "Failed to get service %s", s.ID) + } + profiles[str(doc.Value(), "serviceProfile")] = true + if err := writeDocument(fileName(filepath.Join(o.Directory, "services"), str(doc.Value(), "name"), taken), doc.Value(), Service.ReadOnly); err != nil { + return err + } + counts["services"]++ + } + + // Profiles are shared by all teams; only the custom ones this team's services use + // belong to its project. Steadybit's own come with the platform. + for name := range profiles { + if name == "" { + continue + } + _, profile, err := ServiceProfile.Remote(ctx, c, mapWith("name", name)) + if err != nil { + return platform.Failed(err, "Failed to get service profile %s", name) + } + if profile == nil || str(profile, "origin") != "CUSTOM" { + continue + } + if err := writeDocument(fileName(filepath.Join(o.Directory, "service-profiles"), name, taken), profile, ServiceProfile.ReadOnly); err != nil { + return err + } + counts["service-profiles"]++ + } + + fmt.Printf("Exported team %s to %s: %d experiments, %d schedules, %d services, %d service profiles.\n", + o.Team, o.Directory, counts["experiments"], counts["schedules"], counts["services"], counts["service-profiles"]) + return nil +} + +func mapWith(key, value string) *jsyaml.Map { + m := jsyaml.NewMap() + m.Set(key, value) + return m +} + +// projectDirs are the kinds a project directory holds, in the order to apply them. +func projectDirs(dir string) (map[string]string, error) { + found := map[string]string{} + for _, pk := range projectKinds { + path := filepath.Join(dir, pk.dir) + if info, err := os.Stat(path); err == nil && info.IsDir() { + found[pk.dir] = path + } + } + if len(found) == 0 { + return nil, fmt.Errorf("'%s' holds none of experiments/, schedules/, services/ or service-profiles/.", dir) + } + return found, nil +} + +type ApplyOptions struct { + Directory string + DeleteExperiments bool + DryRun bool +} + +// ApplyProject applies every kind in a project, profiles first and schedules last. +func ApplyProject(ctx context.Context, c *platform.Client, o ApplyOptions) error { + dirs, err := projectDirs(o.Directory) + if err != nil { + return err + } + for _, pk := range projectKinds { + path, ok := dirs[pk.dir] + if !ok { + continue + } + if o.DryRun { + err = DryRun(ctx, c, pk.kind, []string{path}, true) + } else { + switch pk.dir { + case "service-profiles": + err = serviceprofile.Apply(ctx, c, serviceprofile.ApplyOptions{Files: []string{path}, Recursive: true, DeleteExperiments: o.DeleteExperiments}) + case "services": + err = service.Apply(ctx, c, service.ApplyOptions{Files: []string{path}, Recursive: true, DeleteExperiments: o.DeleteExperiments}) + case "experiments": + err = experiment.Apply(ctx, c, experiment.ApplyOptions{Files: []string{path}, Recursive: true}) + case "schedules": + err = schedule.Apply(ctx, c, schedule.ApplyOptions{Files: []string{path}, Recursive: true}) + } + } + if err != nil { + return err + } + } + return nil +} + +// DiffProject diffs every kind in a project, and reports drift once all were compared. +func DiffProject(ctx context.Context, c *platform.Client, dir string) error { + dirs, err := projectDirs(dir) + if err != nil { + return err + } + drift := false + for _, pk := range projectKinds { + if path, ok := dirs[pk.dir]; ok { + err := DiffFiles(ctx, c, pk.kind, []string{path}, true) + if errors.Is(err, ErrDifferent) { + drift = true + } else if err != nil { + return err + } + } + } + if drift { + return ErrDifferent + } + return nil +} diff --git a/internal/gitops/project_test.go b/internal/gitops/project_test.go new file mode 100644 index 0000000..50544f7 --- /dev/null +++ b/internal/gitops/project_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package gitops_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/steadybit/cli/internal/gitops" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + serviceID = "019cd80d-a4c9-775b-bdf8-2672a280ce7c" + profileID = "019eacd7-fb2c-733a-bed5-99a935323db5" +) + +func fakeProject(t *testing.T) *platformtest.Platform { + p := platformtest.New(t) + p.Reply("GET /api/experiments", platformtest.Reply{JSON: map[string]any{"experiments": []any{map[string]any{"key": "ADM-1"}}}}) + p.Reply("GET /api/experiments/ADM-1", platformtest.Reply{Body: `{"key":"ADM-1","version":2,"name":"Survives","team":"ADM","created":"c","lanes":[]}`}) + p.Reply("GET /api/experiments/schedules/v2", platformtest.Reply{Body: `[{"id":"01951394-727f-76a0-8675-c7519ebd0ff5","experimentKey":"ADM-1","cron":"0 0 9 ? * *","editedBy":{},"lastUpdated":"x"}]`}) + p.Reply("GET /api/experiments/schedules/*", platformtest.Reply{Body: `{"id":"01951394-727f-76a0-8675-c7519ebd0ff5","experimentKey":"ADM-1","cron":"0 0 9 ? * *","editedBy":{},"lastUpdated":"x"}`}) + p.Reply("GET /api/services", platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"id": serviceID}}}}) + p.Reply("GET /api/services/"+serviceID, platformtest.Reply{Body: `{"id":"` + serviceID + `","name":"Checkout / API","team":"ADM","serviceProfile":"Shop","version":1}`}) + p.Reply("GET /api/services/profiles", platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"id": profileID, "name": "Shop"}}}}) + p.Reply("GET /api/services/profiles/"+profileID, platformtest.Reply{Body: `{"id":"` + profileID + `","name":"Shop","origin":"CUSTOM","templates":[],"defaultProfile":false}`}) + return p +} + +func TestExportWritesAProjectThatMatchesThePlatform(t *testing.T) { + p := fakeProject(t) + dir := t.TempDir() + + out, err := platformtest.Stdout(t, func() error { return gitops.Export(ctx, p.Client, gitops.ExportOptions{Directory: dir, Team: "ADM"}) }) + + require.NoError(t, err) + assert.Equal(t, "Exported team ADM to "+dir+": 1 experiments, 1 schedules, 1 services, 1 service profiles.\n", out) + for file, content := range map[string]string{ + "experiments/adm-1.yaml": "key: ADM-1\nname: Survives\nteam: ADM\nlanes: []\n", + "schedules/adm-1-9ebd0ff5.yaml": "id: 01951394-727f-76a0-8675-c7519ebd0ff5\nexperimentKey: ADM-1\ncron: 0 0 9 ? * *\n", + "services/checkout-api.yaml": "id: " + serviceID + "\nname: Checkout / API\nteam: ADM\nserviceProfile: Shop\n", + "service-profiles/shop.yaml": "id: " + profileID + "\nname: Shop\norigin: CUSTOM\ntemplates: []\n", + } { + written, err := os.ReadFile(filepath.Join(dir, file)) + require.NoError(t, err, file) + assert.Equal(t, content, string(written), file) + } + + out, err = platformtest.Stdout(t, func() error { return gitops.DiffProject(ctx, p.Client, dir) }) + require.NoError(t, err) + assert.Equal(t, 4, strings.Count(out, "match the platform")) +} + +func TestAProjectEditedLocallyDrifts(t *testing.T) { + p := fakeProject(t) + dir := t.TempDir() + _, err := platformtest.Stdout(t, func() error { return gitops.Export(ctx, p.Client, gitops.ExportOptions{Directory: dir, Team: "ADM"}) }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "experiments", "adm-1.yaml"), []byte("key: ADM-1\nname: Renamed\nteam: ADM\nlanes: []\n"), 0o644)) + + out, err := platformtest.Stdout(t, func() error { return gitops.DiffProject(ctx, p.Client, dir) }) + assert.ErrorIs(t, err, gitops.ErrDifferent) + assert.Contains(t, out, "-name: Survives\n+name: Renamed\n") + + out, err = platformtest.Stdout(t, func() error { + return gitops.ApplyProject(ctx, p.Client, gitops.ApplyOptions{Directory: dir, DryRun: true}) + }) + require.NoError(t, err) + assert.Contains(t, out, "would update experiment ADM-1 (2 lines changed).") + assert.Empty(t, p.Requests("POST /api/experiments/ADM-1"), "a dry run changes nothing") +} + +func TestApplyNeedsAProject(t *testing.T) { + err := gitops.ApplyProject(ctx, nil, gitops.ApplyOptions{Directory: t.TempDir()}) + + assert.ErrorContains(t, err, "holds none of experiments/, schedules/, services/ or service-profiles/.") +} From a4494d19d951630c0c4880ae3878f6305f3fc947 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:44:38 +0200 Subject: [PATCH 11/25] feat(go): --profile on every command; completion from the platform --profile uses a configured profile for one command. Shell completion now offers experiment keys (team first, then its experiments), team keys, and template, schedule, service and profile ids with their names; without access it offers nothing and prints nothing. --- internal/cli/complete.go | 209 +++++++++++++++++++++++++++++++++ internal/cli/complete_test.go | 60 ++++++++++ internal/cli/root.go | 3 + internal/config/config.go | 24 +++- internal/config/config_test.go | 18 +++ 5 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 internal/cli/complete.go create mode 100644 internal/cli/complete_test.go diff --git a/internal/cli/complete.go b/internal/cli/complete.go new file mode 100644 index 0000000..6695e18 --- /dev/null +++ b/internal/cli/complete.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/config" + "github.com/steadybit/cli/internal/platform" +) + +// Shell completion offers what exists on the platform: experiment keys, team keys and +// the ids of templates, schedules, services and profiles, each with its name. It must +// never print an error or keep the shell waiting, so any failure offers nothing. + +type completer func(ctx context.Context, c *platform.Client, toComplete string) ([]string, cobra.ShellCompDirective) + +func remote(complete completer) cobra.CompletionFunc { + return func(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + c, err := platform.New() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + values, directive := complete(ctx, c, toComplete) + return values, directive | cobra.ShellCompDirectiveNoFileComp + } +} + +func matching(values []string, prefix string) []string { + var out []string + for _, v := range values { + if strings.HasPrefix(strings.ToLower(v), strings.ToLower(prefix)) { + out = append(out, v) + } + } + return out +} + +func completeTeams(ctx context.Context, c *platform.Client, prefix string) ([]string, cobra.ShellCompDirective) { + var teams struct { + Teams []struct{ Key, Name string } `json:"teams"` + } + all := false + resp, err := c.GetTeams(ctx, &api.GetTeamsParams{OnlyAccessible: &all}) + if _, err := platform.Decode(resp, err, &teams); err != nil { + return nil, cobra.ShellCompDirectiveError + } + var values []string + for _, t := range teams.Teams { + values = append(values, t.Key+"\t"+t.Name) + } + return matching(values, prefix), cobra.ShellCompDirectiveDefault +} + +// Keys are TEAM-number, so the team is completed first and then only that team's +// experiments are listed: a tenant can hold thousands. +func completeExperimentKeys(ctx context.Context, c *platform.Client, prefix string) ([]string, cobra.ShellCompDirective) { + team, _, hasTeam := strings.Cut(prefix, "-") + if !hasTeam { + teams, directive := completeTeams(ctx, c, prefix) + for i, t := range teams { + key, name, _ := strings.Cut(t, "\t") + teams[i] = key + "-\t" + name + } + return teams, directive | cobra.ShellCompDirectiveNoSpace + } + var list struct { + Experiments []struct{ Key, Name string } `json:"experiments"` + } + teams := []string{strings.ToUpper(team)} + resp, err := c.GetExperiments(ctx, &api.GetExperimentsParams{Team: &teams}) + if _, err := platform.Decode(resp, err, &list); err != nil { + return nil, cobra.ShellCompDirectiveError + } + var values []string + for _, e := range list.Experiments { + values = append(values, e.Key+"\t"+e.Name) + } + return matching(values, prefix), cobra.ShellCompDirectiveDefault +} + +func completeTemplates(ctx context.Context, c *platform.Client, prefix string) ([]string, cobra.ShellCompDirective) { + var list struct { + Templates []struct { + ID string `json:"id"` + Title string `json:"templateTitle"` + } `json:"templates"` + } + resp, err := c.GetExperimentTemplates(ctx, nil) + if _, err := platform.Decode(resp, err, &list); err != nil { + return nil, cobra.ShellCompDirectiveError + } + var values []string + for _, t := range list.Templates { + values = append(values, t.ID+"\t"+t.Title) + } + return matching(values, prefix), cobra.ShellCompDirectiveDefault +} + +func completeSchedules(ctx context.Context, c *platform.Client, prefix string) ([]string, cobra.ShellCompDirective) { + var schedules []struct { + ID string `json:"id"` + ExperimentKey string `json:"experimentKey"` + Cron *string `json:"cron"` + StartAt *string `json:"startAt"` + } + resp, err := c.GetAllSchedulesV2(ctx, nil) + if _, err := platform.Decode(resp, err, &schedules); err != nil { + return nil, cobra.ShellCompDirectiveError + } + var values []string + for _, s := range schedules { + when := "" + if s.Cron != nil { + when = *s.Cron + } else if s.StartAt != nil { + when = *s.StartAt + } + values = append(values, fmt.Sprintf("%s\t%s %s", s.ID, s.ExperimentKey, when)) + } + return matching(values, prefix), cobra.ShellCompDirectiveDefault +} + +func completePaged(fetch func(ctx context.Context, c *platform.Client, page, size int32) (*http.Response, error)) completer { + return func(ctx context.Context, c *platform.Client, prefix string) ([]string, cobra.ShellCompDirective) { + items, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { return fetch(ctx, c, page, size) }) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + var values []string + for _, raw := range items { + var item struct{ ID, Name string } + if json.Unmarshal(raw, &item) == nil { + values = append(values, item.ID+"\t"+item.Name) + } + } + return matching(values, prefix), cobra.ShellCompDirectiveDefault + } +} + +var completeServices = completePaged(func(ctx context.Context, c *platform.Client, page, size int32) (*http.Response, error) { + return c.GetServiceList(ctx, &api.GetServiceListParams{Page: api.PageRequestAO{Page: &page, Size: &size}}) +}) + +var completeProfiles = completePaged(func(ctx context.Context, c *platform.Client, page, size int32) (*http.Response, error) { + return c.GetProfiles(ctx, &api.GetProfilesParams{Page: api.PageRequestAO{Page: &page, Size: &size}}) +}) + +// Profile names come from the local configuration and need no platform. +func completeProfileNames(*cobra.Command, []string, string) ([]cobra.Completion, cobra.ShellCompDirective) { + profiles, err := config.Profiles() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var names []string + for _, p := range profiles { + names = append(names, p.Name+"\t"+p.BaseURL) + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +// registerCompletions attaches completion to flags by what they name, across all +// commands, so a new command gets it without doing anything. +func registerCompletions(root *cobra.Command) { + _ = root.RegisterFlagCompletionFunc("profile", completeProfileNames) + var walk func(cmd *cobra.Command) + walk = func(cmd *cobra.Command) { + path := cmd.CommandPath() + group := "" + if parts := strings.Fields(path); len(parts) > 1 { + group = parts[1] + } + register := func(flag string, complete completer) { + if cmd.Flags().Lookup(flag) != nil { + _ = cmd.RegisterFlagCompletionFunc(flag, remote(complete)) + } + } + register("team", completeTeams) + register("template", completeTemplates) + switch group { + case "experiment": + register("key", completeExperimentKeys) + case "schedule": + register("id", completeSchedules) + register("experiment", completeExperimentKeys) + case "service": + register("id", completeServices) + register("experiment", completeExperimentKeys) + case "service-profile": + register("id", completeProfiles) + case "template": + register("id", completeTemplates) + } + for _, sub := range cmd.Commands() { + walk(sub) + } + } + walk(root) +} diff --git a/internal/cli/complete_test.go b/internal/cli/complete_test.go new file mode 100644 index 0000000..269fa71 --- /dev/null +++ b/internal/cli/complete_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompletesTeamsBeforeExperimentKeys(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams", platformtest.Reply{JSON: map[string]any{"teams": []any{map[string]any{"key": "ADM", "name": "Admins"}, map[string]any{"key": "SHOP", "name": "Shop"}}}}) + p.Reply("GET /api/experiments", platformtest.Reply{JSON: map[string]any{"experiments": []any{ + map[string]any{"key": "ADM-1", "name": "One"}, map[string]any{"key": "ADM-12", "name": "Twelve"}, map[string]any{"key": "ADM-2", "name": "Two"}, + }}}) + + teams, directive := completeExperimentKeys(context.Background(), p.Client, "a") + assert.Equal(t, []string{"ADM-\tAdmins"}, teams) + assert.NotZero(t, directive&cobra.ShellCompDirectiveNoSpace) + + keys, _ := completeExperimentKeys(context.Background(), p.Client, "ADM-1") + assert.Equal(t, []string{"ADM-1\tOne", "ADM-12\tTwelve"}, keys) + assert.Equal(t, []string{"ADM"}, p.Requests("GET /api/experiments")[0].Query["team"]) +} + +// Every flag that names something on the platform completes it. +func TestIdFlagsComplete(t *testing.T) { + root := newRoot() + for _, path := range [][]string{ + {"experiment", "get", "--key"}, {"experiment", "run", "--template"}, {"schedule", "delete", "--id"}, + {"service", "risk", "--id"}, {"service-profile", "get", "--id"}, {"template", "get", "--id"}, + {"export", "--team"}, {"schedule", "create", "--experiment"}, + } { + cmd, _, err := root.Find(path[:len(path)-1]) + require.NoError(t, err, strings.Join(path, " ")) + _, ok := cmd.GetFlagCompletionFunc(strings.TrimPrefix(path[len(path)-1], "--")) + assert.True(t, ok, strings.Join(path, " ")) + } +} + +func TestCompletionStaysQuietWithoutAccess(t *testing.T) { + platformtest.Home(t) + t.Setenv("STEADYBIT_TOKEN", "") + root := newRoot() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"__complete", "template", "get", "-i", ""}) + + require.NoError(t, root.Execute()) + assert.Equal(t, ":4\n", out.String()) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 745be99..bb6dad8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/config" "github.com/steadybit/cli/internal/experiment" "github.com/steadybit/cli/internal/gitops" "github.com/steadybit/cli/internal/output" @@ -68,6 +69,7 @@ func newRoot() *cobra.Command { }, } root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") + root.PersistentFlags().StringVar(&config.ProfileOverride, "profile", "", "Use this configuration profile instead of the selected one.") root.PersistentFlags().StringVar(&output.JQ, "jq", "", "Filter the JSON a command prints with a jq expression; strings are printed raw.") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") @@ -92,6 +94,7 @@ func newRoot() *cobra.Command { } } } + registerCompletions(root) for _, cmd := range append(root.Commands(), root) { setUsage(cmd) } diff --git a/internal/config/config.go b/internal/config/config.go index d9fd267..0d4b4e7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -121,12 +121,32 @@ func activeProfileName() (string, error) { return strings.TrimSpace(string(content)), nil } -// ActiveProfile is the selected profile, or the first one when none is selected. +// ProfileOverride is the --profile flag: the profile to use instead of the active one, +// for this command only. +var ProfileOverride string + +// ActiveProfile is the profile --profile names, else the selected one, else the first. func ActiveProfile() (*Profile, error) { profiles, err := Profiles() - if err != nil || len(profiles) == 0 { + if err != nil { return nil, err } + if ProfileOverride != "" { + names := make([]string, 0, len(profiles)) + for i := range profiles { + if profiles[i].Name == ProfileOverride { + return &profiles[i], nil + } + names = append(names, profiles[i].Name) + } + if len(names) == 0 { + return nil, fmt.Errorf("No profile named %s: none are configured. Add one with `steadybit config profile add`.", ProfileOverride) + } + return nil, fmt.Errorf("No profile named %s. Available: %s", ProfileOverride, strings.Join(names, ", ")) + } + if len(profiles) == 0 { + return nil, nil + } name, err := activeProfileName() if err != nil { return nil, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f7ab854..e44b4a6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -62,3 +62,21 @@ func setHome(t *testing.T) { t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) } + +func TestProfileOverridePicksANamedProfile(t *testing.T) { + setHome(t) + require.NoError(t, AddProfile(Profile{Name: "prod", APIAccessToken: "p"})) + require.NoError(t, AddProfile(Profile{Name: "dev", APIAccessToken: "d", BaseURL: "https://dev"})) + os.Unsetenv("STEADYBIT_TOKEN") + os.Unsetenv("STEADYBIT_URL") + ProfileOverride = "dev" + t.Cleanup(func() { ProfileOverride = "" }) + + cfg, err := Load() + + require.NoError(t, err) + assert.Equal(t, Configuration{APIAccessToken: "d", BaseURL: "https://dev"}, cfg) + ProfileOverride = "stage" + _, err = Load() + assert.EqualError(t, err, "No profile named stage. Available: prod, dev") +} From 22d07722637e0360ca75f4f7f52905ac0a4a0af3 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:44:45 +0200 Subject: [PATCH 12/25] feat(go): webhook, Slack and preflight integrations --- CHANGELOG.md | 3 + internal/cli/integration.go | 81 +++++++++ internal/cli/root.go | 2 +- internal/integration/integration.go | 211 +++++++++++++++++++++++ internal/integration/integration_test.go | 107 ++++++++++++ 5 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 internal/cli/integration.go create mode 100644 internal/integration/integration.go create mode 100644 internal/integration/integration_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0490e03..f817ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ - `user invite` invites users by email, optionally into a team. - `access-token` commands to `list`, `create`, `recreate` and `delete` API access tokens. A new token is printed once; `-t json` prints only its id and value for a script to read. +- `integration webhook|slack|preflight|preflight-action` commands to `list`, `get`, `apply` + and `delete` integrations. The platform masks secrets when reading them, so `apply` + refuses a file still holding the mask instead of removing the secret. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/cli/integration.go b/internal/cli/integration.go new file mode 100644 index 0000000..70e2397 --- /dev/null +++ b/internal/cli/integration.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/integration" + "github.com/steadybit/cli/internal/platform" +) + +const integrationID = "0190d7b2-7d3e-7a4b-8c5d-6e7f8a9b0c1d" + +func newIntegration() *cobra.Command { + cmd := &cobra.Command{Use: "integration", Short: "Manage webhook, Slack and preflight integrations. Changing them needs an admin access token."} + for _, kind := range integration.Kinds { + cmd.AddCommand(newIntegrationKind(kind)) + } + return cmd +} + +func newIntegrationKind(k integration.Kind) *cobra.Command { + plural := k.Plural + // Slack keeps its capital in the middle of a sentence. + lower := k.Title + if k.Name != "slack" { + lower = strings.ToLower(k.Title[:1]) + k.Title[1:] + } + prefix := "steadybit integration " + k.Name + cmd := &cobra.Command{Use: k.Name, Short: "Manage " + plural + "."} + + list := &cobra.Command{ + Use: "list", + Short: "List " + plural + ".", + Args: cobra.NoArgs, + Example: examples(prefix + " list"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return integration.List(ctx, c, k) }), + } + + var g integration.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a " + lower + ". Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples(prefix + " get -i " + integrationID + " -f " + k.Name + ".yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return integration.Get(ctx, c, k, g) }), + } + idFlag(get, &g.ID, "The "+lower+" id.") + outputFlags(get, &g.File, &g.Type, lower) + + var a integration.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update " + plural + " from files. A file without an id creates one, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples(prefix+" apply -f "+k.Name+".yml", prefix+" apply -f ./integrations -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return integration.Apply(ctx, c, k, a) + }), + } + fileFlags(apply, &a.Files, &a.Recursive, lower) + + var d integration.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a " + lower + ".", + Args: cobra.NoArgs, + Example: examples(prefix + " delete -i " + integrationID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return integration.Delete(ctx, c, k, d) + }), + } + idFlag(del, &d.ID, "The "+lower+" id.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, get, apply, del) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index eb5039c..e151035 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAccessToken(), newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) + root.AddCommand(newAccessToken(), newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/integration/integration.go b/internal/integration/integration.go new file mode 100644 index 0000000..adb5a7c --- /dev/null +++ b/internal/integration/integration.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package integration implements the `integration` commands. The four kinds of integration +// have the same endpoints, so one implementation serves them all. +package integration + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// The version is dropped as `service get` drops it: kept in a file, it turns every apply +// after an edit in the UI into a conflict. +var readOnly = []string{"version"} + +type Kind struct { + Name string // as the command names it + Title string // as messages name it + Plural string + // The column that says where the integration reports to. + Column, ColumnTitle string + + list func(ctx context.Context, c *platform.Client) (*http.Response, error) + get func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) + upsert func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) + delete func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) +} + +var ( + Webhook = Kind{ + Name: "webhook", Title: "Webhook integration", Plural: "webhook integrations", Column: "url", ColumnTitle: "URL", + list: func(ctx context.Context, c *platform.Client) (*http.Response, error) { return c.GetCustomWebhooks(ctx) }, + get: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.GetCustomWebhook(ctx, id) + }, + upsert: func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) { + return c.UpsertCustomWebhookWithBody(ctx, "application/json", body) + }, + delete: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.DeleteCustomWebhook(ctx, id) + }, + } + Slack = Kind{ + Name: "slack", Title: "Slack integration", Plural: "Slack integrations", Column: "channel", ColumnTitle: "Channel", + list: func(ctx context.Context, c *platform.Client) (*http.Response, error) { + return c.GetSlackIntegrations(ctx) + }, + get: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.GetSlackIntegration(ctx, id) + }, + upsert: func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) { + return c.UpsertSlackIntegrationWithBody(ctx, "application/json", body) + }, + delete: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.DeleteSlackIntegration(ctx, id) + }, + } + Preflight = Kind{ + Name: "preflight", Title: "Preflight webhook", Plural: "preflight webhooks", Column: "url", ColumnTitle: "URL", + list: func(ctx context.Context, c *platform.Client) (*http.Response, error) { + return c.GetPreflightWebhooks(ctx) + }, + get: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.GetPreflightWebhook(ctx, id) + }, + upsert: func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) { + return c.UpsertPreflightWebhookWithBody(ctx, "application/json", body) + }, + delete: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.DeletePreflightWebhook(ctx, id) + }, + } + PreflightAction = Kind{ + Name: "preflight-action", Title: "Preflight action integration", Plural: "preflight action integrations", Column: "preflightActionId", ColumnTitle: "Preflight action", + list: func(ctx context.Context, c *platform.Client) (*http.Response, error) { + return c.GetPreflightActionIntegrations(ctx) + }, + get: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.GetPreflightActionIntegration(ctx, id) + }, + upsert: func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) { + return c.UpsertPreflightActionIntegrationWithBody(ctx, "application/json", body) + }, + delete: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.DeletePreflightActionIntegration(ctx, id) + }, + } + Kinds = []Kind{Webhook, Slack, Preflight, PreflightAction} +) + +func (k Kind) uuid(id string) (openapi_types.UUID, error) { + u, ok := resource.UUID(id) + if !ok { + return u, fmt.Errorf("%s %s not found.", k.Title, id) + } + return u, nil +} + +func (k Kind) notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("%s %s not found.", k.Title, id) + } + return platform.Failed(err, format, k.Title, id) +} + +func List(ctx context.Context, c *platform.Client, k Kind) error { + var result struct { + Content []map[string]any `json:"content"` + } + resp, err := k.list(ctx, c) + if _, err := platform.Decode(resp, err, &result); err != nil { + return platform.Failed(err, "Failed to get the %s", k.Plural) + } + if len(result.Content) == 0 { + fmt.Printf("No %s found.\n", k.Plural) + return nil + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "scope", Title: "Scope", Alignment: table.Left}, + table.Column{Name: "team", Title: "Team", Alignment: table.Left}, + table.Column{Name: k.Column, Title: k.ColumnTitle, Alignment: table.Left}, + ) + for _, i := range result.Content { + t.AddRow(table.Default, table.Cell("id", i["id"]), table.Cell("name", i["name"]), table.Cell("scope", i["scope"]), + table.Cell("team", i["team"]), table.Cell(k.Column, i[k.Column])) + } + t.Print() + return nil +} + +type GetOptions struct { + ID, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, k Kind, o GetOptions) error { + id, err := k.uuid(o.ID) + if err != nil { + return err + } + doc, _, err := platform.ReadDocument(k.get(ctx, c, id)) + if err != nil { + return k.notFoundOr(err, o.ID, "Failed to get %s %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("%s %s written to %s.\n", k.Title, o.ID, o.File) + } + return nil +} + +type ApplyOptions struct { + Files []string + Recursive bool +} + +func Apply(ctx context.Context, c *platform.Client, k Kind, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, k.Name+" integration", func(file string, doc *output.Document) (resource.Applied, error) { + name, _ := doc.Get("name") + if name == "" { + return resource.Applied{}, fmt.Errorf("%s file '%s' does not name the integration.", k.Title, file) + } + // The platform masks secrets when reading them back and rejects the mask, while + // leaving the secret out removes it. Neither is what a file from `get` means. + if secret, _ := doc.Get("secret"); secret != "" && strings.Trim(secret, "*") == "" { + return resource.Applied{}, fmt.Errorf("%s file '%s' holds the masked secret `get` writes. Put the secret in, or remove it for none.", k.Title, file) + } + var saved struct{ ID, Name string } + resp, err := k.upsert(ctx, c, resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save %s %s", k.Title, name) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("%s %s (%s) %s.\n", k.Title, saved.Name, saved.ID, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteOptions struct { + ID string + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, k Kind, o DeleteOptions) error { + id, err := k.uuid(o.ID) + if err != nil { + return err + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Delete %s %s?", k.Title, o.ID)); !ok || err != nil { + return err + } + if _, _, err := platform.Read(k.delete(ctx, c, id)); err != nil { + return k.notFoundOr(err, o.ID, "Failed to delete %s %s") + } + fmt.Printf("%s %s deleted.\n", k.Title, o.ID) + return nil +} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go new file mode 100644 index 0000000..3bc09ae --- /dev/null +++ b/internal/integration/integration_test.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package integration_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/integration" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "0190d7b2-7d3e-7a4b-8c5d-6e7f8a9b0c1d" + +func TestEveryKindListsFromItsOwnEndpoint(t *testing.T) { + paths := map[string]string{ + "webhook": "/api/integrations/webhook", "slack": "/api/integrations/slack", + "preflight": "/api/integrations/preflight", "preflight-action": "/api/integrations/preflight-action", + } + for _, k := range integration.Kinds { + t.Run(k.Name, func(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET "+paths[k.Name], platformtest.Reply{JSON: map[string]any{"content": []any{ + map[string]any{"id": id, "name": "Notify", "scope": "TEAM", "team": "ADM", k.Column: "where"}, + }}}) + + out, err := platformtest.Stdout(t, func() error { return integration.List(ctx, p.Client, k) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ Notify │ TEAM │ ADM │ where") + }) + } +} + +func TestListSaysWhenThereIsNone(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/integrations/slack", platformtest.Reply{JSON: map[string]any{"content": []any{}}}) + + out, err := platformtest.Stdout(t, func() error { return integration.List(ctx, p.Client, integration.Slack) }) + + require.NoError(t, err) + assert.Equal(t, "No Slack integrations found.\n", out) +} + +func TestGetAndApplyRoundTrip(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/integrations/preflight-action/"+id, platformtest.Reply{Body: `{"id":"` + id + `","version":3,"scope":"GLOBAL","name":"Gate","preflightActionId":"com.example.gate"}`}) + p.Reply("POST /api/integrations/preflight-action", platformtest.Reply{JSON: map[string]any{"id": id, "name": "Gate"}}) + file := filepath.Join(t.TempDir(), "gate.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := integration.Get(ctx, p.Client, integration.PreflightAction, integration.GetOptions{ID: id, File: file}); err != nil { + return err + } + return integration.Apply(ctx, p.Client, integration.PreflightAction, integration.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Equal(t, "Preflight action integration "+id+" written to "+file+".\nPreflight action integration Gate ("+id+") updated.\n", out) + assert.Equal(t, map[string]any{"id": id, "scope": "GLOBAL", "name": "Gate", "preflightActionId": "com.example.gate"}, + p.Requests("POST /api/integrations/preflight-action")[0].JSON(t)) +} + +func TestApplyCreatesAndRefusesAMaskedSecret(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/integrations/webhook", platformtest.Reply{Status: http.StatusCreated, JSON: map[string]any{"id": id, "name": "Notify"}}) + dir := t.TempDir() + file := filepath.Join(dir, "webhook.yml") + require.NoError(t, os.WriteFile(file, []byte("name: Notify\nscope: GLOBAL\nurl: https://example.com\nsecret: s3cret\n"), 0o644)) + masked := filepath.Join(dir, "masked.yml") + require.NoError(t, os.WriteFile(masked, []byte("id: "+id+"\nname: Notify\nsecret: '******'\n"), 0o644)) + + out, err := platformtest.Stdout(t, func() error { + return integration.Apply(ctx, p.Client, integration.Webhook, integration.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Equal(t, "Webhook integration Notify ("+id+") created.\n", out) + content, _ := os.ReadFile(file) + assert.Equal(t, "id: "+id+"\nname: Notify\nscope: GLOBAL\nurl: https://example.com\nsecret: s3cret\n", string(content)) + err = integration.Apply(ctx, p.Client, integration.Webhook, integration.ApplyOptions{Files: []string{masked}}) + assert.EqualError(t, err, "Webhook integration file '"+masked+"' holds the masked secret `get` writes. Put the secret in, or remove it for none.") + assert.Len(t, p.Requests("POST /api/integrations/webhook"), 1) +} + +func TestDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/integrations/slack/"+id, platformtest.Reply{JSON: map[string]any{"id": id}}) + p.Reply("DELETE /api/integrations/preflight/"+id, platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + return integration.Delete(ctx, p.Client, integration.Slack, integration.DeleteOptions{ID: id, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Slack integration "+id+" deleted.\n", out) + assert.EqualError(t, integration.Delete(ctx, p.Client, integration.Preflight, integration.DeleteOptions{ID: id, Yes: true}), "Preflight webhook "+id+" not found.") + assert.EqualError(t, integration.Delete(ctx, p.Client, integration.Preflight, integration.DeleteOptions{ID: "x", Yes: true}), "Preflight webhook x not found.") +} From 57ada045a1040db6bffd57eed2476fe68a315bbf Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:46:38 +0200 Subject: [PATCH 13/25] feat(go): reports --- CHANGELOG.md | 4 + internal/cli/report.go | 64 +++++++++ internal/cli/root.go | 2 +- internal/report/report.go | 245 +++++++++++++++++++++++++++++++++ internal/report/report_test.go | 113 +++++++++++++++ 5 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 internal/cli/report.go create mode 100644 internal/report/report.go create mode 100644 internal/report/report_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f817ee4..7797fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ - `integration webhook|slack|preflight|preflight-action` commands to `list`, `get`, `apply` and `delete` integrations. The platform masks secrets when reading them, so `apply` refuses a file still holding the mask instead of removing the secret. +- `report` commands print the platform's reports as JSON or YAML: `users`, `teams`, + `environments`, `experiments-executed`, `experiments-created`, `services-distribution`, + `services-by-category` and `services-average`. Teams are filtered by key and environments + by name. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/cli/report.go b/internal/cli/report.go new file mode 100644 index 0000000..b423e6d --- /dev/null +++ b/internal/cli/report.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/report" +) + +func newReport() *cobra.Command { + cmd := &cobra.Command{Use: "report", Short: "Print reports on the tenant as time series. Output is written to file or stdout."} + for _, kind := range report.Kinds { + cmd.AddCommand(newReportKind(kind)) + } + return cmd +} + +func newReportKind(k report.Kind) *cobra.Command { + var o report.Options + example := []string{"steadybit report " + k.Name + " --from 2026-01-01 --to 2026-06-30 --rollup MONTHLY"} + cmd := &cobra.Command{ + Use: k.Name, + Short: k.Short, + Args: cobra.NoArgs, + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return report.Run(ctx, c, k, o) }), + } + flags := cmd.Flags() + flags.StringVar(&o.From, "from", "", "The first day, e.g. 2026-09-01. (default: 30 days before --to)") + flags.StringVar(&o.To, "to", "", "The last day, e.g. 2026-09-30. (default: today)") + flags.StringVar(&o.Rollup, "rollup", "", `Sum up per "DAILY" or "MONTHLY" bucket.`) + outputFlags(cmd, &o.File, &o.Type, "report") + if len(k.GroupBy) > 0 { + flags.StringVar(&o.GroupBy, "group-by", "", "Split the series by one of "+strings.Join(k.GroupBy, ", ")+".") + example = append(example, "steadybit report "+k.Name+" --group-by "+k.GroupBy[1]+" --team ADM -t json") + } + var variadics []string + if k.Takes(report.Teams) { + flags.StringArrayVar(&o.Teams, "team", nil, "Only count these teams, by team key.") + variadics = append(variadics, "team") + } + if k.Takes(report.Environments) { + flags.StringArrayVar(&o.Environments, "environment", nil, "Only count these environments, by name.") + variadics = append(variadics, "environment") + } + if k.Takes(report.Services) { + flags.StringArrayVar(&o.Services, "service", nil, "Only count these services, by id.") + variadics = append(variadics, "service") + } + if k.Takes(report.ServiceProperties) { + flags.StringArrayVar(&o.ServiceProperties, "service-property", nil, "Only count services with this property, as KEY=VALUE. Repeat for more.") + } + if k.Takes(report.Categories) { + flags.StringArrayVar(&o.Categories, "category", nil, "Only count these risk categories.") + variadics = append(variadics, "category") + } + variadic(cmd, variadics...) + cmd.Example = examples(example...) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index e151035..34660e4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAccessToken(), newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) + root.AddCommand(newAccessToken(), newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newReport(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..5f9c082 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package report implements the `report` commands: time series over the tenant, printed as +// the platform sends them. +package report + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +// Filter says which filters a report takes beyond the time range. +type Filter int + +const ( + Teams Filter = 1 << iota + Environments + Services + ServiceProperties + Categories +) + +type Kind struct { + Name, Short string + Filters Filter + // The groupings the report offers; none means it cannot be grouped. + GroupBy []string + send func(ctx context.Context, c *platform.Client, groupBy string, body io.Reader) (*http.Response, error) +} + +func (k Kind) Takes(f Filter) bool { return k.Filters&f != 0 } + +const contentType = "application/json" + +var serviceFilters = Teams | Environments | Services | ServiceProperties | Categories + +var Kinds = []Kind{ + {Name: "users", Short: "Users in the tenant over time.", send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetUserCountsWithBody(ctx, contentType, body) + }}, + {Name: "teams", Short: "Teams in the tenant over time.", send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetTeamCountsWithBody(ctx, contentType, body) + }}, + {Name: "environments", Short: "Environments in the tenant over time.", send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetEnvironmentCountsWithBody(ctx, contentType, body) + }}, + { + Name: "experiments-executed", Short: "Experiment runs over time.", Filters: Teams | Environments | Services, + GroupBy: []string{"NONE", "STATE", "TRIGGER", "ACTION", "ISSUES_FIXED", "ISSUES_DISCOVERED"}, + send: func(ctx context.Context, c *platform.Client, groupBy string, body io.Reader) (*http.Response, error) { + params := &api.GetExperimentExecutionsParams{} + if groupBy != "" { + g := api.GetExperimentExecutionsParamsGroupBy(groupBy) + params.GroupBy = &g + } + return c.GetExperimentExecutionsWithBody(ctx, params, contentType, body) + }, + }, + { + Name: "experiments-created", Short: "Experiments created over time.", Filters: Teams | Environments, + GroupBy: []string{"NONE", "CREATED_VIA", "ORIGIN"}, + send: func(ctx context.Context, c *platform.Client, groupBy string, body io.Reader) (*http.Response, error) { + params := &api.GetExperimentCreationsParams{} + if groupBy != "" { + g := api.GetExperimentCreationsParamsGroupBy(groupBy) + params.GroupBy = &g + } + return c.GetExperimentCreationsWithBody(ctx, params, contentType, body) + }, + }, + {Name: "services-distribution", Short: "Services per risk level (low, medium, high) over time.", Filters: serviceFilters, + send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetRiskDistributionWithBody(ctx, contentType, body) + }}, + {Name: "services-by-category", Short: "The average risk of services per category over time.", Filters: serviceFilters, + send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetRiskByCategoryWithBody(ctx, contentType, body) + }}, + {Name: "services-average", Short: "The average risk of services, 0 to 100, over time.", Filters: serviceFilters, + send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetAverageRiskWithBody(ctx, contentType, body) + }}, +} + +type Options struct { + From, To, Rollup, GroupBy string + // Teams by key and environments by name, as users know them; the platform takes ids. + Teams, Environments, Services, Categories []string + ServiceProperties []string + File, Type string +} + +// Today is when a report ends by default. A variable, so that tests can fix it. +var Today = func() time.Time { return time.Now().UTC() } + +func date(flag, value string) (string, error) { + if _, err := time.Parse(time.DateOnly, value); err != nil { + return "", fmt.Errorf("--%s '%s' is not a date like 2026-09-01.", flag, value) + } + return value, nil +} + +func Run(ctx context.Context, c *platform.Client, k Kind, o Options) error { + body := jsyaml.NewMap() + to := Today().Format(time.DateOnly) + if o.To != "" { + var err error + if to, err = date("to", o.To); err != nil { + return err + } + } + // 30 days back, unless told otherwise. + end, _ := time.Parse(time.DateOnly, to) + from := end.AddDate(0, 0, -30).Format(time.DateOnly) + if o.From != "" { + var err error + if from, err = date("from", o.From); err != nil { + return err + } + } + body.Set("from", from) + body.Set("to", to) + if o.Rollup != "" { + rollup := strings.ToUpper(o.Rollup) + if rollup != "DAILY" && rollup != "MONTHLY" { + return fmt.Errorf("--rollup must be DAILY or MONTHLY, not '%s'.", o.Rollup) + } + body.Set("rollup", rollup) + } + groupBy := strings.ToUpper(o.GroupBy) + if groupBy != "" && !contains(k.GroupBy, groupBy) { + return fmt.Errorf("--group-by must be one of %s, not '%s'.", strings.Join(k.GroupBy, ", "), o.GroupBy) + } + + if len(o.Teams) > 0 { + ids, err := teamIDs(ctx, c, o.Teams) + if err != nil { + return err + } + body.Set("teamIds", ids) + } + if len(o.Environments) > 0 { + ids, err := environmentIDs(ctx, c, o.Environments) + if err != nil { + return err + } + body.Set("environmentIds", ids) + } + if len(o.Services) > 0 { + body.Set("serviceIds", list(o.Services)) + } + if len(o.ServiceProperties) > 0 { + properties := jsyaml.NewMap() + for _, pair := range o.ServiceProperties { + key, value, ok := strings.Cut(pair, "=") + if !ok || key == "" { + return fmt.Errorf("'%s' is not in the form KEY=VALUE.", pair) + } + values, _ := properties.Get(key) + existing, _ := values.([]any) + properties.Set(key, append(existing, value)) + } + body.Set("serviceProperties", properties) + } + if len(o.Categories) > 0 { + body.Set("categoryKeys", list(o.Categories)) + } + + doc, _, err := platform.ReadDocument(k.send(ctx, c, groupBy, resource.Body(body))) + if err != nil { + return platform.Failed(err, "Failed to get the %s report", k.Name) + } + if err := resource.Output(doc, o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Report %s written to %s.\n", k.Name, o.File) + } + return nil +} + +func contains(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + +func list(values []string) []any { + out := make([]any, len(values)) + for i, v := range values { + out[i] = v + } + return out +} + +func teamIDs(ctx context.Context, c *platform.Client, keys []string) ([]any, error) { + ids := make([]any, len(keys)) + for i, key := range keys { + var team struct{ ID string } + resp, err := c.GetTeam(ctx, key) + if _, err := platform.Decode(resp, err, &team); err != nil { + if platform.IsStatus(err, http.StatusNotFound) { + return nil, fmt.Errorf("Team %s not found.", key) + } + return nil, platform.Failed(err, "Failed to get team %s", key) + } + ids[i] = team.ID + } + return ids, nil +} + +func environmentIDs(ctx context.Context, c *platform.Client, names []string) ([]any, error) { + var summaries struct { + Environments []struct{ ID, Name string } `json:"environments"` + } + resp, err := c.GetEnvironments(ctx, &api.GetEnvironmentsParams{}) + if _, err := platform.Decode(resp, err, &summaries); err != nil { + return nil, platform.Failed(err, "Failed to get the environments") + } + ids := make([]any, len(names)) + for i, name := range names { + for _, e := range summaries.Environments { + if e.Name == name { + ids[i] = e.ID + } + } + if ids[i] == nil { + return nil, fmt.Errorf("Environment %s not found.", name) + } + } + return ids, nil +} diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..0cc7559 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package report_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/report" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func kind(t *testing.T, name string) report.Kind { + for _, k := range report.Kinds { + if k.Name == name { + return k + } + } + t.Fatalf("no report %s", name) + return report.Kind{} +} + +const series = `{"from":"2026-08-01","to":"2026-09-01","rollup":"MONTHLY","groupBy":"NONE","series":[{"name":"users","values":[["2026-08-01",16]]}]}` + +func TestEveryReportPostsToItsEndpoint(t *testing.T) { + paths := map[string]string{ + "users": "/api/reports/users", "teams": "/api/reports/teams", "environments": "/api/reports/environments", + "experiments-executed": "/api/reports/experiments/executed", "experiments-created": "/api/reports/experiments/created", + "services-distribution": "/api/reports/services/distribution", "services-by-category": "/api/reports/services/by-category", + "services-average": "/api/reports/services/average", + } + require.Len(t, report.Kinds, len(paths)) + for _, k := range report.Kinds { + t.Run(k.Name, func(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST "+paths[k.Name], platformtest.Reply{Body: series}) + + out, err := platformtest.Stdout(t, func() error { + return report.Run(ctx, p.Client, k, report.Options{From: "2026-08-01", To: "2026-09-01"}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "series:\n - name: users\n") + assert.Equal(t, map[string]any{"from": "2026-08-01", "to": "2026-09-01"}, p.Requests("POST " + paths[k.Name])[0].JSON(t)) + }) + } +} + +func TestDefaultsToTheLast30Days(t *testing.T) { + report.Today = func() time.Time { return time.Date(2026, 3, 31, 15, 0, 0, 0, time.UTC) } + t.Cleanup(func() { report.Today = func() time.Time { return time.Now().UTC() } }) + p := platformtest.New(t) + p.Reply("POST /api/reports/users", platformtest.Reply{Body: series}) + + _, err := platformtest.Stdout(t, func() error { return report.Run(ctx, p.Client, kind(t, "users"), report.Options{Rollup: "daily"}) }) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"from": "2026-03-01", "to": "2026-03-31", "rollup": "DAILY"}, p.Requests("POST /api/reports/users")[0].JSON(t)) +} + +func TestFiltersAreSentAsIds(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams/ADM", platformtest.Reply{JSON: map[string]any{"id": "team-1", "key": "ADM"}}) + p.Reply("GET /api/environments", platformtest.Reply{JSON: map[string]any{"environments": []any{map[string]any{"id": "env-1", "name": "Global"}, map[string]any{"id": "env-2", "name": "Global 2"}}}}) + p.Reply("POST /api/reports/services/average", platformtest.Reply{Body: series}) + + _, err := platformtest.Stdout(t, func() error { + return report.Run(ctx, p.Client, kind(t, "services-average"), report.Options{ + From: "2026-08-01", To: "2026-09-01", Teams: []string{"ADM"}, Environments: []string{"Global"}, Services: []string{"svc-1"}, + ServiceProperties: []string{"tier=gold", "tier=silver", "region=eu"}, Categories: []string{"Redundancy"}, Type: "json", + }) + }) + + require.NoError(t, err) + assert.Equal(t, map[string]any{ + "from": "2026-08-01", "to": "2026-09-01", "teamIds": []any{"team-1"}, "environmentIds": []any{"env-1"}, "serviceIds": []any{"svc-1"}, + "serviceProperties": map[string]any{"tier": []any{"gold", "silver"}, "region": []any{"eu"}}, "categoryKeys": []any{"Redundancy"}, + }, p.Requests("POST /api/reports/services/average")[0].JSON(t)) +} + +func TestGroupBy(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/reports/experiments/executed", platformtest.Reply{Body: series}) + + _, err := platformtest.Stdout(t, func() error { + return report.Run(ctx, p.Client, kind(t, "experiments-executed"), report.Options{From: "2026-08-01", To: "2026-09-01", GroupBy: "state"}) + }) + + require.NoError(t, err) + assert.Equal(t, []string{"STATE"}, p.Requests("POST /api/reports/experiments/executed")[0].Query["groupBy"]) + err = report.Run(ctx, p.Client, kind(t, "experiments-created"), report.Options{GroupBy: "state"}) + assert.EqualError(t, err, "--group-by must be one of NONE, CREATED_VIA, ORIGIN, not 'state'.") +} + +func TestRefusals(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams/NOPE", platformtest.Reply{Status: http.StatusNotFound}) + p.Reply("GET /api/environments", platformtest.Reply{JSON: map[string]any{"environments": []any{}}}) + users, executed := kind(t, "users"), kind(t, "experiments-executed") + + assert.EqualError(t, report.Run(ctx, p.Client, users, report.Options{From: "yesterday"}), "--from 'yesterday' is not a date like 2026-09-01.") + assert.EqualError(t, report.Run(ctx, p.Client, users, report.Options{Rollup: "weekly"}), "--rollup must be DAILY or MONTHLY, not 'weekly'.") + assert.EqualError(t, report.Run(ctx, p.Client, executed, report.Options{Teams: []string{"NOPE"}}), "Team NOPE not found.") + assert.EqualError(t, report.Run(ctx, p.Client, executed, report.Options{Environments: []string{"Nowhere"}}), "Environment Nowhere not found.") + assert.EqualError(t, report.Run(ctx, p.Client, kind(t, "services-average"), report.Options{ServiceProperties: []string{"tier"}}), "'tier' is not in the form KEY=VALUE.") +} From 5bf0f132df1fcc562e4abdc8f35d0b63d6b43ce2 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:49:47 +0200 Subject: [PATCH 14/25] feat(go): audit log --- CHANGELOG.md | 1 + internal/accesstoken/accesstoken.go | 18 +--- internal/accesstoken/accesstoken_test.go | 2 +- internal/auditlog/auditlog.go | 110 +++++++++++++++++++++++ internal/auditlog/auditlog_test.go | 59 ++++++++++++ internal/cli/auditlog.go | 27 ++++++ internal/cli/root.go | 2 +- internal/resource/resource.go | 14 +++ 8 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 internal/auditlog/auditlog.go create mode 100644 internal/auditlog/auditlog_test.go create mode 100644 internal/cli/auditlog.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7797fed..82b6a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ `environments`, `experiments-executed`, `experiments-created`, `services-distribution`, `services-by-category` and `services-average`. Teams are filtered by key and environments by name. +- `audit-log` shows who changed what and when, as a table or with `-t json|yaml`. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/accesstoken/accesstoken.go b/internal/accesstoken/accesstoken.go index bd1a18b..b2264d8 100644 --- a/internal/accesstoken/accesstoken.go +++ b/internal/accesstoken/accesstoken.go @@ -9,7 +9,6 @@ import ( "fmt" "net/http" "strings" - "time" "github.com/steadybit/cli/api" "github.com/steadybit/cli/internal/jsyaml" @@ -25,19 +24,6 @@ func notFoundOr(err error, id, format string) error { return platform.Failed(err, format, id) } -// ExpiresAt takes a date, meaning its start in UTC, or a full RFC 3339 time. -func ExpiresAt(value string) (*time.Time, error) { - if value == "" { - return nil, nil - } - for _, layout := range []string{time.RFC3339, time.DateOnly} { - if t, err := time.Parse(layout, value); err == nil { - return &t, nil - } - } - return nil, fmt.Errorf("--expires-at '%s' is neither a date like 2026-12-31 nor a time like 2026-12-31T23:59:59Z.", value) -} - type ListOptions struct { Name, CreatedBy, Type string Teams []string @@ -127,7 +113,7 @@ func Create(ctx context.Context, c *platform.Client, o CreateOptions) error { if !kind.Valid() { return fmt.Errorf("--type must be ADMIN, TEAM or WILDCARD, not '%s'.", o.Type) } - expiresAt, err := ExpiresAt(o.ExpiresAt) + expiresAt, err := resource.Time("expires-at", o.ExpiresAt) if err != nil { return err } @@ -146,7 +132,7 @@ type RecreateOptions struct { } func Recreate(ctx context.Context, c *platform.Client, o RecreateOptions) error { - expiresAt, err := ExpiresAt(o.ExpiresAt) + expiresAt, err := resource.Time("expires-at", o.ExpiresAt) if err != nil { return err } diff --git a/internal/accesstoken/accesstoken_test.go b/internal/accesstoken/accesstoken_test.go index 1271054..a9881a0 100644 --- a/internal/accesstoken/accesstoken_test.go +++ b/internal/accesstoken/accesstoken_test.go @@ -63,7 +63,7 @@ func TestCreatePrintsTheTokenOnce(t *testing.T) { assert.Equal(t, map[string]any{"name": "ci", "type": "ADMIN"}, p.Requests("POST /api/access-tokens/v2")[1].JSON(t)) assert.EqualError(t, accesstoken.Create(ctx, p.Client, accesstoken.CreateOptions{Name: "ci", Type: "ADMIN", ExpiresAt: "tomorrow"}), - "--expires-at 'tomorrow' is neither a date like 2026-12-31 nor a time like 2026-12-31T23:59:59Z.") + "--expires-at 'tomorrow' is neither a date like 2026-09-01 nor a time like 2026-09-01T12:00:00Z.") } func TestRecreateAndDelete(t *testing.T) { diff --git a/internal/auditlog/auditlog.go b/internal/auditlog/auditlog.go new file mode 100644 index 0000000..65c1da3 --- /dev/null +++ b/internal/auditlog/auditlog.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package auditlog implements the `audit-log` command. +package auditlog + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +type Options struct { + From, To string + Type string +} + +// Show prints the entries of the time range; the platform defaults to the last 7 days. +// The endpoint is not paged: it returns the whole range at once. +func Show(ctx context.Context, c *platform.Client, o Options) error { + from, err := resource.Time("from", o.From) + if err != nil { + return err + } + to, err := resource.Time("to", o.To) + if err != nil { + return err + } + body, _, err := platform.Read(c.Find(ctx, &api.FindParams{From: from, To: to})) + if platform.IsStatus(err, http.StatusForbidden) { + return errors.New("The audit log needs an admin access token.") + } + if err != nil { + return platform.Failed(err, "Failed to get the audit log") + } + value, err := output.ParseValue(body) + if err != nil { + return err + } + entries, _ := value.([]any) + if o.Type != "" { + datatype, err := output.ResolveDatatype(o.Type, "") + if err != nil { + return err + } + if datatype == output.JSON { + fmt.Println(jsyaml.JSON(entries)) + } else { + fmt.Println(jsyaml.Dump(entries)) + } + return nil + } + if len(entries) == 0 { + fmt.Println("No audit log entries found.") + return nil + } + t := table.New( + table.Column{Name: "time", Title: "Time", Alignment: table.Left}, + table.Column{Name: "event", Title: "Event", Alignment: table.Left}, + table.Column{Name: "by", Title: "By", Alignment: table.Left}, + table.Column{Name: "team", Title: "Team", Alignment: table.Left}, + table.Column{Name: "environment", Title: "Environment", Alignment: table.Left}, + ) + for _, e := range entries { + entry, _ := e.(*jsyaml.Map) + t.AddRow(table.Default, table.Cell("time", field(entry, "eventTime")), table.Cell("event", field(entry, "eventName")), + table.Cell("by", principal(entry)), table.Cell("team", field(entry, "team", "key")), table.Cell("environment", field(entry, "environment", "name"))) + } + t.Print() + return nil +} + +// principal names who did it: a user by name, an access token by its name, a batch job as such. +func principal(entry *jsyaml.Map) string { + switch field(entry, "principal", "principalType") { + case "USER": + if name := field(entry, "principal", "name"); name != "" { + return name + } + return field(entry, "principal", "username") + case "ACCESS_TOKEN": + return "token " + field(entry, "principal", "name") + case "BATCH_JOB": + return "batch job" + } + return "" +} + +func field(m *jsyaml.Map, path ...string) string { + var value any = m + for _, key := range path { + next, ok := value.(*jsyaml.Map) + if !ok { + return "" + } + value, _ = next.Get(key) + } + if value == nil { + return "" + } + return fmt.Sprint(value) +} diff --git a/internal/auditlog/auditlog_test.go b/internal/auditlog/auditlog_test.go new file mode 100644 index 0000000..56765e4 --- /dev/null +++ b/internal/auditlog/auditlog_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package auditlog_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/auditlog" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const entries = `[ + {"id":"a","eventName":"experiment.created","eventTime":"2026-09-01T10:00:00Z","principal":{"principalType":"USER","name":"Jane Doe","username":"u-1"},"tenant":{"key":"demo","name":"Demo"},"team":{"id":"t","key":"ADM","name":"Admins"}}, + {"id":"b","eventName":"environment.deleted","eventTime":"2026-09-02T11:00:00Z","principal":{"principalType":"ACCESS_TOKEN","name":"CI/CD","tokenType":"ADMIN"},"tenant":{"key":"demo","name":"Demo"},"environment":{"id":"e","name":"Prod","predicate":{}}}, + {"id":"c","eventName":"advice.updated","eventTime":"2026-09-03T12:00:00Z","principal":{"principalType":"BATCH_JOB","username":"system"},"tenant":{"key":"demo","name":"Demo"}} +]` + +func TestShowsWhoDidWhat(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/audit-log", platformtest.Reply{Body: entries}) + + out, err := platformtest.Stdout(t, func() error { + return auditlog.Show(ctx, p.Client, auditlog.Options{From: "2026-09-01", To: "2026-09-08T12:00:00+02:00"}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ 2026-09-01T10:00:00Z │ experiment.created │ Jane Doe │ ADM │ │") + assert.Contains(t, out, "│ 2026-09-02T11:00:00Z │ environment.deleted │ token CI/CD │ │ Prod │") + assert.Contains(t, out, "│ 2026-09-03T12:00:00Z │ advice.updated │ batch job │ │ │") + q := p.Requests("GET /api/audit-log")[0].Query + assert.Equal(t, []string{"2026-09-01T00:00:00Z"}, q["from"]) + assert.Equal(t, []string{"2026-09-08T12:00:00+02:00"}, q["to"]) +} + +func TestPrintsTheEntriesAsJSON(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/audit-log", platformtest.Reply{Body: `[{"id":"a","eventName":"x"}]`}) + + out, err := platformtest.Stdout(t, func() error { return auditlog.Show(ctx, p.Client, auditlog.Options{Type: "json"}) }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"id\": \"a\",\n \"eventName\": \"x\"\n }\n]\n", out) + assert.Empty(t, p.Requests("GET /api/audit-log")[0].Query) +} + +func TestRefusals(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/audit-log", platformtest.Reply{Status: http.StatusForbidden}) + + assert.EqualError(t, auditlog.Show(ctx, p.Client, auditlog.Options{}), "The audit log needs an admin access token.") + assert.EqualError(t, auditlog.Show(ctx, p.Client, auditlog.Options{From: "last week"}), "--from 'last week' is neither a date like 2026-09-01 nor a time like 2026-09-01T12:00:00Z.") +} diff --git a/internal/cli/auditlog.go b/internal/cli/auditlog.go new file mode 100644 index 0000000..7245c90 --- /dev/null +++ b/internal/cli/auditlog.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/auditlog" + "github.com/steadybit/cli/internal/platform" +) + +func newAuditLog() *cobra.Command { + var o auditlog.Options + cmd := &cobra.Command{ + Use: "audit-log", + Short: "Show the audit log: who changed what, and when. Needs an admin access token.", + Args: cobra.NoArgs, + Example: examples("steadybit audit-log", "steadybit audit-log --from 2026-09-01 --to 2026-09-08T12:00:00Z -t json"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return auditlog.Show(ctx, c, o) }), + } + cmd.Flags().StringVar(&o.From, "from", "", "The earliest time, a date or an RFC 3339 time. (default: 7 days before --to)") + cmd.Flags().StringVar(&o.To, "to", "", "The latest time, a date or an RFC 3339 time. (default: 7 days after --from, or now)") + cmd.Flags().StringVarP(&o.Type, "type", "t", "", `Print the entries as "json" or "yaml" instead of a table.`) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 34660e4..0a8cc18 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAccessToken(), newAdvice(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newReport(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) + root.AddCommand(newAccessToken(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newReport(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/resource/resource.go b/internal/resource/resource.go index b4521d8..2c13f25 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -13,6 +13,7 @@ import ( "io" "os" "strings" + "time" openapi_types "github.com/oapi-codegen/runtime/types" "github.com/steadybit/cli/internal/experiment" @@ -192,3 +193,16 @@ func VariablesOutcome(replace bool) string { } return "set" } + +// Time takes a date, meaning its start in UTC, or a full RFC 3339 time. +func Time(flag, value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + for _, layout := range []string{time.RFC3339, time.DateOnly} { + if t, err := time.Parse(layout, value); err == nil { + return &t, nil + } + } + return nil, fmt.Errorf("--%s '%s' is neither a date like 2026-09-01 nor a time like 2026-09-01T12:00:00Z.", flag, value) +} From 5638f398b679de90e31ecd3ed4fb8dca1ad9d422 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:49:57 +0200 Subject: [PATCH 15/25] feat(go): kill switch --- CHANGELOG.md | 2 + internal/cli/killswitch.go | 49 +++++++++++++++++ internal/cli/root.go | 2 +- internal/killswitch/killswitch.go | 75 ++++++++++++++++++++++++++ internal/killswitch/killswitch_test.go | 73 +++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 internal/cli/killswitch.go create mode 100644 internal/killswitch/killswitch.go create mode 100644 internal/killswitch/killswitch_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b6a2e..0e0dc4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ `services-by-category` and `services-average`. Teams are filtered by key and environments by name. - `audit-log` shows who changed what and when, as a table or with `-t json|yaml`. +- `killswitch status`, `activate` and `deactivate`. Activating asks for confirmation unless + `--yes` is given, as it stops every running experiment of the tenant. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/cli/killswitch.go b/internal/cli/killswitch.go new file mode 100644 index 0000000..1393794 --- /dev/null +++ b/internal/cli/killswitch.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/killswitch" + "github.com/steadybit/cli/internal/platform" +) + +func newKillswitch() *cobra.Command { + cmd := &cobra.Command{Use: "killswitch", Short: "Stop all experiments of the tenant at once, and keep them stopped."} + + var s killswitch.StatusOptions + status := &cobra.Command{ + Use: "status", + Short: "Show whether the kill switch is active.", + Args: cobra.NoArgs, + Example: examples("steadybit killswitch status", "steadybit killswitch status -t json"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return killswitch.Status(ctx, c, s) }), + } + status.Flags().StringVarP(&s.Type, "type", "t", "", `Print the status as "json" or "yaml".`) + + var yes bool + activate := &cobra.Command{ + Use: "activate", + Short: "Activate the kill switch: stop every running experiment of every team, and let none run until it is deactivated.", + Args: cobra.NoArgs, + Example: examples("steadybit killswitch activate", "steadybit killswitch activate --yes"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return killswitch.Activate(ctx, c, yes) + }), + } + activate.Flags().BoolVar(&yes, "yes", false, yesHelp) + + deactivate := &cobra.Command{ + Use: "deactivate", + Short: "Deactivate the kill switch, so that experiments can run again. Those it stopped are not restarted.", + Args: cobra.NoArgs, + Example: examples("steadybit killswitch deactivate"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return killswitch.Deactivate(ctx, c) }), + } + + cmd.AddCommand(status, activate, deactivate) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0a8cc18..756cd82 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAccessToken(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newReport(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) + root.AddCommand(newAccessToken(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newKillswitch(), newReport(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/killswitch/killswitch.go b/internal/killswitch/killswitch.go new file mode 100644 index 0000000..4ed5bd4 --- /dev/null +++ b/internal/killswitch/killswitch.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package killswitch implements the `killswitch` commands. +package killswitch + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +type StatusOptions struct { + Type string +} + +func Status(ctx context.Context, c *platform.Client, o StatusOptions) error { + body, _, err := platform.Read(c.GetKillswitch(ctx)) + if err != nil { + return platform.Failed(err, "Failed to get the kill switch status") + } + if o.Type != "" { + doc, err := output.ParseDocument(body) + if err != nil { + return err + } + return resource.Output(doc, "", o.Type) + } + var status struct { + Active bool `json:"active"` + EngagedBy string `json:"engagedBy"` + Engaged string `json:"engaged"` + EngagedByDetails *struct { + Name string `json:"name"` + } `json:"engagedByDetails"` + } + if err := json.Unmarshal(body, &status); err != nil { + return err + } + if !status.Active { + fmt.Println("The kill switch is inactive: experiments can run.") + return nil + } + by := status.EngagedBy + if status.EngagedByDetails != nil && status.EngagedByDetails.Name != "" { + by = status.EngagedByDetails.Name + } + fmt.Printf("The kill switch is active since %s, activated by %s: no experiment can run.\n", status.Engaged, by) + return nil +} + +// Activate stops every experiment running in the tenant, of every team, and keeps new +// ones from starting until the kill switch is deactivated. +func Activate(ctx context.Context, c *platform.Client, yes bool) error { + if ok, err := resource.Confirmed(yes, "Activate the kill switch? It stops every running experiment of every team, and no experiment can run until it is deactivated."); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.EngageKillswitch(ctx)); err != nil { + return platform.Failed(err, "Failed to activate the kill switch") + } + fmt.Println("Kill switch activated. Running experiments are stopped, and none can run until `steadybit killswitch deactivate`.") + return nil +} + +func Deactivate(ctx context.Context, c *platform.Client) error { + if _, _, err := platform.Read(c.DisengageKillswitch(ctx)); err != nil { + return platform.Failed(err, "Failed to deactivate the kill switch") + } + fmt.Println("Kill switch deactivated. Experiments can run again; those it stopped are not restarted.") + return nil +} diff --git a/internal/killswitch/killswitch_test.go b/internal/killswitch/killswitch_test.go new file mode 100644 index 0000000..9e13f5d --- /dev/null +++ b/internal/killswitch/killswitch_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package killswitch_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/killswitch" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func TestStatus(t *testing.T) { + p := platformtest.New(t) + active := false + p.Handle("GET /api/killswitch", func(platformtest.Request) platformtest.Reply { + if !active { + return platformtest.Reply{Body: `{"active":false}`} + } + return platformtest.Reply{Body: `{"active":true,"engagedBy":"u-1","engagedByDetails":{"username":"u-1","name":"Jane Doe"},"engaged":"2026-09-01T10:00:00Z"}`} + }) + + out, err := platformtest.Stdout(t, func() error { + if err := killswitch.Status(ctx, p.Client, killswitch.StatusOptions{}); err != nil { + return err + } + active = true + if err := killswitch.Status(ctx, p.Client, killswitch.StatusOptions{}); err != nil { + return err + } + return killswitch.Status(ctx, p.Client, killswitch.StatusOptions{Type: "yaml"}) + }) + + require.NoError(t, err) + assert.Equal(t, "The kill switch is inactive: experiments can run.\n"+ + "The kill switch is active since 2026-09-01T10:00:00Z, activated by Jane Doe: no experiment can run.\n"+ + "active: true\nengagedBy: u-1\nengagedByDetails:\n username: u-1\n name: Jane Doe\nengaged: '2026-09-01T10:00:00Z'\n\n", out) +} + +func TestActivateAndDeactivate(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/killswitch", platformtest.Reply{}) + p.Reply("DELETE /api/killswitch", platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + if err := killswitch.Activate(ctx, p.Client, true); err != nil { + return err + } + return killswitch.Deactivate(ctx, p.Client) + }) + + require.NoError(t, err) + assert.Equal(t, "Kill switch activated. Running experiments are stopped, and none can run until `steadybit killswitch deactivate`.\n"+ + "Kill switch deactivated. Experiments can run again; those it stopped are not restarted.\n", out) + assert.Len(t, p.Requests("POST /api/killswitch"), 1) + assert.Len(t, p.Requests("DELETE /api/killswitch"), 1) +} + +func TestActivateReportsAFailure(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/killswitch", platformtest.Reply{Status: http.StatusForbidden}) + + err := killswitch.Activate(ctx, p.Client, true) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed to activate the kill switch: ") +} From 922498eb73a082b9ffadf75e54e0a97ef876e261 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:53:27 +0200 Subject: [PATCH 16/25] feat(go): targets, their attributes, and actions --- CHANGELOG.md | 3 + internal/action/action.go | 104 +++++++++++++++++ internal/action/action_test.go | 57 +++++++++ internal/cli/root.go | 2 +- internal/cli/target.go | 108 +++++++++++++++++ internal/target/target.go | 205 +++++++++++++++++++++++++++++++++ internal/target/target_test.go | 104 +++++++++++++++++ 7 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 internal/action/action.go create mode 100644 internal/action/action_test.go create mode 100644 internal/cli/target.go create mode 100644 internal/target/target.go create mode 100644 internal/target/target_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e0dc4d..20ade26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ - `audit-log` shows who changed what and when, as a table or with `-t json|yaml`. - `killswitch status`, `activate` and `deactivate`. Activating asks for confirmation unless `--yes` is given, as it stops every running experiment of the tenant. +- `target query` lists the targets of an environment, by type and query, with the + attributes asked for as columns; `target attribute keys|values` lists what can be + queried. `action list` and `action get` show the actions experiments can use. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/action/action.go b/internal/action/action.go new file mode 100644 index 0000000..73257c7 --- /dev/null +++ b/internal/action/action.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package action implements the `action` commands. +package action + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +type ListOptions struct { + // Only actions of these kinds, e.g. ATTACK or CHECK; the endpoint cannot filter. + Kinds []string +} + +type summary struct { + ID, Name, Kind, Category, Technology string +} + +// all follows nextPage like platform.AllPages, but this endpoint lists under `actions`. +func all(ctx context.Context, c *platform.Client) ([]summary, error) { + var actions []summary + page, size := int32(0), platform.PageSize + for { + var body struct { + Actions []summary `json:"actions"` + NextPage *int32 `json:"nextPage"` + } + resp, err := c.FindAllActions(ctx, &api.FindAllActionsParams{Page: &page, Size: &size}) + if _, err := platform.Decode(resp, err, &body); err != nil { + return nil, err + } + actions = append(actions, body.Actions...) + if body.NextPage == nil || *body.NextPage == page { + return actions, nil + } + page = *body.NextPage + } +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + actions, err := all(ctx, c) + if err != nil { + return platform.Failed(err, "Failed to get the actions") + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "kind", Title: "Kind", Alignment: table.Left}, + table.Column{Name: "category", Title: "Category", Alignment: table.Left}, + ) + rows := 0 + for _, a := range actions { + if len(o.Kinds) > 0 && !anyEqualFold(o.Kinds, a.Kind) { + continue + } + t.AddRow(table.Default, table.Cell("id", a.ID), table.Cell("name", a.Name), table.Cell("kind", a.Kind), table.Cell("category", a.Category)) + rows++ + } + if rows == 0 { + fmt.Println("No actions found.") + return nil + } + t.Print() + return nil +} + +func anyEqualFold(values []string, s string) bool { + for _, v := range values { + if strings.EqualFold(v, s) { + return true + } + } + return false +} + +type GetOptions struct { + ID, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, o GetOptions) error { + doc, _, err := platform.ReadDocument(c.GetAction(ctx, o.ID)) + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Action %s not found.", o.ID) + } + if err != nil { + return platform.Failed(err, "Failed to get action %s", o.ID) + } + if err := resource.Output(doc, o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Action %s written to %s.\n", o.ID, o.File) + } + return nil +} diff --git a/internal/action/action_test.go b/internal/action/action_test.go new file mode 100644 index 0000000..493fb02 --- /dev/null +++ b/internal/action/action_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package action_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/action" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func TestListWalksEveryPageAndFiltersByKind(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/actions", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"actions": []any{map[string]any{"id": "stress-cpu", "name": "Stress CPU", "kind": "ATTACK", "category": "resource"}}, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"actions": []any{map[string]any{"id": "http-check", "name": "HTTP Check", "kind": "CHECK"}}}} + }) + + out, err := platformtest.Stdout(t, func() error { return action.List(ctx, p.Client, action.ListOptions{Kinds: []string{"check"}}) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ http-check │ HTTP Check │ CHECK │ │") + assert.NotContains(t, out, "stress-cpu") + assert.Len(t, p.Requests("GET /api/actions"), 2) + assert.Equal(t, []string{"100"}, p.Requests("GET /api/actions")[0].Query["size"]) +} + +func TestListSaysWhenThereIsNone(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/actions", platformtest.Reply{JSON: map[string]any{"actions": []any{}}}) + + out, err := platformtest.Stdout(t, func() error { return action.List(ctx, p.Client, action.ListOptions{}) }) + + require.NoError(t, err) + assert.Equal(t, "No actions found.\n", out) +} + +func TestGet(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/actions/stress-cpu", platformtest.Reply{Body: `{"id":"stress-cpu","name":"Stress CPU","parameters":[{"name":"duration","type":"duration"}]}`}) + p.Reply("GET /api/actions/nope", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { return action.Get(ctx, p.Client, action.GetOptions{ID: "stress-cpu"}) }) + + require.NoError(t, err) + assert.Equal(t, "id: stress-cpu\nname: Stress CPU\nparameters:\n - name: duration\n type: duration\n\n", out) + assert.EqualError(t, action.Get(ctx, p.Client, action.GetOptions{ID: "nope"}), "Action nope not found.") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 756cd82..b5b9ef0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAccessToken(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newKillswitch(), newReport(), newSchedule(), newService(), newServiceProfile(), newTeam(), newTemplate(), newUser()) + root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newKillswitch(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/cli/target.go b/internal/cli/target.go new file mode 100644 index 0000000..21084d6 --- /dev/null +++ b/internal/cli/target.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/action" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/target" +) + +const listTypeHelp = `Print the list as "json" or "yaml" instead of a table.` + +func environmentNameFlag(cmd *cobra.Command, environment *string) { + cmd.Flags().StringVarP(environment, "environment", "e", "", "The environment name.") + _ = cmd.MarkFlagRequired("environment") +} + +func newTarget() *cobra.Command { + cmd := &cobra.Command{Use: "target", Short: "Find the targets of an environment and their attributes."} + + var q target.QueryOptions + query := &cobra.Command{ + Use: "query", + Short: "List the targets of an environment, optionally of one type and matching a query.", + Args: cobra.NoArgs, + Example: examples( + "steadybit target query -e Global --target-type com.steadybit.extension_container.container", + `steadybit target query -e Global -q 'k8s.namespace="shop"' --attribute k8s.deployment k8s.pod.name --limit 0 -t json`, + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return target.Query(ctx, c, q) }), + } + environmentNameFlag(query, &q.Environment) + query.Flags().StringVar(&q.TargetType, "target-type", "", "Only list targets of this type.") + query.Flags().StringVarP(&q.Query, "query", "q", "", "Only list targets matching this target query.") + query.Flags().StringArrayVar(&q.Attributes, "attribute", nil, "Only fetch these attributes, shown as columns. (default: all, not shown)") + query.Flags().IntVar(&q.Limit, "limit", 100, "List at most this many targets; 0 lists all.") + query.Flags().StringVarP(&q.Type, "type", "t", "", listTypeHelp) + variadic(query, "attribute") + + attribute := &cobra.Command{Use: "attribute", Short: "List the attribute keys and values of targets."} + attributeFlags := func(c *cobra.Command, o *target.AttributeOptions) { + environmentNameFlag(c, &o.Environment) + c.Flags().StringVar(&o.TargetType, "target-type", "", "The target type. Either this or --action is required.") + c.Flags().StringVar(&o.Action, "action", "", "The action whose target selection to use, for actions with an extended one.") + c.Flags().StringVarP(&o.Type, "type", "t", "", listTypeHelp) + } + var k target.AttributeOptions + keys := &cobra.Command{ + Use: "keys", + Short: "List the attribute keys the targets of a type have.", + Args: cobra.NoArgs, + Example: examples("steadybit target attribute keys -e Global --target-type com.steadybit.extension_container.container"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return target.AttributeKeys(ctx, c, k) + }), + } + attributeFlags(keys, &k) + var v target.AttributeOptions + values := &cobra.Command{ + Use: "values", + Short: "List the values an attribute has on the targets of a type.", + Args: cobra.NoArgs, + Example: examples("steadybit target attribute values -e Global --target-type com.steadybit.extension_container.container --key k8s.namespace"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return target.AttributeValues(ctx, c, v) + }), + } + attributeFlags(values, &v) + values.Flags().StringVarP(&v.Key, "key", "k", "", "The attribute key.") + _ = values.MarkFlagRequired("key") + attribute.AddCommand(keys, values) + + cmd.AddCommand(query, attribute) + return cmd +} + +func newAction() *cobra.Command { + cmd := &cobra.Command{Use: "action", Short: "Find the actions experiments can use."} + + var l action.ListOptions + list := &cobra.Command{ + Use: "list", + Short: "List the actions the extensions provide.", + Args: cobra.NoArgs, + Example: examples("steadybit action list", "steadybit action list --kind ATTACK CHECK"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return action.List(ctx, c, l) }), + } + list.Flags().StringArrayVar(&l.Kinds, "kind", nil, `Only list actions of these kinds: "ATTACK", "CHECK", "LOAD_TEST", "OTHER" or "BASIC".`) + variadic(list, "kind") + + var g action.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get an action with its parameters. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit action get -i com.steadybit.extension_container.stress_cpu"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return action.Get(ctx, c, g) }), + } + idFlag(get, &g.ID, "The action id.") + outputFlags(get, &g.File, &g.Type, "action") + + cmd.AddCommand(list, get) + return cmd +} diff --git a/internal/target/target.go b/internal/target/target.go new file mode 100644 index 0000000..3337db8 --- /dev/null +++ b/internal/target/target.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package target implements the `target` commands. +package target + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/table" +) + +func notFoundOr(err error, environment, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Environment %s not found.", environment) + } + return platform.Failed(err, format, environment) +} + +func optional(s string) *string { + if s == "" { + return nil + } + return &s +} + +// printAs writes values as JSON or YAML when a type is given, and returns false otherwise. +func printAs(values []any, datatype string) (bool, error) { + if datatype == "" { + return false, nil + } + resolved, err := output.ResolveDatatype(datatype, "") + if err != nil { + return true, err + } + if resolved == output.JSON { + fmt.Println(jsyaml.JSON(values)) + } else { + fmt.Println(jsyaml.Dump(values)) + } + return true, nil +} + +type QueryOptions struct { + Environment, TargetType, Query string + Attributes []string + // At most this many targets; 0 is all of them. + Limit int + Type string +} + +// The most targets the platform returns at once. +const maxPageSize = 1000 + +func Query(ctx context.Context, c *platform.Client, o QueryOptions) error { + if o.Limit < 0 { + return errors.New("--limit cannot be negative.") + } + params := api.GetTargetsParams{Environment: o.Environment, TargetType: optional(o.TargetType), Query: optional(o.Query)} + if len(o.Attributes) > 0 { + params.Attribute = &o.Attributes + } + targets := []any{} + for { + size := int32(maxPageSize) + if o.Limit > 0 { + size = int32(min(o.Limit-len(targets), maxPageSize)) + } + params.Size = &size + body, _, err := platform.Read(c.GetTargets(ctx, ¶ms)) + if err != nil { + return notFoundOr(err, o.Environment, "Failed to get the targets of environment %s") + } + value, err := output.ParseValue(body) + if err != nil { + return err + } + slice, _ := value.(*jsyaml.Map) + items, _ := slice.Get("items") + list, _ := items.([]any) + targets = append(targets, list...) + hasNext, _ := slice.Get("hasNext") + next, _ := slice.Get("nextCursor") + cursor, _ := next.(string) + if hasNext != true || cursor == "" || len(list) == 0 || (o.Limit > 0 && len(targets) >= o.Limit) { + break + } + params.Cursor = &cursor + } + if printed, err := printAs(targets, o.Type); printed { + return err + } + if len(targets) == 0 { + fmt.Println("No targets found.") + return nil + } + columns := []table.Column{ + {Name: "name", Title: "Name", Alignment: table.Left}, + {Name: "type", Title: "Type", Alignment: table.Left}, + } + // The attributes asked for become columns; they are what the query was about. + for _, a := range o.Attributes { + columns = append(columns, table.Column{Name: "@" + a, Title: a, Alignment: table.Left}) + } + t := table.New(columns...) + for _, item := range targets { + target, _ := item.(*jsyaml.Map) + name, _ := target.Get("name") + kind, _ := target.Get("type") + cells := [][2]string{table.Cell("name", name), table.Cell("type", kind)} + for _, a := range o.Attributes { + cells = append(cells, table.Cell("@"+a, strings.Join(attribute(target, a), ", "))) + } + t.AddRow(table.Default, cells...) + } + t.Print() + return nil +} + +// attribute returns a target's values of a key, which it may have more than once. +func attribute(target *jsyaml.Map, key string) []string { + attributes, _ := target.Get("attributes") + list, _ := attributes.([]any) + var values []string + for _, a := range list { + m, _ := a.(*jsyaml.Map) + if k, _ := m.Get("key"); k == key { + v, _ := m.Get("value") + values = append(values, fmt.Sprint(v)) + } + } + return values +} + +type AttributeOptions struct { + Environment, TargetType, Action string + // The attribute whose values to list; empty lists the keys. + Key string + Type string +} + +func (o AttributeOptions) check() error { + if o.TargetType == "" && o.Action == "" { + return errors.New("Either --target-type or --action must be specified.") + } + return nil +} + +func AttributeKeys(ctx context.Context, c *platform.Client, o AttributeOptions) error { + if err := o.check(); err != nil { + return err + } + keys, err := platform.AllPages[string](func(page, size int32) (*http.Response, error) { + return c.GetTargetAttributeKeys(ctx, &api.GetTargetAttributeKeysParams{ + Environment: o.Environment, TargetType: optional(o.TargetType), ActionId: optional(o.Action), Page: &page, Size: &size, + }) + }) + if err != nil { + return notFoundOr(err, o.Environment, "Failed to get the attribute keys of environment %s") + } + return printStrings(keys, "Key", "No attribute keys found.", o.Type) +} + +func AttributeValues(ctx context.Context, c *platform.Client, o AttributeOptions) error { + if err := o.check(); err != nil { + return err + } + values, err := platform.AllPages[string](func(page, size int32) (*http.Response, error) { + return c.GetTargetAttributeValues(ctx, &api.GetTargetAttributeValuesParams{ + Environment: o.Environment, TargetType: optional(o.TargetType), ActionId: optional(o.Action), AttributeKey: o.Key, Page: &page, Size: &size, + }) + }) + if err != nil { + return notFoundOr(err, o.Environment, "Failed to get the attribute values of environment %s") + } + return printStrings(values, "Value", "No values found for attribute "+o.Key+".", o.Type) +} + +func printStrings(values []string, title, none, datatype string) error { + list := make([]any, len(values)) + for i, v := range values { + list[i] = v + } + if printed, err := printAs(list, datatype); printed { + return err + } + if len(values) == 0 { + fmt.Println(none) + return nil + } + t := table.New(table.Column{Name: "value", Title: title, Alignment: table.Left}) + for _, v := range values { + t.AddRow(table.Default, table.Cell("value", v)) + } + t.Print() + return nil +} diff --git a/internal/target/target_test.go b/internal/target/target_test.go new file mode 100644 index 0000000..1b6885b --- /dev/null +++ b/internal/target/target_test.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package target_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/target" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func container(name string, attributes ...[2]string) map[string]any { + list := []any{} + for _, a := range attributes { + list = append(list, map[string]any{"key": a[0], "value": a[1]}) + } + return map[string]any{"name": name, "type": "container", "attributes": list} +} + +func TestQueryFollowsTheCursorUpToTheLimit(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/targets", func(r platformtest.Request) platformtest.Reply { + if r.Query["cursor"] == nil { + return platformtest.Reply{JSON: map[string]any{"items": []any{ + container("a", [2]string{"k8s.namespace", "shop"}, [2]string{"k8s.pod.name", "a-1"}, [2]string{"k8s.pod.name", "a-2"}), + container("b"), + }, "hasNext": true, "nextCursor": "c-2"}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{container("c", [2]string{"k8s.namespace", "shop"})}, "hasNext": true, "nextCursor": "c-3"}} + }) + + out, err := platformtest.Stdout(t, func() error { + return target.Query(ctx, p.Client, target.QueryOptions{ + Environment: "Global", TargetType: "container", Query: `k8s.namespace="shop"`, Attributes: []string{"k8s.namespace", "k8s.pod.name"}, Limit: 3, + }) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ a │ container │ shop │ a-1, a-2 │") + assert.Contains(t, out, "│ c │ container │ shop │ │") + requests := p.Requests("GET /api/targets") + require.Len(t, requests, 2) + assert.Equal(t, []string{"Global"}, requests[0].Query["environment"]) + assert.Equal(t, []string{"container"}, requests[0].Query["targetType"]) + assert.Equal(t, []string{`k8s.namespace="shop"`}, requests[0].Query["query"]) + assert.Equal(t, []string{"k8s.namespace", "k8s.pod.name"}, requests[0].Query["attribute"]) + assert.Equal(t, []string{"3"}, requests[0].Query["size"]) + assert.Equal(t, []string{"1"}, requests[1].Query["size"]) + assert.Equal(t, []string{"c-2"}, requests[1].Query["cursor"]) +} + +func TestQueryPrintsAsJSON(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/targets", platformtest.Reply{JSON: map[string]any{"items": []any{}, "hasNext": false}}) + + out, err := platformtest.Stdout(t, func() error { + return target.Query(ctx, p.Client, target.QueryOptions{Environment: "Global", Type: "json"}) + }) + + require.NoError(t, err) + assert.Equal(t, "[]\n", out) + assert.Equal(t, []string{"1000"}, p.Requests("GET /api/targets")[0].Query["size"]) + assert.NotContains(t, p.Requests("GET /api/targets")[0].Query, "targetType") +} + +func TestQueryReportsAMissingEnvironment(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/targets", platformtest.Reply{Status: http.StatusNotFound}) + + assert.EqualError(t, target.Query(ctx, p.Client, target.QueryOptions{Environment: "Nowhere"}), "Environment Nowhere not found.") +} + +func TestAttributeKeysAndValues(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/targets/attributes/keys", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"items": []any{"k8s.namespace"}, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{"k8s.pod.name"}}} + }) + p.Reply("GET /api/targets/attributes/values", platformtest.Reply{JSON: map[string]any{"items": []any{"shop", "kube-system"}}}) + + out, err := platformtest.Stdout(t, func() error { + if err := target.AttributeKeys(ctx, p.Client, target.AttributeOptions{Environment: "Global", TargetType: "container"}); err != nil { + return err + } + return target.AttributeValues(ctx, p.Client, target.AttributeOptions{Environment: "Global", Action: "com.example.attack", Key: "k8s.namespace", Type: "yaml"}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ k8s.namespace │\n│ k8s.pod.name │") + assert.Contains(t, out, "- shop\n- kube-system\n") + values := p.Requests("GET /api/targets/attributes/values")[0].Query + assert.Equal(t, []string{"k8s.namespace"}, values["attributeKey"]) + assert.Equal(t, []string{"com.example.attack"}, values["actionId"]) + assert.EqualError(t, target.AttributeKeys(ctx, p.Client, target.AttributeOptions{Environment: "Global"}), "Either --target-type or --action must be specified.") +} From 5037066af0cf059d64bfdb7a36154ec5c3e25855 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:53:57 +0200 Subject: [PATCH 17/25] feat(go): execution watch, experiment init; experiment delete restored `execution watch -i ID | -k KEY` follows a run live, redrawn in place on a terminal and a line per change otherwise; it never cancels the run. `experiment init` creates an experiment from a template by asking for its placeholders, team and environment, and writes it to a file. `experiment delete` had been left out of the Go port; it is back, and a test now pins every command of the TypeScript CLI. A timed-out run is reported once the platform has cancelled it, steps cut short count as errors, and table columns ignore colour codes. The fake platform prefers the most specific route and no longer paces requests. --- internal/cli/commands_test.go | 37 +++++ internal/cli/complete.go | 2 +- internal/cli/execution.go | 24 ++- internal/cli/experiment.go | 39 ++++- internal/execution/execution_test.go | 25 +++ internal/execution/watch.go | 215 +++++++++++++++++++++++++ internal/experiment/experiment.go | 39 +++++ internal/experiment/experiment_test.go | 81 ++++++++++ internal/experiment/init.go | 168 +++++++++++++++++++ internal/experiment/report.go | 25 ++- internal/platform/ratelimit.go | 7 + internal/platformtest/platformtest.go | 11 +- internal/prompt/prompt.go | 4 + internal/table/table.go | 16 +- internal/table/table_test.go | 7 + 15 files changed, 685 insertions(+), 15 deletions(-) create mode 100644 internal/cli/commands_test.go create mode 100644 internal/execution/watch.go create mode 100644 internal/experiment/init.go diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go new file mode 100644 index 0000000..12cb48d --- /dev/null +++ b/internal/cli/commands_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Every command of the TypeScript CLI, which pipelines call by name. Removing one is a +// breaking change; this list is what the Go CLI promised to keep. +var typeScriptCommands = []string{ + "advice validate-status", + "config show", "config profile add", "config profile list", "config profile ls", "config profile remove", "config profile select", + "experiment run", "experiment exec", "experiment get", "experiment apply", "experiment delete", "experiment dump", + "template list", "template get", + "execution get", "execution cancel", "execution property set", "execution property add", "execution artifact list", "execution artifact download", + "schedule list", "schedule get", "schedule apply", "schedule create", "schedule update", "schedule enable", "schedule disable", "schedule delete", + "service list", "service get", "service apply", "service delete", "service risk", + "service experiment list", "service experiment provide", "service experiment link", "service experiment unlink", + "service variable get", "service variable set", + "service-profile list", "service-profile get", "service-profile apply", "service-profile delete", +} + +func TestKeepsEveryCommandOfTheTypeScriptCLI(t *testing.T) { + root := newRoot() + for _, path := range typeScriptCommands { + cmd, rest, err := root.Find(strings.Fields(path)) + if assert.NoError(t, err, path) { + assert.Empty(t, rest, path) + assert.Contains(t, append(cmd.Aliases, cmd.Name()), strings.Fields(path)[len(strings.Fields(path))-1], path) + } + } +} diff --git a/internal/cli/complete.go b/internal/cli/complete.go index 6695e18..1e9c04b 100644 --- a/internal/cli/complete.go +++ b/internal/cli/complete.go @@ -188,7 +188,7 @@ func registerCompletions(root *cobra.Command) { register("team", completeTeams) register("template", completeTemplates) switch group { - case "experiment": + case "experiment", "execution": register("key", completeExperimentKeys) case "schedule": register("id", completeSchedules) diff --git a/internal/cli/execution.go b/internal/cli/execution.go index 45309e9..301ad7d 100644 --- a/internal/cli/execution.go +++ b/internal/cli/execution.go @@ -5,6 +5,8 @@ package cli import ( "context" + "errors" + "time" "github.com/spf13/cobra" "github.com/steadybit/cli/internal/execution" @@ -120,6 +122,26 @@ func newExecution() *cobra.Command { download.MarkFlagsMutuallyExclusive("output", "directory") artifact.AddCommand(list, download) - cmd.AddCommand(get, cancel, property, artifact) + var w execution.WatchOptions + watch := &cobra.Command{ + Use: "watch", + Short: "Follow an experiment run live until it ends: its steps, their targets and timings. Stopping the watch leaves the run alone.", + Args: cobra.NoArgs, + Example: examples( + "steadybit execution watch -i 1234", + "steadybit execution watch -k ADM-1", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + if (w.ID == 0) == (w.Key == "") { + return errors.New("Pass either --id or --key.") + } + return execution.Watch(ctx, c, w) + }), + } + watch.Flags().Int64VarP(&w.ID, "id", "i", 0, "The experiment run id.") + watch.Flags().StringVarP(&w.Key, "key", "k", "", "Watch the latest run of this experiment instead.") + watch.Flags().DurationVar(&w.Interval, "interval", 2*time.Second, "How often to refresh.") + + cmd.AddCommand(get, cancel, property, artifact, watch) return cmd } diff --git a/internal/cli/experiment.go b/internal/cli/experiment.go index d8a19d0..eec52b6 100644 --- a/internal/cli/experiment.go +++ b/internal/cli/experiment.go @@ -18,7 +18,7 @@ import ( func newExperiment() *cobra.Command { cmd := &cobra.Command{Use: "experiment", Short: "Check and run experiments."} - cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDump(), + cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDelete(), newExperimentDump(), newExperimentInit(), newDiff(gitops.Experiment, "experiment", "experiment.yml")) return cmd } @@ -170,3 +170,40 @@ func newExperimentDump() *cobra.Command { variadic(cmd, "team") return cmd } + +func newExperimentInit() *cobra.Command { + var o experiment.InitOptions + cmd := &cobra.Command{ + Use: "init", + Short: "Create an experiment from a template, answering its placeholders, and write it to a file.", + Args: cobra.NoArgs, + Example: examples( + "steadybit experiment init", + "steadybit experiment init --template d7e65100-1d20-4980-be87-c351704910b8 --team ADM -f checkout-latency.yml", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return experiment.Init(ctx, c, o) + }), + } + cmd.Flags().StringVar(&o.Template, "template", "", "The template to start from; asked for when not given.") + cmd.Flags().StringVar(&o.Team, "team", "", "The key of the team owning the experiment; asked for when not given.") + cmd.Flags().StringVar(&o.Environment, "environment", "", "The environment the experiment runs in; asked for when not given.") + cmd.Flags().StringVarP(&o.File, "file", "f", "", "The file to write the experiment to; asked for when not given.") + return cmd +} + +func newExperimentDelete() *cobra.Command { + var key string + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an experiment from Steadybit.", + Args: cobra.NoArgs, + Example: examples("steadybit experiment delete -k ADM-1"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return experiment.Delete(ctx, c, key) + }), + } + cmd.Flags().StringVarP(&key, "key", "k", "", "The experiment key.") + _ = cmd.MarkFlagRequired("key") + return cmd +} diff --git a/internal/execution/execution_test.go b/internal/execution/execution_test.go index 5b96c0b..0709351 100644 --- a/internal/execution/execution_test.go +++ b/internal/execution/execution_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/steadybit/cli/internal/execution" "github.com/steadybit/cli/internal/output" @@ -152,3 +153,27 @@ func TestNeverLetsAnIdStepOutOfADirectory(t *testing.T) { assert.Equal(t, "evil", execution.PathSegment("../../evil")) assert.Equal(t, "report.zip", execution.PathSegment("report.zip")) } + +func TestWatchPrintsChangesUntilTheRunEnds(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/experiments/ADM-1/executions", platformtest.Reply{JSON: map[string]any{"executions": []any{map[string]any{"id": 41}, map[string]any{"id": 42}}}}) + var polls int + p.Handle("GET /api/experiments/executions/42", func(platformtest.Request) platformtest.Reply { + polls++ + state, step := "RUNNING", "RUNNING" + if polls > 1 { + state, step = "FAILED", "FAILED" + } + return platformtest.Reply{JSON: map[string]any{"id": 42, "key": "ADM-1", "state": state, "reason": "check failed", "steps": []any{ + map[string]any{"stepType": "action", "actionId": "http-check", "state": step, "targetExecutions": []any{map[string]any{"state": step}}}, + }}} + }) + + out, err := platformtest.Stdout(t, func() error { + return execution.Watch(ctx, p.Client, execution.WatchOptions{Key: "ADM-1", Interval: time.Millisecond}) + }) + + assert.EqualError(t, err, "Experiment ADM-1 (#42) failed, reason: check failed") + assert.Equal(t, "Experiment ADM-1 run #42: running\n step 1/1 http-check: running (targets 0/1)\nExperiment ADM-1 run #42: failed\n step 1/1 http-check: failed (targets 1/1)\n", out) + assert.Empty(t, p.Requests("POST /api/experiments/executions/42/cancel"), "watching never cancels") +} diff --git a/internal/execution/watch.go b/internal/execution/watch.go new file mode 100644 index 0000000..55bf036 --- /dev/null +++ b/internal/execution/watch.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package execution + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/table" + "golang.org/x/term" +) + +type WatchOptions struct { + ID int64 + Key string + Interval time.Duration +} + +var ended = map[string]bool{"FAILED": true, "ERRORED": true, "CANCELED": true, "COMPLETED": true} + +// Latest is the most recent run of an experiment. +func Latest(ctx context.Context, c *platform.Client, key string) (int64, error) { + var list struct { + Executions []struct { + ID int64 `json:"id"` + } `json:"executions"` + } + resp, err := c.GetExperimentExecutions3(ctx, key, nil) + if _, err := platform.Decode(resp, err, &list); err != nil { + return 0, platform.Failed(err, "Failed to get the runs of experiment %s", key) + } + var latest int64 + for _, e := range list.Executions { + latest = max(latest, e.ID) + } + if latest == 0 { + return 0, fmt.Errorf("Experiment %s has not run yet.", key) + } + return latest, nil +} + +// Watch shows a run as it progresses, until it ends: redrawn in place on a terminal, +// as a line per change otherwise. It only watches; stopping it leaves the run alone. +func Watch(ctx context.Context, c *platform.Client, o WatchOptions) error { + id := o.ID + if o.Key != "" { + latest, err := Latest(ctx, c, o.Key) + if err != nil { + return err + } + id = latest + } + interval := o.Interval + if interval <= 0 { + interval = 2 * time.Second + } + live := term.IsTerminal(int(os.Stdout.Fd())) + drawn := 0 + previous := map[string]string{} + for { + doc, err := Fetch(ctx, c, id) + if err != nil { + return err + } + run := doc.Value() + state := str(run, "state") + if live { + frame := render(run, time.Now()) + // Back to the top of the previous frame, and clear it, before drawing. + if drawn > 0 { + fmt.Printf("\x1b[%dA\x1b[J", drawn) + } + fmt.Print(frame) + drawn = strings.Count(frame, "\n") + } else { + changes(os.Stdout, run, previous) + } + if ended[state] { + if state != "COMPLETED" { + reason := str(run, "reason") + if reason != "" { + reason = ", reason: " + reason + } + return fmt.Errorf("Experiment %s (#%d) %s%s", str(run, "key"), id, strings.ToLower(state), reason) + } + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + } + } +} + +func parseTime(s string) time.Time { + t, _ := time.Parse(time.RFC3339Nano, s) + return t +} + +func elapsed(started, ended string, now time.Time) string { + from := parseTime(started) + if from.IsZero() { + return "" + } + to := parseTime(ended) + if to.IsZero() { + to = now + } + return to.Sub(from).Round(time.Second).String() +} + +func stepName(step *jsyaml.Map) string { + if label := str(step, "customLabel"); label != "" { + return label + } + if action := str(step, "actionId"); action != "" { + return action + } + if strings.EqualFold(str(step, "stepType"), "wait") { + if params, ok := step.Get("parameters"); ok { + if m, ok := params.(*jsyaml.Map); ok { + return "wait " + str(m, "duration") + } + } + } + return strings.ToLower(str(step, "stepType")) +} + +// targets summarises a step's target executions: how many ended, of how many. +func targets(step *jsyaml.Map) string { + all := list(step, "targetExecutions") + if len(all) == 0 { + return "" + } + done := 0 + for _, t := range all { + if m, ok := t.(*jsyaml.Map); ok && ended[str(m, "state")] { + done++ + } + } + return fmt.Sprintf("%d/%d", done, len(all)) +} + +func colorState(state string) string { + lower := strings.ToLower(state) + switch state { + case "COMPLETED": + return output.Green(lower) + case "FAILED", "ERRORED": + return output.Red(lower) + case "RUNNING": + return output.Bold(lower) + } + return lower +} + +func render(run *jsyaml.Map, now time.Time) string { + var b strings.Builder + fmt.Fprintf(&b, "%s %s · run #%s · %s · %s\n", output.Bold(str(run, "key")), str(run, "name"), + number(run, "id"), colorState(str(run, "state")), elapsed(str(run, "started"), str(run, "ended"), now)) + t := table.New( + table.Column{Name: "n", Title: "#"}, + table.Column{Name: "step", Title: "Step", Alignment: table.Left}, + table.Column{Name: "state", Title: "State", Alignment: table.Left}, + table.Column{Name: "targets", Title: "Targets"}, + table.Column{Name: "time", Title: "Time"}, + ) + for i, s := range list(run, "steps") { + step, _ := s.(*jsyaml.Map) + t.AddRow(table.Default, table.Cell("n", i+1), table.Cell("step", stepName(step)), table.Cell("state", colorState(str(step, "state"))), + table.Cell("targets", targets(step)), table.Cell("time", elapsed(str(step, "started"), str(step, "ended"), now))) + } + b.WriteString(t.Render()) + b.WriteString("\n") + return b.String() +} + +func number(m *jsyaml.Map, key string) string { + v, _ := m.Get(key) + if f, ok := v.(float64); ok { + return jsyaml.NumberString(f) + } + return fmt.Sprint(v) +} + +// changes prints what moved since the last poll, one line each, for logs and pipes. +func changes(w io.Writer, run *jsyaml.Map, previous map[string]string) { + if state := str(run, "state"); previous["run"] != state { + previous["run"] = state + fmt.Fprintf(w, "Experiment %s run #%s: %s\n", str(run, "key"), number(run, "id"), strings.ToLower(state)) + } + steps := list(run, "steps") + for i, s := range steps { + step, _ := s.(*jsyaml.Map) + key := fmt.Sprint("step", i) + now := str(step, "state") + " " + targets(step) + if previous[key] != now { + previous[key] = now + line := fmt.Sprintf(" step %d/%d %s: %s", i+1, len(steps), stepName(step), strings.ToLower(str(step, "state"))) + if t := targets(step); t != "" { + line += fmt.Sprintf(" (targets %s)", t) + } + fmt.Fprintln(w, line) + } + } +} diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index ba4052f..41839c6 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -92,6 +92,19 @@ func Get(ctx context.Context, c *platform.Client, o GetOptions) error { return nil } +// Delete removes an experiment. +func Delete(ctx context.Context, c *platform.Client, key string) error { + _, _, err := platform.Read(c.DeleteExperiment(ctx, key)) + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Experiment %s not found.", key) + } + if err != nil { + return platform.Failed(err, "Failed to delete the experiment. HTTP request failed.") + } + fmt.Printf("Experiment %s deleted.\n", key) + return nil +} + // ResolveFiles expands directories into their YAML files, recursively on request. func ResolveFiles(paths []string, recursive bool) ([]string, error) { var files []string @@ -470,6 +483,26 @@ type WaitOptions struct { Steps bool } +// settle waits a little for a cancelled run to end, and returns it as it last was. +func settle(ctx context.Context, c *platform.Client, path string, last *RunResult) *RunResult { + for range 10 { + time.Sleep(PollInterval) + body, _, err := platform.Read(c.Get(ctx, path)) + if err != nil { + return last + } + run, err := parseRun(body) + if err != nil { + return last + } + last = run + if terminal[run.State] { + return run + } + } + return last +} + // ErrTimedOut is returned when --timeout cancelled the run. var ErrTimedOut = errors.New("timed out") @@ -534,6 +567,12 @@ func wait(ctx context.Context, c *platform.Client, location string, o WaitOption if !terminal[run.State] { if !deadline.IsZero() && time.Now().After(deadline) { cancel(fmt.Sprintf("Experiment run %d did not end within %s", run.ID, o.Timeout)) + // Reported once the platform has stopped it, so the report shows what was + // cut short rather than a run that seems to be still going. + run = settle(ctx, c, path, run) + if run.Reason == "" { + run.Reason = fmt.Sprintf("did not end within %s", o.Timeout) + } return run, fmt.Errorf("Experiment %s (#%d) did not end within %s and was canceled: %w", run.Key, run.ID, o.Timeout, ErrTimedOut) } continue diff --git a/internal/experiment/experiment_test.go b/internal/experiment/experiment_test.go index 07b18b8..1910d90 100644 --- a/internal/experiment/experiment_test.go +++ b/internal/experiment/experiment_test.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync/atomic" "testing" "time" @@ -18,6 +19,7 @@ import ( "github.com/steadybit/cli/internal/output" "github.com/steadybit/cli/internal/platform" "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/prompt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -482,3 +484,82 @@ func TestJQFiltersWhatGetPrints(t *testing.T) { require.NoError(t, err) assert.Equal(t, "10s\n", out) } + +func TestInitAsksForThePlaceholdersAndWritesTheExperiment(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/experiments/templates", platformtest.Reply{JSON: map[string]any{"templates": []any{ + map[string]any{"id": templateID, "templateTitle": "Checkout survives latency"}, + }}}) + p.Reply("GET /api/experiments/templates/"+templateID, platformtest.Reply{JSON: map[string]any{ + "templateTitle": "Checkout survives latency", + "placeholders": []any{map[string]any{"key": "DELAY", "name": "Delay", "description": "How slow the network gets."}}, + }}) + p.Reply("POST /api/experiments/templates/"+templateID+"/experiment-create", platformtest.Reply{Status: http.StatusCreated, Headers: map[string]string{"Location": p.URL + "/api/experiments/ADM-7"}}) + p.Reply("GET /api/experiments/ADM-7", platformtest.Reply{Body: `{"key":"ADM-7","name":"Checkout survives latency","team":"ADM"}`}) + experiment.Interactive = func() bool { return true } + t.Cleanup(func() { experiment.Interactive = func() bool { return false } }) + file := filepath.Join(t.TempDir(), "checkout.yml") + // search, template number, placeholder, team, environment (default), file + prompt.UseInput(strings.NewReader("checkout\n1\n500ms\nADM\n\n" + file + "\n")) + + out, err := platformtest.Stdout(t, func() error { return experiment.Init(ctx, p.Client, experiment.InitOptions{}) }) + + require.NoError(t, err) + assert.Contains(t, out, "How slow the network gets.\n? Delay (DELAY): ") + assert.Contains(t, out, "Experiment ADM-7 created. Run it with:\n\n steadybit experiment run -f "+file+"\n") + assert.Equal(t, map[string]any{"team": "ADM", "environment": "Global", "placeholders": []any{map[string]any{"key": "DELAY", "value": "500ms"}}}, + p.Requests("POST /api/experiments/templates/" + templateID + "/experiment-create")[0].JSON(t)) + content, _ := os.ReadFile(file) + assert.Equal(t, "key: ADM-7\nname: Checkout survives latency\nteam: ADM\n", string(content)) +} + +func TestInitNeedsATerminal(t *testing.T) { + experiment.Interactive = func() bool { return false } + + err := experiment.Init(ctx, nil, experiment.InitOptions{}) + + assert.ErrorContains(t, err, "needs a terminal. In scripts, use `experiment apply --template`.") +} + +// The report shows the run once the platform has stopped it, not the moment it was cut. +func TestATimedOutRunIsReportedAsCanceled(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/TST-1/execute", started(p, "TST-1", 1)) + var canceled atomic.Bool + p.Handle("GET /api/experiments/executions/1", func(platformtest.Request) platformtest.Reply { + state := "RUNNING" + if canceled.Load() { + state = "CANCELED" + } + return platformtest.Reply{JSON: map[string]any{"id": 1, "key": "TST-1", "state": state, + "steps": []any{map[string]any{"stepType": "WAIT", "state": state}}}} + }) + p.Handle("POST /api/experiments/executions/1/cancel", func(platformtest.Request) platformtest.Reply { + canceled.Store(true) + return platformtest.Reply{Status: http.StatusAccepted} + }) + report := filepath.Join(t.TempDir(), "r.xml") + + _, err := platformtest.Stdout(t, func() error { + return experiment.Run(ctx, p.Client, experiment.RunOptions{Key: "TST-1", Yes: true, Wait: true, Report: report, WaitOptions: experiment.WaitOptions{Timeout: time.Nanosecond}}) + }) + + assert.ErrorIs(t, err, experiment.ErrTimedOut) + content, _ := os.ReadFile(report) + assert.Contains(t, string(content), ``) + assert.Contains(t, string(content), ` + did not end within 1ns`) + assert.Contains(t, string(content), `errors="1" skipped="1"`) +} + +func TestDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/experiments/TST-1", platformtest.Reply{}) + p.Reply("DELETE /api/experiments/TST-9", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { return experiment.Delete(ctx, p.Client, "TST-1") }) + + require.NoError(t, err) + assert.Equal(t, "Experiment TST-1 deleted.\n", out) + assert.EqualError(t, experiment.Delete(ctx, p.Client, "TST-9"), "Experiment TST-9 not found.") +} diff --git a/internal/experiment/init.go b/internal/experiment/init.go new file mode 100644 index 0000000..2ec0910 --- /dev/null +++ b/internal/experiment/init.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package experiment + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "regexp" + "strconv" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/prompt" + "golang.org/x/term" +) + +type InitOptions struct { + Template string + Team string + Environment string + File string +} + +// Interactive reports whether questions can be asked. Tests replace it. +var Interactive = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } + +type templateDetails struct { + Title string `json:"templateTitle"` + Placeholders []struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + } `json:"placeholders"` +} + +// Init walks through creating an experiment from a template: which template, its +// placeholders, the team and environment. It creates the experiment and writes it to a +// file, ready for `run -f` and for Git. +func Init(ctx context.Context, c *platform.Client, o InitOptions) error { + if !Interactive() { + return errors.New("`experiment init` asks questions and needs a terminal. In scripts, use `experiment apply --template`.") + } + var err error + if o.Template == "" { + if o.Template, err = chooseTemplate(ctx, c); err != nil { + return err + } + } + var id openapi_types.UUID + if err := id.UnmarshalText([]byte(o.Template)); err != nil { + return fmt.Errorf("Experiment template %s not found.", o.Template) + } + var template templateDetails + resp, err := c.GetExperimentTemplate(ctx, id) + if _, err := platform.Decode(resp, err, &template); err != nil { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Experiment template %s not found.", o.Template) + } + return platform.Failed(err, "Failed to get experiment template %s", o.Template) + } + + fmt.Printf("\n%s\n\n", output.Bold(template.Title)) + placeholders := jsyaml.NewMap() + for _, p := range template.Placeholders { + if p.Description != "" { + fmt.Println(strings.TrimSpace(p.Description)) + } + value, err := prompt.Input(fmt.Sprintf("%s (%s):", p.Name, p.Key), "", prompt.NotBlank) + if err != nil { + return err + } + placeholders.Set(p.Key, value) + } + if o.Team == "" { + if o.Team, err = prompt.Input("Team key:", "", prompt.NotBlank); err != nil { + return err + } + } + if o.Environment == "" { + if o.Environment, err = prompt.Input("Environment:", "Global", prompt.NotBlank); err != nil { + return err + } + } + if o.File == "" { + suggested := slug(template.Title) + ".yml" + if o.File, err = prompt.Input("Write it to:", suggested, prompt.NotBlank); err != nil { + return err + } + } + + request, err := createRequest(TemplateOptions{Template: o.Template, Team: o.Team, Environment: o.Environment, Placeholder: placeholders}) + if err != nil { + return err + } + reset := true + _, created, err := platform.Read(c.CreateExperimentByTemplate(ctx, id, &api.CreateExperimentByTemplateParams{ResetProperties: &reset}, request)) + if err != nil { + return platform.Failed(err, "Failed to create the experiment from template %s", o.Template) + } + key := keyFromLocation(created) + if err := Get(ctx, c, GetOptions{Key: key, File: o.File}); err != nil { + return err + } + fmt.Printf("\nExperiment %s created. Run it with:\n\n steadybit experiment run -f %s\n", key, o.File) + return nil +} + +func chooseTemplate(ctx context.Context, c *platform.Client) (string, error) { + search, err := prompt.Input("Search templates:", "", func(string) error { return nil }) + if err != nil { + return "", err + } + params := &api.GetExperimentTemplatesParams{} + if search != "" { + params.FreeTextPhrases = &[]string{search} + } + var list struct { + Templates []struct { + ID string `json:"id"` + Title string `json:"templateTitle"` + } `json:"templates"` + } + resp, err := c.GetExperimentTemplates(ctx, params) + if _, err := platform.Decode(resp, err, &list); err != nil { + return "", platform.Failed(err, "Failed to get the experiment templates") + } + if len(list.Templates) == 0 { + return "", fmt.Errorf("No experiment templates match '%s'.", search) + } + const shown = 20 + for i, t := range list.Templates { + if i == shown { + fmt.Printf(" … and %d more; search more precisely to see them.\n", len(list.Templates)-shown) + break + } + fmt.Printf(" %2d) %s\n", i+1, t.Title) + } + choice, err := prompt.Input("Template:", "1", func(v string) error { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > min(len(list.Templates), shown) { + return fmt.Errorf("choose one of the numbers above") + } + return nil + }) + if err != nil { + return "", err + } + n, _ := strconv.Atoi(choice) + return list.Templates[n-1].ID, nil +} + +var nonWord = regexp.MustCompile(`[^a-z0-9]+`) + +func slug(s string) string { + s = strings.Trim(nonWord.ReplaceAllString(strings.ToLower(s), "-"), "-") + if s == "" { + return "experiment" + } + return s +} diff --git a/internal/experiment/report.go b/internal/experiment/report.go index c437860..9d46d13 100644 --- a/internal/experiment/report.go +++ b/internal/experiment/report.go @@ -73,10 +73,10 @@ func parseRun(body []byte) (*RunResult, error) { case name != "": case s.ActionID != "": name = s.ActionID - case s.StepType == "wait": + case strings.EqualFold(s.StepType, "wait") && s.Parameters["duration"] != nil: name = fmt.Sprintf("wait %v", s.Parameters["duration"]) default: - name = s.StepType + name = strings.ToLower(s.StepType) } run.Steps = append(run.Steps, Step{Name: name, State: s.State, Reason: s.Reason, Started: s.Started, Ended: s.Ended}) } @@ -156,8 +156,13 @@ func junitCaseFor(className, name, state, reason string, d time.Duration) junitC c.Failure = &junitProblem{Message: message, Type: state, Text: reason} case "ERRORED": c.Error = &junitProblem{Message: message, Type: state, Text: reason} - case "CANCELED", "SKIPPED", "CREATED", "PREPARED", "": - c.Skipped = &struct{}{} + case "CANCELED", "SKIPPED", "CREATED", "PREPARED", "", "COMPLETED": + if state != "COMPLETED" { + c.Skipped = &struct{}{} + } + default: + // Still going when the run was cut short, by a timeout for instance. + c.Error = &junitProblem{Message: "did not end: " + message, Type: state, Text: reason} } return c } @@ -195,13 +200,21 @@ func junit(runs []*RunResult) ([]byte, error) { // A run that ended badly without any step to blame, a canceled or timed-out run // for instance, still fails its suite. if run.State != "COMPLETED" && suite.Failures == 0 && suite.Errors == 0 { - suite.Cases = append(suite.Cases, junitCaseFor(run.Key, "run", run.State, run.Reason, run.Duration())) - suite.Tests++ + message := strings.ToLower(run.State) + if run.Reason != "" { + message += ": " + run.Reason + } + problem := &junitProblem{Message: message, Type: run.State, Text: run.Reason} + c := junitCase{ClassName: run.Key, Name: "run", Time: seconds(run.Duration())} if run.State == "FAILED" { + c.Failure = problem suite.Failures++ } else { + c.Error = problem suite.Errors++ } + suite.Cases = append(suite.Cases, c) + suite.Tests++ } suites.Tests += suite.Tests suites.Failures += suite.Failures diff --git a/internal/platform/ratelimit.go b/internal/platform/ratelimit.go index 8498b46..3adc0c0 100644 --- a/internal/platform/ratelimit.go +++ b/internal/platform/ratelimit.go @@ -121,6 +121,13 @@ var ( sharedLimiterOnce sync.Once ) +// SetLimiter replaces the shared limiter. Tests use it so that a suite's requests are +// not paced to the platform's allowance. +func SetLimiter(l *RateLimiter) { + sharedLimiterOnce.Do(func() {}) + sharedLimiter = l +} + // Limiter is built on first use, so that a command that sends nothing never reads, or // complains about, the environment. func Limiter() *RateLimiter { diff --git a/internal/platformtest/platformtest.go b/internal/platformtest/platformtest.go index 565715b..2d8883b 100644 --- a/internal/platformtest/platformtest.go +++ b/internal/platformtest/platformtest.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/steadybit/cli/internal/platform" ) @@ -57,6 +58,8 @@ type Platform struct { // New starts a fake platform and points the CLI configuration at it. func New(t *testing.T) *Platform { t.Helper() + // The fake platform meters nothing, and a suite would soon exhaust the real allowance. + platform.SetLimiter(platform.NewRateLimiter(platform.Bucket{Burst: 1 << 30, RefillTokens: 1 << 30, RefillInterval: time.Second}, nil)) p := &Platform{t: t, routes: map[string]func(Request) Reply{}} p.server = httptest.NewServer(http.HandlerFunc(p.serve)) t.Cleanup(p.server.Close) @@ -115,12 +118,16 @@ func (p *Platform) serve(w http.ResponseWriter, r *http.Request) { request := Request{Method: r.Method, Path: r.URL.EscapedPath(), Query: r.URL.Query(), Header: r.Header, Body: body} p.mu.Lock() p.requests = append(p.requests, request) + // The most specific route answers: `/schedules/v2` over `/schedules/*`. Map order is + // random, so taking the first match made tests pass or fail by chance. var handler func(Request) Reply + best := -1 for route, h := range p.routes { method, path, _ := strings.Cut(route, " ") if method == r.Method && matches(path, request.Path) { - handler = h - break + if exact := strings.Count(path, "/") - strings.Count(path, "*"); exact > best { + handler, best = h, exact + } } } p.mu.Unlock() diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index da1caa9..e66ef76 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -8,6 +8,7 @@ package prompt import ( "bufio" "fmt" + "io" "os" "strings" @@ -17,6 +18,9 @@ import ( var reader = bufio.NewReader(os.Stdin) +// UseInput reads answers from r instead of the terminal. Tests script a dialogue with it. +func UseInput(r io.Reader) { reader = bufio.NewReader(r) } + type Validator func(string) error func NotBlank(value string) error { diff --git a/internal/table/table.go b/internal/table/table.go index 8d78d1e..52b559d 100644 --- a/internal/table/table.go +++ b/internal/table/table.go @@ -7,6 +7,7 @@ package table import ( "fmt" + "regexp" "strings" "github.com/mattn/go-runewidth" @@ -82,8 +83,15 @@ func (c Column) title() string { return c.Name } -func pad(s string, width int, a Alignment) string { - gap := strings.Repeat(" ", width-runewidth.StringWidth(s)) +// ansi matches colour codes, which take no room on screen. +var ansi = regexp.MustCompile("\x1b\\[[0-9;]*m") + +// width is how many columns s takes on screen: wide characters count twice, colour +// codes not at all. +func width(s string) int { return runewidth.StringWidth(ansi.ReplaceAllString(s, "")) } + +func pad(s string, w int, a Alignment) string { + gap := strings.Repeat(" ", w-width(s)) if a == Left { return s + gap } @@ -103,9 +111,9 @@ func colored(s string, c Color) string { func (t *Table) Render() string { widths := make([]int, len(t.columns)) for i, c := range t.columns { - widths[i] = runewidth.StringWidth(c.title()) + widths[i] = width(c.title()) for _, r := range t.rows { - widths[i] = max(widths[i], runewidth.StringWidth(r.cells[c.Name])) + widths[i] = max(widths[i], width(r.cells[c.Name])) } } line := func(left, middle, right string) string { diff --git a/internal/table/table_test.go b/internal/table/table_test.go index eaaa32c..8ad7d13 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -25,3 +25,10 @@ func TestTakesColumnsFromTheRowsWhenNoneAreDeclared(t *testing.T) { assert.Equal(t, "┌────────┬────────┐\n│ target │ advice │\n├────────┼────────┤\n│ a │ b │\n└────────┴────────┘", tbl.Render()) } + +func TestColouredCellsKeepTheColumnsAligned(t *testing.T) { + tbl := New(Column{Name: "s", Title: "State", Alignment: Left}) + tbl.AddRow(Default, Cell("s", "\x1b[32mcompleted\x1b[0m")) + + assert.Equal(t, "┌───────────┐\n│ State │\n├───────────┤\n│ \x1b[32mcompleted\x1b[0m │\n└───────────┘", tbl.Render()) +} From a00d8055d0da44a21cf72229fe0b6171c3091ce8 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:54:33 +0200 Subject: [PATCH 18/25] build: go-difflib is a direct dependency --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 7e91795..ef6c63d 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/itchyny/gojq v0.12.19 github.com/mattn/go-runewidth v0.0.30 github.com/oapi-codegen/runtime v1.7.0 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.12.1 diff --git a/go.sum b/go.sum index 36282e2..0afaa6f 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= From 790312325df2a7622d2572c06ce9eb985a78d00f Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:54:35 +0200 Subject: [PATCH 19/25] feat(go): hubs --- CHANGELOG.md | 2 + internal/cli/hub.go | 71 ++++++++++++++++++ internal/cli/root.go | 2 +- internal/hub/hub.go | 156 +++++++++++++++++++++++++++++++++++++++ internal/hub/hub_test.go | 88 ++++++++++++++++++++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 internal/cli/hub.go create mode 100644 internal/hub/hub.go create mode 100644 internal/hub/hub_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ade26..6abe848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ - `target query` lists the targets of an environment, by type and query, with the attributes asked for as columns; `target attribute keys|values` lists what can be queried. `action list` and `action get` show the actions experiments can use. +- `hub` commands to `list`, `get`, `apply`, `delete` and `resync` the hubs templates are + imported from. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/cli/hub.go b/internal/cli/hub.go new file mode 100644 index 0000000..52eb754 --- /dev/null +++ b/internal/cli/hub.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/hub" + "github.com/steadybit/cli/internal/platform" +) + +func newHub() *cobra.Command { + cmd := &cobra.Command{Use: "hub", Short: "Manage the hubs experiment templates are imported from."} + + list := &cobra.Command{ + Use: "list", + Short: "List hubs.", + Args: cobra.NoArgs, + Example: examples("steadybit hub list"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.List(ctx, c) }), + } + + var g hub.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a hub. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit hub get -i " + hubID + " -f hub.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.Get(ctx, c, g) }), + } + idFlag(get, &g.ID, "The hub id.") + outputFlags(get, &g.File, &g.Type, "hub") + + var a hub.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update hubs from files. A file without an id creates a hub, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples("steadybit hub apply -f hub.yml --synchronize"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.Apply(ctx, c, a) }), + } + fileFlags(apply, &a.Files, &a.Recursive, "hub") + apply.Flags().BoolVar(&a.Synchronize, "synchronize", false, "Fetch the hub's templates from its repository, waiting until it is done.") + + var d hub.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a hub. The templates imported from it are kept unless --delete-imported-templates is given.", + Args: cobra.NoArgs, + Example: examples("steadybit hub delete -i " + hubID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.Delete(ctx, c, d) }), + } + idFlag(del, &d.ID, "The hub id.") + del.Flags().BoolVar(&d.Templates, "delete-imported-templates", false, "Also delete the templates imported from the hub.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + var resyncID string + resync := &cobra.Command{ + Use: "resync", + Short: "Fetch a hub's templates from its repository again, waiting until it is done.", + Args: cobra.NoArgs, + Example: examples("steadybit hub resync -i " + hubID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.Resync(ctx, c, resyncID) }), + } + idFlag(resync, &resyncID, "The hub id.") + + cmd.AddCommand(list, get, apply, del, resync) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b5b9ef0..0d39c0b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newIntegration(), newKillswitch(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser()) + root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newHub(), newIntegration(), newKillswitch(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..b26421c --- /dev/null +++ b/internal/hub/hub.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package hub implements the `hub` commands. +package hub + +import ( + "context" + "fmt" + "net/http" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// What the last synchronisation found and who edited the hub is the platform's. The +// version is dropped as `service get` drops it. +var readOnly = []string{"version", "templates", "lastSync", "lastRepositoryChange", "syncError", "created", "createdBy", "edited", "editedBy"} + +func uuid(id string) (openapi_types.UUID, error) { + u, ok := resource.UUID(id) + if !ok { + return u, fmt.Errorf("Hub %s not found.", id) + } + return u, nil +} + +func notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Hub %s not found.", id) + } + return platform.Failed(err, format, id) +} + +func List(ctx context.Context, c *platform.Client) error { + var summaries struct { + Hubs []struct { + ID string `json:"id"` + HubName string `json:"hubName"` + } `json:"hubs"` + } + resp, err := c.GetHubs(ctx) + if _, err := platform.Decode(resp, err, &summaries); err != nil { + return platform.Failed(err, "Failed to get the hubs") + } + if len(summaries.Hubs) == 0 { + fmt.Println("No hubs found.") + return nil + } + t := table.New(table.Column{Name: "id", Title: "Id", Alignment: table.Left}, table.Column{Name: "name", Title: "Name", Alignment: table.Left}) + for _, h := range summaries.Hubs { + t.AddRow(table.Default, table.Cell("id", h.ID), table.Cell("name", h.HubName)) + } + t.Print() + return nil +} + +type GetOptions struct { + ID, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, o GetOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + doc, _, err := platform.ReadDocument(c.GetHubById(ctx, id)) + if err != nil { + return notFoundOr(err, o.ID, "Failed to get hub %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Hub %s written to %s.\n", o.ID, o.File) + } + return nil +} + +type ApplyOptions struct { + Files []string + Recursive bool + Synchronize bool +} + +func Apply(ctx context.Context, c *platform.Client, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "hub", func(file string, doc *output.Document) (resource.Applied, error) { + name, _ := doc.Get("hubName") + if name == "" { + return resource.Applied{}, fmt.Errorf("Hub file '%s' does not name a hubName.", file) + } + var saved struct { + ID string `json:"id"` + HubName string `json:"hubName"` + } + resp, err := c.UpsertHubWithBody(ctx, &api.UpsertHubParams{Synchronize: &o.Synchronize}, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save hub %s", name) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Hub %s (%s) %s.\n", saved.HubName, saved.ID, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteOptions struct { + ID string + Templates bool + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + question := fmt.Sprintf("Delete hub %s? The templates imported from it are kept.", o.ID) + if o.Templates { + question = fmt.Sprintf("Delete hub %s and the templates imported from it?", o.ID) + } + if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteHub(ctx, id, &api.DeleteHubParams{DeleteImportedTemplates: &o.Templates})); err != nil { + return notFoundOr(err, o.ID, "Failed to delete hub %s") + } + fmt.Printf("Hub %s deleted.\n", o.ID) + return nil +} + +// Resync fetches the hub's repository again; the platform answers once it is done. +func Resync(ctx context.Context, c *platform.Client, idText string) error { + id, err := uuid(idText) + if err != nil { + return err + } + var hub struct { + HubName string `json:"hubName"` + Templates []any `json:"templates"` + SyncError string `json:"syncError"` + } + resp, err := c.ResyncHub(ctx, id) + if _, err := platform.Decode(resp, err, &hub); err != nil { + return notFoundOr(err, idText, "Failed to resynchronize hub %s") + } + if hub.SyncError != "" { + return fmt.Errorf("Hub %s could not be synchronized: %s", hub.HubName, hub.SyncError) + } + fmt.Printf("Hub %s synchronized, %d template(s).\n", hub.HubName, len(hub.Templates)) + return nil +} diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go new file mode 100644 index 0000000..1326019 --- /dev/null +++ b/internal/hub/hub_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package hub_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/hub" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a10" + +const stored = `{"hubName":"Reliability Hub","hubLink":"https://hub.example.com","repositoryUrl":"https://example.com/index.json","id":"` + id + `","version":2,` + + `"templates":[{"id":"t-1"}],"lastSync":"s","lastRepositoryChange":"r","syncError":null,"created":"c","createdBy":{},"edited":"e","editedBy":{}}` + +func TestList(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/hubs", platformtest.Reply{JSON: map[string]any{"hubs": []any{map[string]any{"id": id, "hubName": "Reliability Hub"}}}}) + + out, err := platformtest.Stdout(t, func() error { return hub.List(ctx, p.Client) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ Reliability Hub │") +} + +func TestGetAndApplyRoundTrip(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/hubs/"+id, platformtest.Reply{Body: stored}) + p.Reply("POST /api/hubs", platformtest.Reply{Body: stored}) + file := filepath.Join(t.TempDir(), "hub.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := hub.Get(ctx, p.Client, hub.GetOptions{ID: id, File: file}); err != nil { + return err + } + return hub.Apply(ctx, p.Client, hub.ApplyOptions{Files: []string{file}, Synchronize: true}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Hub Reliability Hub ("+id+") updated.") + content, _ := os.ReadFile(file) + assert.Equal(t, "hubName: Reliability Hub\nhubLink: https://hub.example.com\nrepositoryUrl: https://example.com/index.json\nid: "+id+"\n", string(content)) + sent := p.Requests("POST /api/hubs")[0] + assert.Equal(t, []string{"true"}, sent.Query["synchronize"]) + assert.Equal(t, map[string]any{"hubName": "Reliability Hub", "hubLink": "https://hub.example.com", "repositoryUrl": "https://example.com/index.json", "id": id}, sent.JSON(t)) +} + +func TestDeleteKeepsTemplatesUnlessAsked(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/hubs/"+id, platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + if err := hub.Delete(ctx, p.Client, hub.DeleteOptions{ID: id, Yes: true}); err != nil { + return err + } + return hub.Delete(ctx, p.Client, hub.DeleteOptions{ID: id, Templates: true, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Hub "+id+" deleted.\nHub "+id+" deleted.\n", out) + requests := p.Requests("DELETE /api/hubs/" + id) + assert.Equal(t, []string{"false"}, requests[0].Query["deleteImportedTemplates"]) + assert.Equal(t, []string{"true"}, requests[1].Query["deleteImportedTemplates"]) +} + +func TestResync(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/hubs/"+id+"/resync", platformtest.Reply{Body: stored}) + p.Reply("POST /api/hubs/0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a11/resync", platformtest.Reply{Body: `{"hubName":"Broken","templates":[],"syncError":"index.json not found"}`}) + p.Reply("POST /api/hubs/0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a12/resync", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { return hub.Resync(ctx, p.Client, id) }) + + require.NoError(t, err) + assert.Equal(t, "Hub Reliability Hub synchronized, 1 template(s).\n", out) + assert.EqualError(t, hub.Resync(ctx, p.Client, "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a11"), "Hub Broken could not be synchronized: index.json not found") + assert.EqualError(t, hub.Resync(ctx, p.Client, "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a12"), "Hub 0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a12 not found.") +} From ea4877863830627993c1e21a1c91faf682b1f28c Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:55:05 +0200 Subject: [PATCH 20/25] docs: everyday use, GitOps and CI features --- CHANGELOG.md | 22 +++++++++++++++++++++- README.md | 11 +++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d89a48b..cd40087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,27 @@ experiment, schedule and service files are written byte for byte as before. - Writing a new experiment's key back into a YAML file no longer rewrites the file: the key is added at the top and comments, anchors and formatting are kept. -- Shell completion: `steadybit completion bash|zsh|fish|powershell`. +- Shell completion: `steadybit completion bash|zsh|fish|powershell`, which completes + experiment keys, team keys and the ids of templates, schedules, services and profiles + from the platform. +- **Interrupting `experiment run --wait` now cancels the run it started**, before exiting + with 130 (Ctrl-C) or 143 (SIGTERM, as CI runners send when a job is cancelled), so an + aborted pipeline no longer leaves an attack running. `--keep-running-on-interrupt` keeps + the previous behaviour. +- `experiment run --wait` gains `--timeout` (cancel and fail a run that takes too long), + `--report` (a JUnit report with a test case per step, or JSON) and `--show-steps`. In + GitHub Actions a summary of every run is added to the job summary. +- `diff` for experiments, schedules, services and service profiles shows how files differ + from the platform, and exits with 2 when they do; `apply --dry-run` reports what an + apply would change. +- `export --team X -d dir`, `apply -d dir` and `diff -d dir` keep a team's experiments, + schedules, services and custom service profiles in Git as one project. +- Every listing prints the platform's items with `-t json` or `-t yaml`, and `--jq` filters + the JSON any command prints, without jq installed. +- `execution watch` follows a run live; `experiment init` creates an experiment from a + template by asking for its placeholders. +- `--profile ` uses a configured profile for one command. +- A GitHub Action, `uses: steadybit/cli@v5`, installs the CLI on a runner. - `experiment apply --template ` creates an experiment from an experiment template, or updates the one created before with the same `--external-id`. With `-k` it re-renders an existing experiment with new placeholder values. Placeholders are given with diff --git a/README.md b/README.md index 99abc73..e35b78e 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,17 @@ steadybit service-profile list --origin custom steadybit service-profile apply -f profile.yml ``` +## Everyday use + +```bash +steadybit experiment init # create an experiment from a template, answering its placeholders +steadybit execution watch -k ADM-1 # follow the latest run of an experiment live +steadybit experiment get -k ADM-1 --profile prod # use another configured profile for one command +``` + +Shell completion (`steadybit completion --help`) completes experiment keys, team keys and +the ids of templates, schedules, services and service profiles from the platform. + ## GitOps Keep a team's experiments, schedules, services and custom service profiles in Git: From 799cc2cfdb1215e608fb266140663d587f8337c9 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:57:03 +0200 Subject: [PATCH 21/25] feat(go): property definitions and associations --- CHANGELOG.md | 2 + internal/cli/property.go | 143 ++++++++++++++ internal/cli/root.go | 2 +- internal/property/property.go | 287 +++++++++++++++++++++++++++++ internal/property/property_test.go | 133 +++++++++++++ 5 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 internal/cli/property.go create mode 100644 internal/property/property.go create mode 100644 internal/property/property_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abe848..9634ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,8 @@ queried. `action list` and `action get` show the actions experiments can use. - `hub` commands to `list`, `get`, `apply`, `delete` and `resync` the hubs templates are imported from. +- `property definition` and `property association` commands to `list`, `get`, `apply` and + `delete` the properties experiments and services carry. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. diff --git a/internal/cli/property.go b/internal/cli/property.go new file mode 100644 index 0000000..877b28b --- /dev/null +++ b/internal/cli/property.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/property" +) + +const associationID = "0190d7b2-9e8f-7c6d-b5a4-3f2e1d0c9b8a" + +func propertyKeyFlag(cmd *cobra.Command, key *string) { + cmd.Flags().StringVarP(key, "key", "k", "", "The property key.") + _ = cmd.MarkFlagRequired("key") +} + +func newProperty() *cobra.Command { + cmd := &cobra.Command{Use: "property", Short: "Manage the properties experiments and services carry."} + cmd.AddCommand(newPropertyDefinition(), newPropertyAssociation()) + return cmd +} + +func newPropertyDefinition() *cobra.Command { + cmd := &cobra.Command{Use: "definition", Short: "Manage property definitions: a property's key, label and type."} + + list := &cobra.Command{ + Use: "list", + Short: "List property definitions.", + Args: cobra.NoArgs, + Example: examples("steadybit property definition list"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.ListDefinitions(ctx, c) + }), + } + + var g property.GetDefinitionOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a property definition. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit property definition get -k RESULT_COLOR -f result-color.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.GetDefinition(ctx, c, g) + }), + } + propertyKeyFlag(get, &g.Key) + outputFlags(get, &g.File, &g.Type, "property definition") + + var a property.ApplyDefinitionOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update property definitions from files. The key names the definition to update.", + Args: cobra.NoArgs, + Example: examples("steadybit property definition apply -f result-color.yml", "steadybit property definition apply -f ./properties -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.ApplyDefinitions(ctx, c, a) + }), + } + fileFlags(apply, &a.Files, &a.Recursive, "property definition") + apply.Flags().BoolVar(&a.DeleteValues, "delete-values", false, "Allow removing enum values still in use, deleting them where they are used.") + + var d property.DeleteDefinitionOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a property definition. Only one without associations can be deleted, unless --delete-associations is given.", + Args: cobra.NoArgs, + Example: examples("steadybit property definition delete -k RESULT_COLOR"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.DeleteDefinition(ctx, c, d) + }), + } + propertyKeyFlag(del, &d.Key) + del.Flags().BoolVar(&d.Associations, "delete-associations", false, "Also delete the associations of the property.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, get, apply, del) + return cmd +} + +func newPropertyAssociation() *cobra.Command { + cmd := &cobra.Command{Use: "association", Short: "Manage property associations: which experiments or services carry a property."} + + var l property.ListAssociationOptions + list := &cobra.Command{ + Use: "list", + Short: "List property associations.", + Args: cobra.NoArgs, + Example: examples("steadybit property association list", "steadybit property association list --key RESULT_COLOR --type EXPERIMENT"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.ListAssociations(ctx, c, l) + }), + } + list.Flags().StringVar(&l.Key, "key", "", "Only list associations of this property.") + list.Flags().StringVar(&l.Experiment, "experiment", "", "Only list associations given to this experiment, by key. Those for all experiments are not listed.") + list.Flags().StringVar(&l.Service, "service", "", "Only list associations given to this service, by id. Those for all services are not listed.") + list.Flags().StringVar(&l.Type, "type", "", `Only list "EXPERIMENT" or "SERVICE" associations.`) + + var g property.GetAssociationOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a property association. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit property association get -i " + associationID + " -f association.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.GetAssociation(ctx, c, g) + }), + } + idFlag(get, &g.ID, "The property association id.") + outputFlags(get, &g.File, &g.Type, "property association") + + var a property.ApplyAssociationOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update property associations from files. A file without an id creates one, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples("steadybit property association apply -f association.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.ApplyAssociations(ctx, c, a) + }), + } + fileFlags(apply, &a.Files, &a.Recursive, "property association") + + var d property.DeleteAssociationOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a property association. Only one whose values are not used can be deleted, unless --delete-values is given.", + Args: cobra.NoArgs, + Example: examples("steadybit property association delete -i " + associationID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.DeleteAssociation(ctx, c, d) + }), + } + idFlag(del, &d.ID, "The property association id.") + del.Flags().BoolVar(&d.DeleteValues, "delete-values", false, "Also delete the values experiments and schedules have for it.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, get, apply, del) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0d39c0b..f12805b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -69,7 +69,7 @@ func newRoot() *cobra.Command { root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") root.Flags().BoolP("version", "V", false, "output the version number") root.SetVersionTemplate("{{.Version}}\n") - root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newHub(), newIntegration(), newKillswitch(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser()) + root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newHub(), newIntegration(), newKillswitch(), newProperty(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser()) // Shell completion is new with the Go CLI; it gets examples like every other command. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { diff --git a/internal/property/property.go b/internal/property/property.go new file mode 100644 index 0000000..16ce541 --- /dev/null +++ b/internal/property/property.go @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package property implements the `property` commands: the definitions of the properties +// experiments and services carry, and their associations, which say who carries them. +package property + +import ( + "context" + "fmt" + "net/http" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// The version is dropped as `service get` drops it: kept in a file, it turns every apply +// after an edit in the UI into a conflict. +var readOnly = []string{"version"} + +func definitionNotFoundOr(err error, key, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Property definition %s not found.", key) + } + return platform.Failed(err, format, key) +} + +func ListDefinitions(ctx context.Context, c *platform.Client) error { + type definition struct { + Key, Label, DataType string + EnumValues []string `json:"enumValues"` + } + definitions, err := platform.AllPages[definition](func(page, size int32) (*http.Response, error) { + return c.GetPropertyDefinitions(ctx, &api.GetPropertyDefinitionsParams{Page: api.PageRequestAO{Page: &page, Size: &size}}) + }) + if err != nil { + return platform.Failed(err, "Failed to get the property definitions") + } + if len(definitions) == 0 { + fmt.Println("No property definitions found.") + return nil + } + t := table.New( + table.Column{Name: "key", Title: "Key", Alignment: table.Left}, + table.Column{Name: "label", Title: "Label", Alignment: table.Left}, + table.Column{Name: "dataType", Title: "Type", Alignment: table.Left}, + table.Column{Name: "enumValues", Title: "Values", Alignment: table.Left}, + ) + for _, d := range definitions { + t.AddRow(table.Default, table.Cell("key", d.Key), table.Cell("label", d.Label), table.Cell("dataType", d.DataType), + table.Cell("enumValues", values(d.EnumValues))) + } + t.Print() + return nil +} + +// values shows the first few enum values; `get` has them all. +func values(enum []string) string { + const shown = 5 + if len(enum) > shown { + return strings.Join(enum[:shown], ", ") + fmt.Sprintf(" and %d more", len(enum)-shown) + } + return strings.Join(enum, ", ") +} + +type GetDefinitionOptions struct { + Key, File, Type string +} + +func GetDefinition(ctx context.Context, c *platform.Client, o GetDefinitionOptions) error { + doc, _, err := platform.ReadDocument(c.GetPropertyDefinition(ctx, o.Key)) + if err != nil { + return definitionNotFoundOr(err, o.Key, "Failed to get property definition %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Property definition %s written to %s.\n", o.Key, o.File) + } + return nil +} + +type ApplyDefinitionOptions struct { + Files []string + Recursive bool + DeleteValues bool +} + +// ApplyDefinitions upserts definitions by their key; there is no id to write back. +func ApplyDefinitions(ctx context.Context, c *platform.Client, o ApplyDefinitionOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "property definition", func(file string, doc *output.Document) (resource.Applied, error) { + key, _ := doc.Get("key") + if key == "" { + return resource.Applied{}, fmt.Errorf("Property definition file '%s' does not name the key.", file) + } + resp, err := c.UpsertPropertyDefinitionWithBody(ctx, &api.UpsertPropertyDefinitionParams{DeleteValues: &o.DeleteValues}, "application/json", + resource.Body(resource.Strip(doc, readOnly...).Value())) + _, resp, err = platform.Read(resp, err) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save property definition %s", key) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Property definition %s %s.\n", key, resource.CreatedOrUpdated(created)) + return resource.Applied{Created: created}, nil + }) +} + +type DeleteDefinitionOptions struct { + Key string + Associations bool + Yes bool +} + +func DeleteDefinition(ctx context.Context, c *platform.Client, o DeleteDefinitionOptions) error { + question := fmt.Sprintf("Delete property definition %s?", o.Key) + if o.Associations { + question = fmt.Sprintf("Delete property definition %s and its associations?", o.Key) + } + if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { + return err + } + params := &api.DeletePropertyDefinitionParams{DeleteAssociations: &o.Associations} + if _, _, err := platform.Read(c.DeletePropertyDefinition(ctx, o.Key, params)); err != nil { + return definitionNotFoundOr(err, o.Key, "Failed to delete property definition %s") + } + fmt.Printf("Property definition %s deleted.\n", o.Key) + return nil +} + +func associationUUID(id string) (openapi_types.UUID, error) { + u, ok := resource.UUID(id) + if !ok { + return u, fmt.Errorf("Property association %s not found.", id) + } + return u, nil +} + +func associationNotFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Property association %s not found.", id) + } + return platform.Failed(err, format, id) +} + +type ListAssociationOptions struct { + Key, Experiment, Service, Type string +} + +func ListAssociations(ctx context.Context, c *platform.Client, o ListAssociationOptions) error { + params := api.GetAssociationsParams{} + if o.Key != "" { + params.Key = &o.Key + } + if o.Experiment != "" { + params.ExperimentKey = &o.Experiment + } + if o.Service != "" { + service, ok := resource.UUID(o.Service) + if !ok { + return fmt.Errorf("Service %s not found.", o.Service) + } + params.ServiceId = &service + } + if o.Type != "" { + kind := api.GetAssociationsParamsAssociationTypeAO(strings.ToUpper(o.Type)) + if !kind.Valid() { + return fmt.Errorf("--type must be EXPERIMENT or SERVICE, not '%s'.", o.Type) + } + params.AssociationTypeAO = &kind + } + type association struct { + ID, Key, AssociationType string + ExperimentKey, ServiceID *string + Required, EditableInExecution *bool + } + // The page size is the platform's; it takes only the page number. + associations, err := platform.AllPages[association](func(page, _ int32) (*http.Response, error) { + params.Page = &page + return c.GetAssociations(ctx, ¶ms) + }) + if err != nil { + return platform.Failed(err, "Failed to get the property associations") + } + if len(associations) == 0 { + fmt.Println("No property associations found.") + return nil + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "key", Title: "Key", Alignment: table.Left}, + table.Column{Name: "type", Title: "Type", Alignment: table.Left}, + table.Column{Name: "for", Title: "For", Alignment: table.Left}, + table.Column{Name: "required", Title: "Required", Alignment: table.Left}, + table.Column{Name: "editable", Title: "Editable in runs", Alignment: table.Left}, + ) + boolOr := func(b *bool) string { return fmt.Sprint(b != nil && *b) } + for _, a := range associations { + // Without an experiment or service, an association is for all of them. + target := "all" + switch { + case a.ExperimentKey != nil: + target = *a.ExperimentKey + case a.ServiceID != nil: + target = *a.ServiceID + } + t.AddRow(table.Default, table.Cell("id", a.ID), table.Cell("key", a.Key), table.Cell("type", a.AssociationType), table.Cell("for", target), + table.Cell("required", boolOr(a.Required)), table.Cell("editable", boolOr(a.EditableInExecution))) + } + t.Print() + return nil +} + +type GetAssociationOptions struct { + ID, File, Type string +} + +func GetAssociation(ctx context.Context, c *platform.Client, o GetAssociationOptions) error { + id, err := associationUUID(o.ID) + if err != nil { + return err + } + doc, _, err := platform.ReadDocument(c.GetPropertyDefinition1(ctx, id)) + if err != nil { + return associationNotFoundOr(err, o.ID, "Failed to get property association %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Property association %s written to %s.\n", o.ID, o.File) + } + return nil +} + +type ApplyAssociationOptions struct { + Files []string + Recursive bool +} + +func ApplyAssociations(ctx context.Context, c *platform.Client, o ApplyAssociationOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "property association", func(file string, doc *output.Document) (resource.Applied, error) { + key, _ := doc.Get("key") + if key == "" { + return resource.Applied{}, fmt.Errorf("Property association file '%s' does not name the property key.", file) + } + var saved struct{ ID, Key string } + resp, err := c.UpsertPropertyAssociationWithBody(ctx, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save the property association of %s", key) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Property association %s of %s %s.\n", saved.ID, saved.Key, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteAssociationOptions struct { + ID string + DeleteValues bool + Yes bool +} + +func DeleteAssociation(ctx context.Context, c *platform.Client, o DeleteAssociationOptions) error { + id, err := associationUUID(o.ID) + if err != nil { + return err + } + question := fmt.Sprintf("Delete property association %s?", o.ID) + if o.DeleteValues { + question = fmt.Sprintf("Delete property association %s and the values experiments and schedules have for it?", o.ID) + } + if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeletePropertyAssociation(ctx, id, &api.DeletePropertyAssociationParams{DeleteValues: &o.DeleteValues})); err != nil { + return associationNotFoundOr(err, o.ID, "Failed to delete property association %s") + } + fmt.Printf("Property association %s deleted.\n", o.ID) + return nil +} diff --git a/internal/property/property_test.go b/internal/property/property_test.go new file mode 100644 index 0000000..d3f1579 --- /dev/null +++ b/internal/property/property_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package property_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/property" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "0190d7b2-9e8f-7c6d-b5a4-3f2e1d0c9b8a" + +func TestListDefinitionsWalksEveryPage(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/properties/definitions", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"key": "COLOR", "label": "Color", "dataType": "ENUM", + "enumValues": []any{"a", "b", "c", "d", "e", "f", "g"}}}, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"key": "OWNER", "label": "Owner", "dataType": "STRING"}}}} + }) + + out, err := platformtest.Stdout(t, func() error { return property.ListDefinitions(ctx, p.Client) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ COLOR │ Color │ ENUM │ a, b, c, d, e and 2 more │") + assert.Contains(t, out, "│ OWNER │ Owner │ STRING │ │") + assert.Equal(t, []string{"100"}, p.Requests("GET /api/properties/definitions")[0].Query["size"]) +} + +func TestDefinitionRoundTripByKey(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/properties/definitions/COLOR", platformtest.Reply{Body: `{"key":"COLOR","label":"Color","dataType":"ENUM","enumValues":["red"],"version":3}`}) + p.Reply("POST /api/properties/definitions", platformtest.Reply{Body: `{"key":"COLOR"}`}) + file := filepath.Join(t.TempDir(), "color.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := property.GetDefinition(ctx, p.Client, property.GetDefinitionOptions{Key: "COLOR", File: file}); err != nil { + return err + } + return property.ApplyDefinitions(ctx, p.Client, property.ApplyDefinitionOptions{Files: []string{file}, DeleteValues: true}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Property definition COLOR updated.") + content, _ := os.ReadFile(file) + assert.Equal(t, "key: COLOR\nlabel: Color\ndataType: ENUM\nenumValues:\n - red\n", string(content)) + sent := p.Requests("POST /api/properties/definitions")[0] + assert.Equal(t, []string{"true"}, sent.Query["deleteValues"]) + assert.Equal(t, map[string]any{"key": "COLOR", "label": "Color", "dataType": "ENUM", "enumValues": []any{"red"}}, sent.JSON(t)) +} + +func TestDeleteDefinition(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/properties/definitions/COLOR", platformtest.Reply{}) + p.Reply("DELETE /api/properties/definitions/NOPE", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + return property.DeleteDefinition(ctx, p.Client, property.DeleteDefinitionOptions{Key: "COLOR", Associations: true, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Property definition COLOR deleted.\n", out) + assert.Equal(t, []string{"true"}, p.Requests("DELETE /api/properties/definitions/COLOR")[0].Query["deleteAssociations"]) + assert.EqualError(t, property.DeleteDefinition(ctx, p.Client, property.DeleteDefinitionOptions{Key: "NOPE", Yes: true}), "Property definition NOPE not found.") +} + +func TestListAssociationsWithFilters(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/properties/associations", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"items": []any{ + map[string]any{"id": id, "key": "COLOR", "associationType": "EXPERIMENT", "required": true}, + }, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{ + map[string]any{"id": "b", "key": "COLOR", "associationType": "EXPERIMENT", "experimentKey": "ADM-1", "editableInExecution": true}, + }}} + }) + + out, err := platformtest.Stdout(t, func() error { + return property.ListAssociations(ctx, p.Client, property.ListAssociationOptions{Key: "COLOR", Type: "experiment"}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ COLOR │ EXPERIMENT │ all │ true │ false │") + assert.Contains(t, out, "│ ADM-1 │ false │ true │") + q := p.Requests("GET /api/properties/associations")[0].Query + assert.Equal(t, []string{"COLOR"}, q["key"]) + assert.Equal(t, []string{"EXPERIMENT"}, q["associationTypeAO"]) + assert.NotContains(t, q, "size") + assert.EqualError(t, property.ListAssociations(ctx, p.Client, property.ListAssociationOptions{Type: "team"}), "--type must be EXPERIMENT or SERVICE, not 'team'.") +} + +func TestAssociationGetApplyDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/properties/associations/"+id, platformtest.Reply{Body: `{"key":"COLOR","associationType":"EXPERIMENT","id":"` + id + `","version":1}`}) + p.Reply("POST /api/properties/associations", platformtest.Reply{Status: http.StatusCreated, JSON: map[string]any{"id": id, "key": "COLOR"}}) + p.Reply("DELETE /api/properties/associations/"+id, platformtest.Reply{}) + dir := t.TempDir() + got := filepath.Join(dir, "got.yml") + created := filepath.Join(dir, "new.yml") + require.NoError(t, os.WriteFile(created, []byte("key: COLOR\nassociationType: SERVICE\n"), 0o644)) + + out, err := platformtest.Stdout(t, func() error { + if err := property.GetAssociation(ctx, p.Client, property.GetAssociationOptions{ID: id, File: got}); err != nil { + return err + } + if err := property.ApplyAssociations(ctx, p.Client, property.ApplyAssociationOptions{Files: []string{created}}); err != nil { + return err + } + return property.DeleteAssociation(ctx, p.Client, property.DeleteAssociationOptions{ID: id, Yes: true}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Property association "+id+" of COLOR created.\nProperty association "+id+" deleted.\n") + content, _ := os.ReadFile(got) + assert.Equal(t, "key: COLOR\nassociationType: EXPERIMENT\nid: "+id+"\n", string(content)) + content, _ = os.ReadFile(created) + assert.Equal(t, "id: "+id+"\nkey: COLOR\nassociationType: SERVICE\n", string(content)) + assert.Equal(t, []string{"false"}, p.Requests("DELETE /api/properties/associations/" + id)[0].Query["deleteValues"]) + assert.EqualError(t, property.GetAssociation(ctx, p.Client, property.GetAssociationOptions{ID: "x"}), "Property association x not found.") +} From 9b268501a5cd177dcef9836b8e9b2954c63aa026 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:57:54 +0200 Subject: [PATCH 22/25] fix(go): say why the platform refused to delete a team --- internal/cli/team.go | 4 ++-- internal/team/team.go | 8 +++++++- internal/team/team_test.go | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/cli/team.go b/internal/cli/team.go index e3c3432..bed5a76 100644 --- a/internal/cli/team.go +++ b/internal/cli/team.go @@ -53,9 +53,9 @@ func newTeam() *cobra.Command { var d team.DeleteOptions del := &cobra.Command{ Use: "delete", - Short: "Delete a team. Nothing may be running in it.", + Short: "Delete a team, with --purge-experiments, which the platform requires. Nothing may be running in it.", Args: cobra.NoArgs, - Example: examples("steadybit team delete -k OPS", "steadybit team delete -k OPS --purge-experiments --yes"), + Example: examples("steadybit team delete -k OPS --purge-experiments", "steadybit team delete -k OPS --purge-experiments --yes"), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.Delete(ctx, c, d) }), } teamKeyFlag(del, &d.Key) diff --git a/internal/team/team.go b/internal/team/team.go index 947c2a1..ada3c7e 100644 --- a/internal/team/team.go +++ b/internal/team/team.go @@ -137,7 +137,13 @@ func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { return err } - if _, _, err := platform.Read(c.DeleteTeam(ctx, o.Key, &api.DeleteTeamParams{PurgeIncludingExperiments: o.Experiments})); err != nil { + _, _, err := platform.Read(c.DeleteTeam(ctx, o.Key, &api.DeleteTeamParams{PurgeIncludingExperiments: o.Experiments})) + // The platform refuses without purging, even a team without experiments, and says + // nothing about why. + if !o.Experiments && platform.IsStatus(err, http.StatusBadRequest) { + return fmt.Errorf("Team %s was not deleted. The platform deletes a team only with --purge-experiments, which deletes its experiments and their runs too.", o.Key) + } + if err != nil { return notFoundOr(err, o.Key, "Failed to delete team %s") } fmt.Printf("Team %s deleted.\n", o.Key) diff --git a/internal/team/team_test.go b/internal/team/team_test.go index 9d78185..83fc3fc 100644 --- a/internal/team/team_test.go +++ b/internal/team/team_test.go @@ -74,6 +74,7 @@ func TestDeletePurgesOnlyWhenAsked(t *testing.T) { p := platformtest.New(t) p.Reply("DELETE /api/teams/OPS", platformtest.Reply{Body: stored}) p.Reply("DELETE /api/teams/NOPE", platformtest.Reply{Status: http.StatusNotFound}) + p.Reply("DELETE /api/teams/EMPTY", platformtest.Reply{Status: http.StatusBadRequest}) out, err := platformtest.Stdout(t, func() error { if err := team.Delete(ctx, p.Client, team.DeleteOptions{Key: "OPS", Yes: true}); err != nil { @@ -88,6 +89,8 @@ func TestDeletePurgesOnlyWhenAsked(t *testing.T) { assert.Equal(t, []string{"false"}, requests[0].Query["purgeIncludingExperiments"]) assert.Equal(t, []string{"true"}, requests[1].Query["purgeIncludingExperiments"]) assert.EqualError(t, team.Delete(ctx, p.Client, team.DeleteOptions{Key: "NOPE", Yes: true}), "Team NOPE not found.") + assert.EqualError(t, team.Delete(ctx, p.Client, team.DeleteOptions{Key: "EMPTY", Yes: true}), + "Team EMPTY was not deleted. The platform deletes a team only with --purge-experiments, which deletes its experiments and their runs too.") } func TestMembers(t *testing.T) { From 50257e2004b513b0b5289df3cb197fd172b40f6a Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 14:58:04 +0200 Subject: [PATCH 23/25] docs: the new command groups in the README --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index dc06235..6639394 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,47 @@ steadybit service-profile list --origin custom steadybit service-profile apply -f profile.yml ``` +### Environments, teams and more as files in Git + +Experiment templates, environments, teams, hubs, integrations and property definitions and +associations are managed the same way, with `list`, `get`, `apply` and `delete`: + +```bash +steadybit environment get -i -f environment.yml +steadybit team apply -f ./teams -R +steadybit integration webhook apply -f webhook.yml +steadybit template apply -f ./templates -R +``` + +Their parts have commands of their own: + +```bash +steadybit environment variable set -i region=eu +steadybit team member add -k ADM --email jane@example.com --role OWNER +steadybit team environment add -k ADM --environment Global +``` + +### Tenant administration + +```bash +steadybit access-token create --name ci --type TEAM --team ADM --expires-at 2026-12-31 +steadybit user invite --email jane@example.com --team ADM +steadybit killswitch status +steadybit audit-log --from 2026-09-01 -t json +steadybit report experiments-executed --group-by STATE --rollup MONTHLY +``` + +Commands that cannot be undone, such as `killswitch activate`, `access-token delete` or +`team member set`, ask for confirmation on a terminal; `--yes` skips the question. + +### Targets and actions + +```bash +steadybit target query -e Global --target-type com.steadybit.extension_container.container --attribute k8s.namespace +steadybit target attribute values -e Global --target-type com.steadybit.extension_container.container -k k8s.namespace +steadybit action list --kind ATTACK +``` + ## Container Image You can also use the cli via our container image: From 70e1e1d063af8746dc973319dc061ef3902516c4 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 15:08:41 +0200 Subject: [PATCH 24/25] docs: the new command groups ship in v6.0.0 --- CHANGELOG.md | 54 ++++++++++++++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b2ed2..12229ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,33 +12,8 @@ - Writing a new experiment's key back into a YAML file no longer rewrites the file: the key is added at the top and comments, anchors and formatting are kept. - Shell completion: `steadybit completion bash|zsh|fish|powershell`. -- Errors about a missing required flag are worded differently (`required flag(s) "key" not - set`); they still exit with 1. - -## v5.0.0 - -- `experiment apply --template ` creates an experiment from an experiment template, or - updates the one created before with the same `--external-id`. With `-k` it re-renders an - existing experiment with new placeholder values. Placeholders are given with - `-p KEY=VALUE`, from a file with `--placeholders`, or both. -- `experiment run --template ` creates and runs an experiment from a template in one - step, with `--execution-variable` for values that apply to that run only. `--wait`, - `--retries` and `--allowParallel` work as for any other run. -- `template list` and `template get` find templates and their placeholders; - `template get --placeholders` writes a placeholders file to fill in. `template apply` - and `template delete` manage templates as files in Git, and `template import` imports - templates from a connected hub. -- `execution` commands for experiment runs: `get`, `cancel`, `property set` and - `property add` to annotate a run, and `artifact list` and `artifact download` for the - files its actions attached. -- `schedule` commands to `list`, `get`, `create`, `update`, `enable`, `disable` and `delete` - experiment schedules, and `apply` to manage them as files in Git, like experiments. -- `service` commands to `list`, `get`, `apply` and `delete` services, managing them as files - in Git like experiments. `service risk` shows a service's risk, and with `--fail-above` - fails a pipeline when it is too high. `service experiment` lists, links, unlinks and - provides (from a profile template) a service's experiments, and `service variable` gets, - merges or replaces its variables. -- `service-profile` commands to `list`, `get`, `apply` and `delete` service profiles. +- `template apply` and `template delete` manage experiment templates as files in Git, and + `template import` imports templates from a connected hub. - `environment` commands to `list`, `get`, `apply` and `delete` environments, and `environment variable` to get, merge or replace their variables. - `team` commands to `list`, `get`, `apply` and `delete` teams, `team member` to list, add, @@ -64,6 +39,31 @@ imported from. - `property definition` and `property association` commands to `list`, `get`, `apply` and `delete` the properties experiments and services carry. +- Errors about a missing required flag are worded differently (`required flag(s) "key" not + set`); they still exit with 1. + +## v5.0.0 + +- `experiment apply --template ` creates an experiment from an experiment template, or + updates the one created before with the same `--external-id`. With `-k` it re-renders an + existing experiment with new placeholder values. Placeholders are given with + `-p KEY=VALUE`, from a file with `--placeholders`, or both. +- `experiment run --template ` creates and runs an experiment from a template in one + step, with `--execution-variable` for values that apply to that run only. `--wait`, + `--retries` and `--allowParallel` work as for any other run. +- `template list` and `template get` find templates and their placeholders; + `template get --placeholders` writes a placeholders file to fill in. +- `execution` commands for experiment runs: `get`, `cancel`, `property set` and + `property add` to annotate a run, and `artifact list` and `artifact download` for the + files its actions attached. +- `schedule` commands to `list`, `get`, `create`, `update`, `enable`, `disable` and `delete` + experiment schedules, and `apply` to manage them as files in Git, like experiments. +- `service` commands to `list`, `get`, `apply` and `delete` services, managing them as files + in Git like experiments. `service risk` shows a service's risk, and with `--fail-above` + fails a pipeline when it is too high. `service experiment` lists, links, unlinks and + provides (from a profile template) a service's experiments, and `service variable` gets, + merges or replaces its variables. +- `service-profile` commands to `list`, `get`, `apply` and `delete` service profiles. - Every command now shows examples in its `--help`. - Fixed the table printed by `advice validate-status` containing colour escape codes when piped. Tables now follow the same terminal check as the rest of the output. From b48bc8172c7bea84d07a3348b002a3221b257ac0 Mon Sep 17 00:00:00 2001 From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.> Date: Fri, 25 Sep 2026 15:39:40 +0200 Subject: [PATCH 25/25] feat(go): -t json|yaml and --jq on the new listings; complete team keys and environment ids Environments, teams, their members and environments, integrations, hubs, property definitions and actions take -t json|yaml; access tokens and property associations take --output, as --type filters them already. --- internal/accesstoken/accesstoken.go | 11 ++++++++- internal/action/action.go | 25 ++++++++++++++++---- internal/action/action_test.go | 12 ++++++++++ internal/cli/accesstoken.go | 2 ++ internal/cli/complete.go | 19 ++++++++++++++++ internal/cli/environment.go | 2 ++ internal/cli/hub.go | 5 +++- internal/cli/integration.go | 7 +++++- internal/cli/property.go | 6 ++++- internal/cli/target.go | 2 ++ internal/cli/team.go | 14 ++++++++---- internal/environment/environment.go | 7 +++++- internal/hub/hub.go | 8 +++++-- internal/hub/hub_test.go | 2 +- internal/integration/integration.go | 8 +++++-- internal/integration/integration_test.go | 4 ++-- internal/property/property.go | 22 +++++++++++++++--- internal/property/property_test.go | 2 +- internal/resource/list.go | 24 ++++++++++++++++++++ internal/resource/listed_test.go | 29 ++++++++++++++++++++++++ internal/target/target.go | 6 ++++- internal/team/team.go | 23 +++++++++++++++---- internal/team/team_test.go | 16 ++++++++++--- 23 files changed, 222 insertions(+), 34 deletions(-) create mode 100644 internal/resource/listed_test.go diff --git a/internal/accesstoken/accesstoken.go b/internal/accesstoken/accesstoken.go index b2264d8..7a6d2dd 100644 --- a/internal/accesstoken/accesstoken.go +++ b/internal/accesstoken/accesstoken.go @@ -28,6 +28,8 @@ type ListOptions struct { Name, CreatedBy, Type string Teams []string Expired *bool + // Output is -t for the other listings; --type already names the token type here. + Output string } func List(ctx context.Context, c *platform.Client, o ListOptions) error { @@ -51,13 +53,20 @@ func List(ctx context.Context, c *platform.Client, o ListOptions) error { ExpiresAt *string `json:"expiresAt"` LastUsed *string `json:"lastUsed"` } - tokens, err := platform.AllPages[summary](func(page, size int32) (*http.Response, error) { + raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { params.PageRequest = api.PageRequestAO{Page: &page, Size: &size} return c.GetAccessTokens1(ctx, ¶ms) }) if err != nil { return platform.Failed(err, "Failed to get the access tokens") } + if resource.Machine(o.Output) { + return resource.List(raw, o.Output, nil) + } + var tokens []summary + if err := resource.DecodeEach(raw, &tokens); err != nil { + return err + } if len(tokens) == 0 { fmt.Println("No access tokens found.") return nil diff --git a/internal/action/action.go b/internal/action/action.go index 73257c7..c801cdb 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -6,6 +6,7 @@ package action import ( "context" + "encoding/json" "fmt" "net/http" "strings" @@ -19,6 +20,7 @@ import ( type ListOptions struct { // Only actions of these kinds, e.g. ATTACK or CHECK; the endpoint cannot filter. Kinds []string + Type string } type summary struct { @@ -26,8 +28,10 @@ type summary struct { } // all follows nextPage like platform.AllPages, but this endpoint lists under `actions`. -func all(ctx context.Context, c *platform.Client) ([]summary, error) { +// all walks the pages of actions, keeping each as the platform sent it too. +func all(ctx context.Context, c *platform.Client) ([]summary, []json.RawMessage, error) { var actions []summary + var raw []json.RawMessage page, size := int32(0), platform.PageSize for { var body struct { @@ -35,22 +39,33 @@ func all(ctx context.Context, c *platform.Client) ([]summary, error) { NextPage *int32 `json:"nextPage"` } resp, err := c.FindAllActions(ctx, &api.FindAllActionsParams{Page: &page, Size: &size}) - if _, err := platform.Decode(resp, err, &body); err != nil { - return nil, err + items, err := resource.DecodeListed(resp, err, "actions", &body) + if err != nil { + return nil, nil, err } actions = append(actions, body.Actions...) + raw = append(raw, items...) if body.NextPage == nil || *body.NextPage == page { - return actions, nil + return actions, raw, nil } page = *body.NextPage } } func List(ctx context.Context, c *platform.Client, o ListOptions) error { - actions, err := all(ctx, c) + actions, raw, err := all(ctx, c) if err != nil { return platform.Failed(err, "Failed to get the actions") } + if resource.Machine(o.Type) { + var kept []json.RawMessage + for i, a := range actions { + if len(o.Kinds) == 0 || anyEqualFold(o.Kinds, a.Kind) { + kept = append(kept, raw[i]) + } + } + return resource.List(kept, o.Type, nil) + } t := table.New( table.Column{Name: "id", Title: "Id", Alignment: table.Left}, table.Column{Name: "name", Title: "Name", Alignment: table.Left}, diff --git a/internal/action/action_test.go b/internal/action/action_test.go index 493fb02..28fdc62 100644 --- a/internal/action/action_test.go +++ b/internal/action/action_test.go @@ -55,3 +55,15 @@ func TestGet(t *testing.T) { assert.Equal(t, "id: stress-cpu\nname: Stress CPU\nparameters:\n - name: duration\n type: duration\n\n", out) assert.EqualError(t, action.Get(ctx, p.Client, action.GetOptions{ID: "nope"}), "Action nope not found.") } + +func TestListAsJSONKeepsTheKindFilter(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/actions", platformtest.Reply{Body: `{"actions":[{"id":"a","kind":"ATTACK"},{"id":"c","kind":"CHECK"}]}`}) + + out, err := platformtest.Stdout(t, func() error { + return action.List(ctx, p.Client, action.ListOptions{Kinds: []string{"check"}, Type: "json"}) + }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"id\": \"c\",\n \"kind\": \"CHECK\"\n }\n]\n", out) +} diff --git a/internal/cli/accesstoken.go b/internal/cli/accesstoken.go index c6854df..3c1fea6 100644 --- a/internal/cli/accesstoken.go +++ b/internal/cli/accesstoken.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/accesstoken" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" ) const ( @@ -36,6 +37,7 @@ func newAccessToken() *cobra.Command { list.Flags().StringVar(&l.Name, "name", "", "Only list tokens with this name.") list.Flags().StringVar(&l.CreatedBy, "created-by", "", "Only list tokens created by this user.") list.Flags().StringVar(&l.Type, "type", "", `Only list tokens of this type, "ADMIN", "TEAM" or "WILDCARD".`) + list.Flags().StringVar(&l.Output, "output", "", resource.ListTypeHelp) list.Flags().StringArrayVar(&l.Teams, "team", nil, "Only list tokens of these teams, by team key.") list.Flags().BoolVar(&expired, "expired", false, "Only list expired tokens, or with --expired=false those still valid.") variadic(list, "team") diff --git a/internal/cli/complete.go b/internal/cli/complete.go index 1e9c04b..29a499e 100644 --- a/internal/cli/complete.go +++ b/internal/cli/complete.go @@ -148,6 +148,21 @@ func completePaged(fetch func(ctx context.Context, c *platform.Client, page, siz } } +func completeEnvironments(ctx context.Context, c *platform.Client, prefix string) ([]string, cobra.ShellCompDirective) { + var list struct { + Environments []struct{ ID, Name string } `json:"environments"` + } + resp, err := c.GetEnvironments(ctx, nil) + if _, err := platform.Decode(resp, err, &list); err != nil { + return nil, cobra.ShellCompDirectiveError + } + var values []string + for _, e := range list.Environments { + values = append(values, e.ID+"\t"+e.Name) + } + return matching(values, prefix), cobra.ShellCompDirectiveDefault +} + var completeServices = completePaged(func(ctx context.Context, c *platform.Client, page, size int32) (*http.Response, error) { return c.GetServiceList(ctx, &api.GetServiceListParams{Page: api.PageRequestAO{Page: &page, Size: &size}}) }) @@ -200,6 +215,10 @@ func registerCompletions(root *cobra.Command) { register("id", completeProfiles) case "template": register("id", completeTemplates) + case "team": + register("key", completeTeams) + case "environment": + register("id", completeEnvironments) } for _, sub := range cmd.Commands() { walk(sub) diff --git a/internal/cli/environment.go b/internal/cli/environment.go index 57e1cc3..53495c0 100644 --- a/internal/cli/environment.go +++ b/internal/cli/environment.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/environment" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" ) const environmentID = "0190d7b2-1c5e-7f3a-8e4b-2d6f9a1c3e57" @@ -25,6 +26,7 @@ func newEnvironment() *cobra.Command { RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.List(ctx, c, l) }), } list.Flags().StringVar(&l.Search, "search", "", "Only list environments whose name, or the name or key of a team using them, matches.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) var g environment.GetOptions get := &cobra.Command{ diff --git a/internal/cli/hub.go b/internal/cli/hub.go index 52eb754..bb7083b 100644 --- a/internal/cli/hub.go +++ b/internal/cli/hub.go @@ -9,18 +9,21 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/hub" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" ) func newHub() *cobra.Command { cmd := &cobra.Command{Use: "hub", Short: "Manage the hubs experiment templates are imported from."} + var listType string list := &cobra.Command{ Use: "list", Short: "List hubs.", Args: cobra.NoArgs, Example: examples("steadybit hub list"), - RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.List(ctx, c) }), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.List(ctx, c, listType) }), } + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) var g hub.GetOptions get := &cobra.Command{ diff --git a/internal/cli/integration.go b/internal/cli/integration.go index 70e2397..b39722e 100644 --- a/internal/cli/integration.go +++ b/internal/cli/integration.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/integration" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" ) const integrationID = "0190d7b2-7d3e-7a4b-8c5d-6e7f8a9b0c1d" @@ -32,13 +33,17 @@ func newIntegrationKind(k integration.Kind) *cobra.Command { prefix := "steadybit integration " + k.Name cmd := &cobra.Command{Use: k.Name, Short: "Manage " + plural + "."} + var listType string list := &cobra.Command{ Use: "list", Short: "List " + plural + ".", Args: cobra.NoArgs, Example: examples(prefix + " list"), - RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return integration.List(ctx, c, k) }), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return integration.List(ctx, c, k, listType) + }), } + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) var g integration.GetOptions get := &cobra.Command{ diff --git a/internal/cli/property.go b/internal/cli/property.go index 877b28b..e20045a 100644 --- a/internal/cli/property.go +++ b/internal/cli/property.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/platform" "github.com/steadybit/cli/internal/property" + "github.com/steadybit/cli/internal/resource" ) const associationID = "0190d7b2-9e8f-7c6d-b5a4-3f2e1d0c9b8a" @@ -27,15 +28,17 @@ func newProperty() *cobra.Command { func newPropertyDefinition() *cobra.Command { cmd := &cobra.Command{Use: "definition", Short: "Manage property definitions: a property's key, label and type."} + var listType string list := &cobra.Command{ Use: "list", Short: "List property definitions.", Args: cobra.NoArgs, Example: examples("steadybit property definition list"), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { - return property.ListDefinitions(ctx, c) + return property.ListDefinitions(ctx, c, listType) }), } + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) var g property.GetDefinitionOptions get := &cobra.Command{ @@ -98,6 +101,7 @@ func newPropertyAssociation() *cobra.Command { list.Flags().StringVar(&l.Experiment, "experiment", "", "Only list associations given to this experiment, by key. Those for all experiments are not listed.") list.Flags().StringVar(&l.Service, "service", "", "Only list associations given to this service, by id. Those for all services are not listed.") list.Flags().StringVar(&l.Type, "type", "", `Only list "EXPERIMENT" or "SERVICE" associations.`) + list.Flags().StringVar(&l.Output, "output", "", resource.ListTypeHelp) var g property.GetAssociationOptions get := &cobra.Command{ diff --git a/internal/cli/target.go b/internal/cli/target.go index 21084d6..0538346 100644 --- a/internal/cli/target.go +++ b/internal/cli/target.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/action" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/target" ) @@ -90,6 +91,7 @@ func newAction() *cobra.Command { RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return action.List(ctx, c, l) }), } list.Flags().StringArrayVar(&l.Kinds, "kind", nil, `Only list actions of these kinds: "ATTACK", "CHECK", "LOAD_TEST", "OTHER" or "BASIC".`) + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) variadic(list, "kind") var g action.GetOptions diff --git a/internal/cli/team.go b/internal/cli/team.go index bed5a76..7d7c71e 100644 --- a/internal/cli/team.go +++ b/internal/cli/team.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/team" ) @@ -28,6 +29,7 @@ func newTeam() *cobra.Command { RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.List(ctx, c, l) }), } list.Flags().StringVar(&l.Search, "search", "", "Only list teams whose name or key, or a member's name or email, matches.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) var g team.GetOptions get := &cobra.Command{ @@ -69,15 +71,18 @@ func newTeam() *cobra.Command { func newTeamMember() *cobra.Command { cmd := &cobra.Command{Use: "member", Short: "Manage the members of a team."} - var key string + var key, listType string list := &cobra.Command{ Use: "list", Short: "List the members of a team.", Args: cobra.NoArgs, Example: examples("steadybit team member list -k ADM"), - RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.ListMembers(ctx, c, key) }), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return team.ListMembers(ctx, c, key, listType) + }), } teamKeyFlag(list, &key) + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) change := func(use, short string, withRole, confirm bool, example string, run func(context.Context, *platform.Client, team.MemberOptions) error) *cobra.Command { var o team.MemberOptions @@ -115,17 +120,18 @@ func newTeamMember() *cobra.Command { func newTeamEnvironment() *cobra.Command { cmd := &cobra.Command{Use: "environment", Short: "Manage the environments a team may use."} - var key string + var key, envListType string list := &cobra.Command{ Use: "list", Short: "List the environments of a team.", Args: cobra.NoArgs, Example: examples("steadybit team environment list -k ADM"), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { - return team.ListEnvironments(ctx, c, key) + return team.ListEnvironments(ctx, c, key, envListType) }), } teamKeyFlag(list, &key) + list.Flags().StringVarP(&envListType, "type", "t", "", resource.ListTypeHelp) change := func(use, short string, validate, confirm bool, example string, run func(context.Context, *platform.Client, team.EnvironmentOptions) error) *cobra.Command { var o team.EnvironmentOptions diff --git a/internal/environment/environment.go b/internal/environment/environment.go index e800766..c685706 100644 --- a/internal/environment/environment.go +++ b/internal/environment/environment.go @@ -38,6 +38,7 @@ func notFoundOr(err error, id, format string) error { type ListOptions struct { Search string + Type string } func List(ctx context.Context, c *platform.Client, o ListOptions) error { @@ -51,9 +52,13 @@ func List(ctx context.Context, c *platform.Client, o ListOptions) error { params.Search = &o.Search } resp, err := c.GetEnvironments(ctx, params) - if _, err := platform.Decode(resp, err, &summaries); err != nil { + raw, err := resource.DecodeListed(resp, err, "environments", &summaries) + if err != nil { return platform.Failed(err, "Failed to get the environments") } + if resource.Machine(o.Type) { + return resource.List(raw, o.Type, nil) + } if len(summaries.Environments) == 0 { fmt.Println("No environments found.") return nil diff --git a/internal/hub/hub.go b/internal/hub/hub.go index b26421c..c2abd3f 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -36,7 +36,7 @@ func notFoundOr(err error, id, format string) error { return platform.Failed(err, format, id) } -func List(ctx context.Context, c *platform.Client) error { +func List(ctx context.Context, c *platform.Client, explicitType string) error { var summaries struct { Hubs []struct { ID string `json:"id"` @@ -44,9 +44,13 @@ func List(ctx context.Context, c *platform.Client) error { } `json:"hubs"` } resp, err := c.GetHubs(ctx) - if _, err := platform.Decode(resp, err, &summaries); err != nil { + raw, err := resource.DecodeListed(resp, err, "hubs", &summaries) + if err != nil { return platform.Failed(err, "Failed to get the hubs") } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } if len(summaries.Hubs) == 0 { fmt.Println("No hubs found.") return nil diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index 1326019..6f277d9 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -27,7 +27,7 @@ func TestList(t *testing.T) { p := platformtest.New(t) p.Reply("GET /api/hubs", platformtest.Reply{JSON: map[string]any{"hubs": []any{map[string]any{"id": id, "hubName": "Reliability Hub"}}}}) - out, err := platformtest.Stdout(t, func() error { return hub.List(ctx, p.Client) }) + out, err := platformtest.Stdout(t, func() error { return hub.List(ctx, p.Client, "") }) require.NoError(t, err) assert.Contains(t, out, "│ "+id+" │ Reliability Hub │") diff --git a/internal/integration/integration.go b/internal/integration/integration.go index adb5a7c..ccf778e 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -113,14 +113,18 @@ func (k Kind) notFoundOr(err error, id, format string) error { return platform.Failed(err, format, k.Title, id) } -func List(ctx context.Context, c *platform.Client, k Kind) error { +func List(ctx context.Context, c *platform.Client, k Kind, explicitType string) error { var result struct { Content []map[string]any `json:"content"` } resp, err := k.list(ctx, c) - if _, err := platform.Decode(resp, err, &result); err != nil { + raw, err := resource.DecodeListed(resp, err, "content", &result) + if err != nil { return platform.Failed(err, "Failed to get the %s", k.Plural) } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } if len(result.Content) == 0 { fmt.Printf("No %s found.\n", k.Plural) return nil diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 3bc09ae..25255d6 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -32,7 +32,7 @@ func TestEveryKindListsFromItsOwnEndpoint(t *testing.T) { map[string]any{"id": id, "name": "Notify", "scope": "TEAM", "team": "ADM", k.Column: "where"}, }}}) - out, err := platformtest.Stdout(t, func() error { return integration.List(ctx, p.Client, k) }) + out, err := platformtest.Stdout(t, func() error { return integration.List(ctx, p.Client, k, "") }) require.NoError(t, err) assert.Contains(t, out, "│ "+id+" │ Notify │ TEAM │ ADM │ where") @@ -44,7 +44,7 @@ func TestListSaysWhenThereIsNone(t *testing.T) { p := platformtest.New(t) p.Reply("GET /api/integrations/slack", platformtest.Reply{JSON: map[string]any{"content": []any{}}}) - out, err := platformtest.Stdout(t, func() error { return integration.List(ctx, p.Client, integration.Slack) }) + out, err := platformtest.Stdout(t, func() error { return integration.List(ctx, p.Client, integration.Slack, "") }) require.NoError(t, err) assert.Equal(t, "No Slack integrations found.\n", out) diff --git a/internal/property/property.go b/internal/property/property.go index 16ce541..7ec0877 100644 --- a/internal/property/property.go +++ b/internal/property/property.go @@ -30,17 +30,24 @@ func definitionNotFoundOr(err error, key, format string) error { return platform.Failed(err, format, key) } -func ListDefinitions(ctx context.Context, c *platform.Client) error { +func ListDefinitions(ctx context.Context, c *platform.Client, explicitType string) error { type definition struct { Key, Label, DataType string EnumValues []string `json:"enumValues"` } - definitions, err := platform.AllPages[definition](func(page, size int32) (*http.Response, error) { + raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { return c.GetPropertyDefinitions(ctx, &api.GetPropertyDefinitionsParams{Page: api.PageRequestAO{Page: &page, Size: &size}}) }) if err != nil { return platform.Failed(err, "Failed to get the property definitions") } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } + var definitions []definition + if err := resource.DecodeEach(raw, &definitions); err != nil { + return err + } if len(definitions) == 0 { fmt.Println("No property definitions found.") return nil @@ -150,6 +157,8 @@ func associationNotFoundOr(err error, id, format string) error { type ListAssociationOptions struct { Key, Experiment, Service, Type string + // Output is -t for the other listings; --type already names the association type here. + Output string } func ListAssociations(ctx context.Context, c *platform.Client, o ListAssociationOptions) error { @@ -180,13 +189,20 @@ func ListAssociations(ctx context.Context, c *platform.Client, o ListAssociation Required, EditableInExecution *bool } // The page size is the platform's; it takes only the page number. - associations, err := platform.AllPages[association](func(page, _ int32) (*http.Response, error) { + raw, err := platform.AllPagesRaw(func(page, _ int32) (*http.Response, error) { params.Page = &page return c.GetAssociations(ctx, ¶ms) }) if err != nil { return platform.Failed(err, "Failed to get the property associations") } + if resource.Machine(o.Output) { + return resource.List(raw, o.Output, nil) + } + var associations []association + if err := resource.DecodeEach(raw, &associations); err != nil { + return err + } if len(associations) == 0 { fmt.Println("No property associations found.") return nil diff --git a/internal/property/property_test.go b/internal/property/property_test.go index d3f1579..5c55e32 100644 --- a/internal/property/property_test.go +++ b/internal/property/property_test.go @@ -30,7 +30,7 @@ func TestListDefinitionsWalksEveryPage(t *testing.T) { return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"key": "OWNER", "label": "Owner", "dataType": "STRING"}}}} }) - out, err := platformtest.Stdout(t, func() error { return property.ListDefinitions(ctx, p.Client) }) + out, err := platformtest.Stdout(t, func() error { return property.ListDefinitions(ctx, p.Client, "") }) require.NoError(t, err) assert.Contains(t, out, "│ COLOR │ Color │ ENUM │ a, b, c, d, e and 2 more │") diff --git a/internal/resource/list.go b/internal/resource/list.go index 80d6258..63b9d14 100644 --- a/internal/resource/list.go +++ b/internal/resource/list.go @@ -6,11 +6,13 @@ package resource import ( "encoding/json" "fmt" + "net/http" "os" "strings" "github.com/steadybit/cli/internal/jsyaml" "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" ) // ListTypeHelp describes the -t flag of listings. @@ -67,3 +69,25 @@ func DecodeEach[T any](raw []json.RawMessage, into *[]T) error { } return nil } + +// DecodeListed reads a listing response once: into typed, for the table, and as the raw +// items under field ("" for a response that is the array itself), for -t and --jq. +func DecodeListed(resp *http.Response, err error, field string, typed any) ([]json.RawMessage, error) { + body, _, err := platform.Read(resp, err) + if err != nil { + return nil, err + } + if err := json.Unmarshal(body, typed); err != nil { + return nil, err + } + var raw []json.RawMessage + if field == "" { + err = json.Unmarshal(body, &raw) + } else { + var wrapper map[string]json.RawMessage + if err = json.Unmarshal(body, &wrapper); err == nil && wrapper[field] != nil { + err = json.Unmarshal(wrapper[field], &raw) + } + } + return raw, err +} diff --git a/internal/resource/listed_test.go b/internal/resource/listed_test.go new file mode 100644 index 0000000..4adbb2e --- /dev/null +++ b/internal/resource/listed_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package resource_test + +import ( + "context" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeListedKeepsTheRawItemsBesideTheTypedOnes(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams", platformtest.Reply{Body: `{"teams":[{"key":"ADM","name":"Admins","extra":{"kept":true}}]}`}) + + var typed struct { + Teams []struct{ Key string } `json:"teams"` + } + resp, err := p.Client.GetTeams(context.Background(), nil) + items, err := resource.DecodeListed(resp, err, "teams", &typed) + + require.NoError(t, err) + assert.Equal(t, "ADM", typed.Teams[0].Key) + assert.JSONEq(t, `{"key":"ADM","name":"Admins","extra":{"kept":true}}`, string(items[0])) +} diff --git a/internal/target/target.go b/internal/target/target.go index 3337db8..a3bfa33 100644 --- a/internal/target/target.go +++ b/internal/target/target.go @@ -15,6 +15,7 @@ import ( "github.com/steadybit/cli/internal/jsyaml" "github.com/steadybit/cli/internal/output" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/table" ) @@ -34,9 +35,12 @@ func optional(s string) *string { // printAs writes values as JSON or YAML when a type is given, and returns false otherwise. func printAs(values []any, datatype string) (bool, error) { - if datatype == "" { + if !resource.Machine(datatype) { return false, nil } + if output.JQ != "" { + return true, resource.PrintJSONValue([]byte(jsyaml.CompactJSON(values)), datatype) + } resolved, err := output.ResolveDatatype(datatype, "") if err != nil { return true, err diff --git a/internal/team/team.go b/internal/team/team.go index ada3c7e..542c486 100644 --- a/internal/team/team.go +++ b/internal/team/team.go @@ -35,6 +35,7 @@ func notFoundOr(err error, key, format string) error { type ListOptions struct { Search string + Type string } func List(ctx context.Context, c *platform.Client, o ListOptions) error { @@ -50,9 +51,13 @@ func List(ctx context.Context, c *platform.Client, o ListOptions) error { params.Search = &o.Search } resp, err := c.GetTeams(ctx, params) - if _, err := platform.Decode(resp, err, &summaries); err != nil { + raw, err := resource.DecodeListed(resp, err, "teams", &summaries) + if err != nil { return platform.Failed(err, "Failed to get the teams") } + if resource.Machine(o.Type) { + return resource.List(raw, o.Type, nil) + } if len(summaries.Teams) == 0 { fmt.Println("No teams found.") return nil @@ -161,12 +166,16 @@ type members struct { Members []member `json:"members"` } -func ListMembers(ctx context.Context, c *platform.Client, key string) error { +func ListMembers(ctx context.Context, c *platform.Client, key, explicitType string) error { var result members resp, err := c.GetTeamMembers(ctx, key) - if _, err := platform.Decode(resp, err, &result); err != nil { + raw, err := resource.DecodeListed(resp, err, "members", &result) + if err != nil { return notFoundOr(err, key, "Failed to get the members of team %s") } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } printMembers(key, result.Members) return nil } @@ -286,12 +295,16 @@ func (e environments) names() []string { return names } -func ListEnvironments(ctx context.Context, c *platform.Client, key string) error { +func ListEnvironments(ctx context.Context, c *platform.Client, key, explicitType string) error { var result environments resp, err := c.GetTeamEnvironments(ctx, key) - if _, err := platform.Decode(resp, err, &result); err != nil { + raw, err := resource.DecodeListed(resp, err, "environments", &result) + if err != nil { return notFoundOr(err, key, "Failed to get the environments of team %s") } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } if len(result.Environments) == 0 { fmt.Printf("Team %s has no environments.\n", key) return nil diff --git a/internal/team/team_test.go b/internal/team/team_test.go index 83fc3fc..85f5829 100644 --- a/internal/team/team_test.go +++ b/internal/team/team_test.go @@ -102,7 +102,7 @@ func TestMembers(t *testing.T) { p.Reply("PUT /api/teams/OPS/members", result) out, err := platformtest.Stdout(t, func() error { - if err := team.ListMembers(ctx, p.Client, "OPS"); err != nil { + if err := team.ListMembers(ctx, p.Client, "OPS", ""); err != nil { return err } if err := team.AddMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Usernames: []string{"u-1"}, Emails: []string{"joe@example.com"}, Role: "owner"}); err != nil { @@ -139,7 +139,7 @@ func TestEnvironments(t *testing.T) { p.Reply("GET /api/teams/NOPE/environments", platformtest.Reply{Status: http.StatusNotFound}) out, err := platformtest.Stdout(t, func() error { - if err := team.ListEnvironments(ctx, p.Client, "OPS"); err != nil { + if err := team.ListEnvironments(ctx, p.Client, "OPS", ""); err != nil { return err } o := team.EnvironmentOptions{Key: "OPS", Environments: []string{"Prod"}, Yes: true} @@ -159,5 +159,15 @@ func TestEnvironments(t *testing.T) { assert.Equal(t, want, p.Requests("POST /api/teams/OPS/environments/add")[0].JSON(t)) assert.Equal(t, want, p.Requests("POST /api/teams/OPS/environments/remove")[0].JSON(t)) assert.Equal(t, want, p.Requests("PUT /api/teams/OPS/environments")[0].JSON(t)) - assert.EqualError(t, team.ListEnvironments(ctx, p.Client, "NOPE"), "Team NOPE not found.") + assert.EqualError(t, team.ListEnvironments(ctx, p.Client, "NOPE", ""), "Team NOPE not found.") +} + +func TestListPrintsThePlatformsTeamsAsJSON(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams", platformtest.Reply{Body: `{"teams":[{"key":"ADM","name":"Admins","members":[]}]}`}) + + out, err := platformtest.Stdout(t, func() error { return team.List(ctx, p.Client, team.ListOptions{Type: "json"}) }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"key\": \"ADM\",\n \"name\": \"Admins\",\n \"members\": []\n }\n]\n", out) }