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/CHANGELOG.md b/CHANGELOG.md index cb48c7f..43c33a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,27 @@ 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@v6`, installs the CLI on a runner. - Errors about a missing required flag are worded differently (`required flag(s) "key" not set`); they still exit with 1. diff --git a/README.md b/README.md index dc06235..a407708 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,81 @@ 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: + +```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 +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@v6 +- 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:6 + 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" 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/go.mod b/go.mod index e4fba85..ef6c63d 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,12 @@ 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/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 go.yaml.in/yaml/v3 v3.0.5 golang.org/x/term v0.46.0 @@ -14,21 +18,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..0afaa6f 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= @@ -85,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= diff --git a/internal/cli/complete.go b/internal/cli/complete.go new file mode 100644 index 0000000..1e9c04b --- /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", "execution": + 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/execution.go b/internal/cli/execution.go index 841a985..301ad7d 100644 --- a/internal/cli/execution.go +++ b/internal/cli/execution.go @@ -5,10 +5,13 @@ package cli import ( "context" + "errors" + "time" "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 +91,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", @@ -117,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 6ad6b2c..eec52b6 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(), newExperimentDelete(), newExperimentDump()) + cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDelete(), newExperimentDump(), newExperimentInit(), + newDiff(gitops.Experiment, "experiment", "experiment.yml")) return cmd } @@ -70,6 +72,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 +89,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") @@ -139,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 } @@ -163,6 +171,27 @@ func newExperimentDump() *cobra.Command { 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{ diff --git a/internal/cli/gitops.go b/internal/cli/gitops.go new file mode 100644 index 0000000..ec52906 --- /dev/null +++ b/internal/cli/gitops.go @@ -0,0 +1,104 @@ +// 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) + } +} + +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 0812837..bb6dad8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,7 +13,9 @@ 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" "github.com/steadybit/cli/internal/platform" ) @@ -67,9 +69,12 @@ 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") - 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() { @@ -89,6 +94,7 @@ func newRoot() *cobra.Command { } } } + registerCompletions(root) for _, cmd := range append(root.Commands(), root) { setUsage(cmd) } @@ -112,6 +118,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 8682bbb..2ae498e 100644 --- a/internal/cli/schedule.go +++ b/internal/cli/schedule.go @@ -7,7 +7,9 @@ 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" ) @@ -51,11 +53,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 @@ -82,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{ @@ -122,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 9990d77..9ac2241 100644 --- a/internal/cli/service.go +++ b/internal/cli/service.go @@ -7,7 +7,9 @@ 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" "github.com/steadybit/cli/internal/serviceprofile" ) @@ -30,12 +32,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 @@ -63,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{ @@ -110,6 +114,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 @@ -193,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 } @@ -211,6 +216,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 @@ -240,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{ @@ -253,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/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/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") +} 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/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 b4eae6c..41839c6 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -24,6 +24,8 @@ import ( "time" "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" @@ -64,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 @@ -258,6 +263,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 } @@ -301,21 +309,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 @@ -440,30 +469,112 @@ 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 +} + +// 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") + +// 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)) + // 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 } if run.State != "COMPLETED" { @@ -471,8 +582,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 79e5d1f..1910d90 100644 --- a/internal/experiment/experiment_test.go +++ b/internal/experiment/experiment_test.go @@ -8,14 +8,18 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync/atomic" "testing" "time" "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/steadybit/cli/internal/prompt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -307,6 +311,247 @@ 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")) +} + +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) +} + +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{}) 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 new file mode 100644 index 0000000..9d46d13 --- /dev/null +++ b/internal/experiment/report.go @@ -0,0 +1,280 @@ +// 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 strings.EqualFold(s.StepType, "wait") && s.Parameters["duration"] != nil: + name = fmt.Sprintf("wait %v", s.Parameters["duration"]) + default: + 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}) + } + 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", "", "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 +} + +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 { + 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 + 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/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/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/.") +} 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/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/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 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/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/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 a305016..e66ef76 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -8,33 +8,18 @@ package prompt import ( "bufio" "fmt" + "io" "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() -) +var reader = bufio.NewReader(os.Stdin) -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) - }() -} +// 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 @@ -86,10 +71,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 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/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()) +} 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