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 1/9] 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 2/9] 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 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 3/9] 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 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 4/9] 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 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 5/9] 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 6/9] 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 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 7/9] 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 8/9] 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 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 9/9] 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: