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..c7ba81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,54 @@ 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. +- `template apply` and `template delete` manage experiment templates as files in Git, and + `template import` imports templates from a connected hub. +- `environment` commands to `list`, `get`, `apply` and `delete` environments, and + `environment variable` to get, merge or replace their variables. +- `team` commands to `list`, `get`, `apply` and `delete` teams, `team member` to list, add, + remove or replace their members, and `team environment` to do the same with the + environments they may use. +- `user invite` invites users by email, optionally into a team. +- `access-token` commands to `list`, `create`, `recreate` and `delete` API access tokens. A + new token is printed once; `-t json` prints only its id and value for a script to read. +- `integration webhook|slack|preflight|preflight-action` commands to `list`, `get`, `apply` + and `delete` integrations. The platform masks secrets when reading them, so `apply` + refuses a file still holding the mask instead of removing the secret. +- `report` commands print the platform's reports as JSON or YAML: `users`, `teams`, + `environments`, `experiments-executed`, `experiments-created`, `services-distribution`, + `services-by-category` and `services-average`. Teams are filtered by key and environments + by name. +- `audit-log` shows who changed what and when, as a table or with `-t json|yaml`. +- `killswitch status`, `activate` and `deactivate`. Activating asks for confirmation unless + `--yes` is given, as it stops every running experiment of the tenant. +- `target query` lists the targets of an environment, by type and query, with the + attributes asked for as columns; `target attribute keys|values` lists what can be + queried. `action list` and `action get` show the actions experiments can use. +- `hub` commands to `list`, `get`, `apply`, `delete` and `resync` the hubs templates are + imported from. +- `property definition` and `property association` commands to `list`, `get`, `apply` and + `delete` the properties experiments and services carry. - 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..f6ff082 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,122 @@ steadybit service-profile list --origin custom steadybit service-profile apply -f profile.yml ``` +### Environments, teams and more as files in Git + +Experiment templates, environments, teams, hubs, integrations and property definitions and +associations are managed the same way, with `list`, `get`, `apply` and `delete`: + +```bash +steadybit environment get -i -f environment.yml +steadybit team apply -f ./teams -R +steadybit integration webhook apply -f webhook.yml +steadybit template apply -f ./templates -R +``` + +Their parts have commands of their own: + +```bash +steadybit environment variable set -i region=eu +steadybit team member add -k ADM --email jane@example.com --role OWNER +steadybit team environment add -k ADM --environment Global +``` + +### Tenant administration + +```bash +steadybit access-token create --name ci --type TEAM --team ADM --expires-at 2026-12-31 +steadybit user invite --email jane@example.com --team ADM +steadybit killswitch status +steadybit audit-log --from 2026-09-01 -t json +steadybit report experiments-executed --group-by STATE --rollup MONTHLY +``` + +Commands that cannot be undone, such as `killswitch activate`, `access-token delete` or +`team member set`, ask for confirmation on a terminal; `--yes` skips the question. + +### Targets and actions + +```bash +steadybit target query -e Global --target-type com.steadybit.extension_container.container --attribute k8s.namespace +steadybit target attribute values -e Global --target-type com.steadybit.extension_container.container -k k8s.namespace +steadybit action list --kind ATTACK +``` + +## 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/accesstoken/accesstoken.go b/internal/accesstoken/accesstoken.go new file mode 100644 index 0000000..7a6d2dd --- /dev/null +++ b/internal/accesstoken/accesstoken.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package accesstoken implements the `access-token` commands, on the v2 endpoints only. +package accesstoken + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +func notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Access token %s not found.", id) + } + return platform.Failed(err, format, id) +} + +type ListOptions struct { + Name, CreatedBy, Type string + Teams []string + Expired *bool + // Output is -t for the other listings; --type already names the token type here. + Output string +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + params := api.GetAccessTokens1Params{Teams: resource.Optional(o.Teams), Expired: o.Expired} + if o.Name != "" { + params.Name = &o.Name + } + if o.CreatedBy != "" { + params.CreatedBy = &o.CreatedBy + } + if o.Type != "" { + t := api.GetAccessTokens1ParamsType(strings.ToUpper(o.Type)) + if !t.Valid() { + return fmt.Errorf("--type must be ADMIN, TEAM or WILDCARD, not '%s'.", o.Type) + } + params.Type = &t + } + type summary struct { + ID, Name, Type string + Teams []string + ExpiresAt *string `json:"expiresAt"` + LastUsed *string `json:"lastUsed"` + } + raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { + params.PageRequest = api.PageRequestAO{Page: &page, Size: &size} + return c.GetAccessTokens1(ctx, ¶ms) + }) + if err != nil { + return platform.Failed(err, "Failed to get the access tokens") + } + if resource.Machine(o.Output) { + return resource.List(raw, o.Output, nil) + } + var tokens []summary + if err := resource.DecodeEach(raw, &tokens); err != nil { + return err + } + if len(tokens) == 0 { + fmt.Println("No access tokens found.") + return nil + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "type", Title: "Type", Alignment: table.Left}, + table.Column{Name: "teams", Title: "Teams", Alignment: table.Left}, + table.Column{Name: "expiresAt", Title: "Expires", Alignment: table.Left}, + table.Column{Name: "lastUsed", Title: "Last used", Alignment: table.Left}, + ) + or := func(s *string, fallback string) string { + if s == nil { + return fallback + } + return *s + } + for _, token := range tokens { + t.AddRow(table.Default, table.Cell("id", token.ID), table.Cell("name", token.Name), table.Cell("type", token.Type), + table.Cell("teams", strings.Join(token.Teams, ", ")), table.Cell("expiresAt", or(token.ExpiresAt, "never")), table.Cell("lastUsed", or(token.LastUsed, "never"))) + } + t.Print() + return nil +} + +type created struct { + ID string `json:"id"` + Token string `json:"token"` +} + +// printToken shows a new token, the only time it can be seen. With a type, only the id +// and token are printed, for a pipeline to read. +func printToken(token created, datatype, headline string) error { + if datatype != "" { + m := jsyaml.NewMap() + m.Set("id", token.ID) + m.Set("token", token.Token) + return resource.OutputValue(m, "", datatype) + } + fmt.Printf("%s Store it now, it cannot be shown again:\n%s\n", headline, token.Token) + return nil +} + +type CreateOptions struct { + Name, Type, ExpiresAt string + Teams []string + Output string +} + +func Create(ctx context.Context, c *platform.Client, o CreateOptions) error { + kind := api.CreateAccessTokenRequestV2AOType(strings.ToUpper(o.Type)) + if !kind.Valid() { + return fmt.Errorf("--type must be ADMIN, TEAM or WILDCARD, not '%s'.", o.Type) + } + expiresAt, err := resource.Time("expires-at", o.ExpiresAt) + if err != nil { + return err + } + var token created + resp, err := c.CreateAccessToken1(ctx, api.CreateAccessTokenRequestV2AO{Name: o.Name, Type: kind, Teams: resource.Optional(o.Teams), ExpiresAt: expiresAt}) + if _, err := platform.Decode(resp, err, &token); err != nil { + return platform.Failed(err, "Failed to create access token %s", o.Name) + } + return printToken(token, o.Output, fmt.Sprintf("Access token %s (%s) created.", o.Name, token.ID)) +} + +type RecreateOptions struct { + ID, ExpiresAt string + Output string + Yes bool +} + +func Recreate(ctx context.Context, c *platform.Client, o RecreateOptions) error { + expiresAt, err := resource.Time("expires-at", o.ExpiresAt) + if err != nil { + return err + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Recreate access token %s? The current token stops working.", o.ID)); !ok || err != nil { + return err + } + var token created + resp, err := c.RecreateAccessToken(ctx, o.ID, api.RecreateAccessTokenRequestV2AO{ExpiresAt: expiresAt}) + if _, err := platform.Decode(resp, err, &token); err != nil { + return notFoundOr(err, o.ID, "Failed to recreate access token %s") + } + return printToken(token, o.Output, fmt.Sprintf("Access token %s recreated as %s.", o.ID, token.ID)) +} + +type DeleteOptions struct { + ID string + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Delete access token %s? Everything using it loses access.", o.ID)); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteAccessToken1(ctx, o.ID)); err != nil { + return notFoundOr(err, o.ID, "Failed to delete access token %s") + } + fmt.Printf("Access token %s deleted.\n", o.ID) + return nil +} diff --git a/internal/accesstoken/accesstoken_test.go b/internal/accesstoken/accesstoken_test.go new file mode 100644 index 0000000..a9881a0 --- /dev/null +++ b/internal/accesstoken/accesstoken_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package accesstoken_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/accesstoken" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "Xy12AbCd" + +func TestListWalksEveryPageWithTheFilters(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/access-tokens/v2", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"id": id, "name": "ci", "type": "TEAM", "teams": []any{"ADM", "OPS"}}}, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"id": "b", "name": "admin", "type": "ADMIN", "expiresAt": "2026-12-31T00:00:00Z", "lastUsed": "2026-09-01T10:00:00Z"}}}} + }) + no := false + + out, err := platformtest.Stdout(t, func() error { + return accesstoken.List(ctx, p.Client, accesstoken.ListOptions{Type: "team", Teams: []string{"ADM", "OPS"}, Expired: &no}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ ci │ TEAM │ ADM, OPS │ never │ never │") + assert.Contains(t, out, "│ b │ admin │ ADMIN │ │ 2026-12-31T00:00:00Z │ 2026-09-01T10:00:00Z │") + q := p.Requests("GET /api/access-tokens/v2")[0].Query + assert.Equal(t, []string{"TEAM"}, q["type"]) + assert.Equal(t, []string{"ADM", "OPS"}, q["teams"]) + assert.Equal(t, []string{"false"}, q["expired"]) + assert.Equal(t, []string{"100"}, q["size"]) + assert.EqualError(t, accesstoken.List(ctx, p.Client, accesstoken.ListOptions{Type: "user"}), "--type must be ADMIN, TEAM or WILDCARD, not 'user'.") +} + +func TestCreatePrintsTheTokenOnce(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/access-tokens/v2", platformtest.Reply{JSON: map[string]any{"id": id, "token": "secret-value"}}) + + out, err := platformtest.Stdout(t, func() error { + return accesstoken.Create(ctx, p.Client, accesstoken.CreateOptions{Name: "ci", Type: "team", Teams: []string{"ADM"}, ExpiresAt: "2026-12-31"}) + }) + + require.NoError(t, err) + assert.Equal(t, "Access token ci ("+id+") created. Store it now, it cannot be shown again:\nsecret-value\n", out) + assert.Equal(t, map[string]any{"name": "ci", "type": "TEAM", "teams": []any{"ADM"}, "expiresAt": "2026-12-31T00:00:00Z"}, p.Requests("POST /api/access-tokens/v2")[0].JSON(t)) + + out, err = platformtest.Stdout(t, func() error { + return accesstoken.Create(ctx, p.Client, accesstoken.CreateOptions{Name: "ci", Type: "ADMIN", Output: "json"}) + }) + require.NoError(t, err) + assert.Equal(t, "{\n \"id\": \""+id+"\",\n \"token\": \"secret-value\"\n}\n", out) + assert.Equal(t, map[string]any{"name": "ci", "type": "ADMIN"}, p.Requests("POST /api/access-tokens/v2")[1].JSON(t)) + + assert.EqualError(t, accesstoken.Create(ctx, p.Client, accesstoken.CreateOptions{Name: "ci", Type: "ADMIN", ExpiresAt: "tomorrow"}), + "--expires-at 'tomorrow' is neither a date like 2026-09-01 nor a time like 2026-09-01T12:00:00Z.") +} + +func TestRecreateAndDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/access-tokens/v2/"+id+"/recreate", platformtest.Reply{JSON: map[string]any{"id": "new-id", "token": "new-secret"}}) + p.Reply("DELETE /api/access-tokens/v2/"+id, platformtest.Reply{}) + p.Reply("DELETE /api/access-tokens/v2/nope", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + if err := accesstoken.Recreate(ctx, p.Client, accesstoken.RecreateOptions{ID: id, ExpiresAt: "2027-06-30T12:00:00+02:00", Yes: true}); err != nil { + return err + } + return accesstoken.Delete(ctx, p.Client, accesstoken.DeleteOptions{ID: id, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Access token "+id+" recreated as new-id. Store it now, it cannot be shown again:\nnew-secret\nAccess token "+id+" deleted.\n", out) + assert.Equal(t, map[string]any{"expiresAt": "2027-06-30T12:00:00+02:00"}, p.Requests("POST /api/access-tokens/v2/" + id + "/recreate")[0].JSON(t)) + assert.EqualError(t, accesstoken.Delete(ctx, p.Client, accesstoken.DeleteOptions{ID: "nope", Yes: true}), "Access token nope not found.") +} diff --git a/internal/action/action.go b/internal/action/action.go new file mode 100644 index 0000000..c801cdb --- /dev/null +++ b/internal/action/action.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package action implements the `action` commands. +package action + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +type ListOptions struct { + // Only actions of these kinds, e.g. ATTACK or CHECK; the endpoint cannot filter. + Kinds []string + Type string +} + +type summary struct { + ID, Name, Kind, Category, Technology string +} + +// all follows nextPage like platform.AllPages, but this endpoint lists under `actions`. +// all walks the pages of actions, keeping each as the platform sent it too. +func all(ctx context.Context, c *platform.Client) ([]summary, []json.RawMessage, error) { + var actions []summary + var raw []json.RawMessage + page, size := int32(0), platform.PageSize + for { + var body struct { + Actions []summary `json:"actions"` + NextPage *int32 `json:"nextPage"` + } + resp, err := c.FindAllActions(ctx, &api.FindAllActionsParams{Page: &page, Size: &size}) + items, err := resource.DecodeListed(resp, err, "actions", &body) + if err != nil { + return nil, nil, err + } + actions = append(actions, body.Actions...) + raw = append(raw, items...) + if body.NextPage == nil || *body.NextPage == page { + return actions, raw, nil + } + page = *body.NextPage + } +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + actions, raw, err := all(ctx, c) + if err != nil { + return platform.Failed(err, "Failed to get the actions") + } + if resource.Machine(o.Type) { + var kept []json.RawMessage + for i, a := range actions { + if len(o.Kinds) == 0 || anyEqualFold(o.Kinds, a.Kind) { + kept = append(kept, raw[i]) + } + } + return resource.List(kept, o.Type, nil) + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "kind", Title: "Kind", Alignment: table.Left}, + table.Column{Name: "category", Title: "Category", Alignment: table.Left}, + ) + rows := 0 + for _, a := range actions { + if len(o.Kinds) > 0 && !anyEqualFold(o.Kinds, a.Kind) { + continue + } + t.AddRow(table.Default, table.Cell("id", a.ID), table.Cell("name", a.Name), table.Cell("kind", a.Kind), table.Cell("category", a.Category)) + rows++ + } + if rows == 0 { + fmt.Println("No actions found.") + return nil + } + t.Print() + return nil +} + +func anyEqualFold(values []string, s string) bool { + for _, v := range values { + if strings.EqualFold(v, s) { + return true + } + } + return false +} + +type GetOptions struct { + ID, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, o GetOptions) error { + doc, _, err := platform.ReadDocument(c.GetAction(ctx, o.ID)) + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Action %s not found.", o.ID) + } + if err != nil { + return platform.Failed(err, "Failed to get action %s", o.ID) + } + if err := resource.Output(doc, o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Action %s written to %s.\n", o.ID, o.File) + } + return nil +} diff --git a/internal/action/action_test.go b/internal/action/action_test.go new file mode 100644 index 0000000..28fdc62 --- /dev/null +++ b/internal/action/action_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package action_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/action" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func TestListWalksEveryPageAndFiltersByKind(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/actions", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"actions": []any{map[string]any{"id": "stress-cpu", "name": "Stress CPU", "kind": "ATTACK", "category": "resource"}}, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"actions": []any{map[string]any{"id": "http-check", "name": "HTTP Check", "kind": "CHECK"}}}} + }) + + out, err := platformtest.Stdout(t, func() error { return action.List(ctx, p.Client, action.ListOptions{Kinds: []string{"check"}}) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ http-check │ HTTP Check │ CHECK │ │") + assert.NotContains(t, out, "stress-cpu") + assert.Len(t, p.Requests("GET /api/actions"), 2) + assert.Equal(t, []string{"100"}, p.Requests("GET /api/actions")[0].Query["size"]) +} + +func TestListSaysWhenThereIsNone(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/actions", platformtest.Reply{JSON: map[string]any{"actions": []any{}}}) + + out, err := platformtest.Stdout(t, func() error { return action.List(ctx, p.Client, action.ListOptions{}) }) + + require.NoError(t, err) + assert.Equal(t, "No actions found.\n", out) +} + +func TestGet(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/actions/stress-cpu", platformtest.Reply{Body: `{"id":"stress-cpu","name":"Stress CPU","parameters":[{"name":"duration","type":"duration"}]}`}) + p.Reply("GET /api/actions/nope", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { return action.Get(ctx, p.Client, action.GetOptions{ID: "stress-cpu"}) }) + + require.NoError(t, err) + assert.Equal(t, "id: stress-cpu\nname: Stress CPU\nparameters:\n - name: duration\n type: duration\n\n", out) + assert.EqualError(t, action.Get(ctx, p.Client, action.GetOptions{ID: "nope"}), "Action nope not found.") +} + +func TestListAsJSONKeepsTheKindFilter(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/actions", platformtest.Reply{Body: `{"actions":[{"id":"a","kind":"ATTACK"},{"id":"c","kind":"CHECK"}]}`}) + + out, err := platformtest.Stdout(t, func() error { + return action.List(ctx, p.Client, action.ListOptions{Kinds: []string{"check"}, Type: "json"}) + }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"id\": \"c\",\n \"kind\": \"CHECK\"\n }\n]\n", out) +} diff --git a/internal/auditlog/auditlog.go b/internal/auditlog/auditlog.go new file mode 100644 index 0000000..65c1da3 --- /dev/null +++ b/internal/auditlog/auditlog.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package auditlog implements the `audit-log` command. +package auditlog + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +type Options struct { + From, To string + Type string +} + +// Show prints the entries of the time range; the platform defaults to the last 7 days. +// The endpoint is not paged: it returns the whole range at once. +func Show(ctx context.Context, c *platform.Client, o Options) error { + from, err := resource.Time("from", o.From) + if err != nil { + return err + } + to, err := resource.Time("to", o.To) + if err != nil { + return err + } + body, _, err := platform.Read(c.Find(ctx, &api.FindParams{From: from, To: to})) + if platform.IsStatus(err, http.StatusForbidden) { + return errors.New("The audit log needs an admin access token.") + } + if err != nil { + return platform.Failed(err, "Failed to get the audit log") + } + value, err := output.ParseValue(body) + if err != nil { + return err + } + entries, _ := value.([]any) + if o.Type != "" { + datatype, err := output.ResolveDatatype(o.Type, "") + if err != nil { + return err + } + if datatype == output.JSON { + fmt.Println(jsyaml.JSON(entries)) + } else { + fmt.Println(jsyaml.Dump(entries)) + } + return nil + } + if len(entries) == 0 { + fmt.Println("No audit log entries found.") + return nil + } + t := table.New( + table.Column{Name: "time", Title: "Time", Alignment: table.Left}, + table.Column{Name: "event", Title: "Event", Alignment: table.Left}, + table.Column{Name: "by", Title: "By", Alignment: table.Left}, + table.Column{Name: "team", Title: "Team", Alignment: table.Left}, + table.Column{Name: "environment", Title: "Environment", Alignment: table.Left}, + ) + for _, e := range entries { + entry, _ := e.(*jsyaml.Map) + t.AddRow(table.Default, table.Cell("time", field(entry, "eventTime")), table.Cell("event", field(entry, "eventName")), + table.Cell("by", principal(entry)), table.Cell("team", field(entry, "team", "key")), table.Cell("environment", field(entry, "environment", "name"))) + } + t.Print() + return nil +} + +// principal names who did it: a user by name, an access token by its name, a batch job as such. +func principal(entry *jsyaml.Map) string { + switch field(entry, "principal", "principalType") { + case "USER": + if name := field(entry, "principal", "name"); name != "" { + return name + } + return field(entry, "principal", "username") + case "ACCESS_TOKEN": + return "token " + field(entry, "principal", "name") + case "BATCH_JOB": + return "batch job" + } + return "" +} + +func field(m *jsyaml.Map, path ...string) string { + var value any = m + for _, key := range path { + next, ok := value.(*jsyaml.Map) + if !ok { + return "" + } + value, _ = next.Get(key) + } + if value == nil { + return "" + } + return fmt.Sprint(value) +} diff --git a/internal/auditlog/auditlog_test.go b/internal/auditlog/auditlog_test.go new file mode 100644 index 0000000..56765e4 --- /dev/null +++ b/internal/auditlog/auditlog_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package auditlog_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/auditlog" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const entries = `[ + {"id":"a","eventName":"experiment.created","eventTime":"2026-09-01T10:00:00Z","principal":{"principalType":"USER","name":"Jane Doe","username":"u-1"},"tenant":{"key":"demo","name":"Demo"},"team":{"id":"t","key":"ADM","name":"Admins"}}, + {"id":"b","eventName":"environment.deleted","eventTime":"2026-09-02T11:00:00Z","principal":{"principalType":"ACCESS_TOKEN","name":"CI/CD","tokenType":"ADMIN"},"tenant":{"key":"demo","name":"Demo"},"environment":{"id":"e","name":"Prod","predicate":{}}}, + {"id":"c","eventName":"advice.updated","eventTime":"2026-09-03T12:00:00Z","principal":{"principalType":"BATCH_JOB","username":"system"},"tenant":{"key":"demo","name":"Demo"}} +]` + +func TestShowsWhoDidWhat(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/audit-log", platformtest.Reply{Body: entries}) + + out, err := platformtest.Stdout(t, func() error { + return auditlog.Show(ctx, p.Client, auditlog.Options{From: "2026-09-01", To: "2026-09-08T12:00:00+02:00"}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ 2026-09-01T10:00:00Z │ experiment.created │ Jane Doe │ ADM │ │") + assert.Contains(t, out, "│ 2026-09-02T11:00:00Z │ environment.deleted │ token CI/CD │ │ Prod │") + assert.Contains(t, out, "│ 2026-09-03T12:00:00Z │ advice.updated │ batch job │ │ │") + q := p.Requests("GET /api/audit-log")[0].Query + assert.Equal(t, []string{"2026-09-01T00:00:00Z"}, q["from"]) + assert.Equal(t, []string{"2026-09-08T12:00:00+02:00"}, q["to"]) +} + +func TestPrintsTheEntriesAsJSON(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/audit-log", platformtest.Reply{Body: `[{"id":"a","eventName":"x"}]`}) + + out, err := platformtest.Stdout(t, func() error { return auditlog.Show(ctx, p.Client, auditlog.Options{Type: "json"}) }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"id\": \"a\",\n \"eventName\": \"x\"\n }\n]\n", out) + assert.Empty(t, p.Requests("GET /api/audit-log")[0].Query) +} + +func TestRefusals(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/audit-log", platformtest.Reply{Status: http.StatusForbidden}) + + assert.EqualError(t, auditlog.Show(ctx, p.Client, auditlog.Options{}), "The audit log needs an admin access token.") + assert.EqualError(t, auditlog.Show(ctx, p.Client, auditlog.Options{From: "last week"}), "--from 'last week' is neither a date like 2026-09-01 nor a time like 2026-09-01T12:00:00Z.") +} diff --git a/internal/cli/accesstoken.go b/internal/cli/accesstoken.go new file mode 100644 index 0000000..3c1fea6 --- /dev/null +++ b/internal/cli/accesstoken.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/accesstoken" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +const ( + accessTokenID = "Xy12AbCd" + tokenTypeHelp = `Print only the id and the token as "json" or "yaml", for a script to read.` +) + +func newAccessToken() *cobra.Command { + cmd := &cobra.Command{Use: "access-token", Short: "Manage the API access tokens of the tenant."} + + var l accesstoken.ListOptions + var expired bool + list := &cobra.Command{ + Use: "list", + Short: "List access tokens. The tokens themselves are never shown.", + Args: cobra.NoArgs, + Example: examples("steadybit access-token list", "steadybit access-token list --type TEAM --team ADM OPS --expired=false"), + PreRun: func(cmd *cobra.Command, _ []string) { + if cmd.Flags().Changed("expired") { + l.Expired = &expired + } + }, + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return accesstoken.List(ctx, c, l) }), + } + list.Flags().StringVar(&l.Name, "name", "", "Only list tokens with this name.") + list.Flags().StringVar(&l.CreatedBy, "created-by", "", "Only list tokens created by this user.") + list.Flags().StringVar(&l.Type, "type", "", `Only list tokens of this type, "ADMIN", "TEAM" or "WILDCARD".`) + list.Flags().StringVar(&l.Output, "output", "", resource.ListTypeHelp) + list.Flags().StringArrayVar(&l.Teams, "team", nil, "Only list tokens of these teams, by team key.") + list.Flags().BoolVar(&expired, "expired", false, "Only list expired tokens, or with --expired=false those still valid.") + variadic(list, "team") + + var cr accesstoken.CreateOptions + create := &cobra.Command{ + Use: "create", + Short: "Create an access token. The token is printed once and cannot be shown again.", + Args: cobra.NoArgs, + Example: examples( + "steadybit access-token create --name ci --type TEAM --team ADM --expires-at 2026-12-31", + "steadybit access-token create --name ci --type TEAM --team ADM -t json", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return accesstoken.Create(ctx, c, cr) }), + } + create.Flags().StringVar(&cr.Name, "name", "", "The name of the token.") + create.Flags().StringVar(&cr.Type, "type", "", `The type of the token: "ADMIN", "TEAM" for the teams given with --team, or "WILDCARD" for every team.`) + create.Flags().StringArrayVar(&cr.Teams, "team", nil, "The teams of a TEAM token, by team key.") + create.Flags().StringVar(&cr.ExpiresAt, "expires-at", "", "When the token expires, a date or an RFC 3339 time. (default: never)") + create.Flags().StringVarP(&cr.Output, "output", "t", "", tokenTypeHelp) + _ = create.MarkFlagRequired("name") + _ = create.MarkFlagRequired("type") + variadic(create, "team") + + var r accesstoken.RecreateOptions + recreate := &cobra.Command{ + Use: "recreate", + Short: "Replace an access token with a new one of the same name, type and teams. The old token stops working.", + Args: cobra.NoArgs, + Example: examples("steadybit access-token recreate -i " + accessTokenID + " --expires-at 2027-06-30"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return accesstoken.Recreate(ctx, c, r) + }), + } + idFlag(recreate, &r.ID, "The access token id.") + recreate.Flags().StringVar(&r.ExpiresAt, "expires-at", "", "When the new token expires, a date or an RFC 3339 time. (default: never)") + recreate.Flags().StringVarP(&r.Output, "output", "t", "", tokenTypeHelp) + recreate.Flags().BoolVar(&r.Yes, "yes", false, yesHelp) + + var d accesstoken.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete an access token. Everything using it loses access.", + Args: cobra.NoArgs, + Example: examples("steadybit access-token delete -i " + accessTokenID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return accesstoken.Delete(ctx, c, d) }), + } + idFlag(del, &d.ID, "The access token id.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, create, recreate, del) + return cmd +} diff --git a/internal/cli/auditlog.go b/internal/cli/auditlog.go new file mode 100644 index 0000000..7245c90 --- /dev/null +++ b/internal/cli/auditlog.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/auditlog" + "github.com/steadybit/cli/internal/platform" +) + +func newAuditLog() *cobra.Command { + var o auditlog.Options + cmd := &cobra.Command{ + Use: "audit-log", + Short: "Show the audit log: who changed what, and when. Needs an admin access token.", + Args: cobra.NoArgs, + Example: examples("steadybit audit-log", "steadybit audit-log --from 2026-09-01 --to 2026-09-08T12:00:00Z -t json"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return auditlog.Show(ctx, c, o) }), + } + cmd.Flags().StringVar(&o.From, "from", "", "The earliest time, a date or an RFC 3339 time. (default: 7 days before --to)") + cmd.Flags().StringVar(&o.To, "to", "", "The latest time, a date or an RFC 3339 time. (default: 7 days after --from, or now)") + cmd.Flags().StringVarP(&o.Type, "type", "t", "", `Print the entries as "json" or "yaml" instead of a table.`) + return cmd +} diff --git a/internal/cli/complete.go b/internal/cli/complete.go new file mode 100644 index 0000000..29a499e --- /dev/null +++ b/internal/cli/complete.go @@ -0,0 +1,228 @@ +// 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 + } +} + +func completeEnvironments(ctx context.Context, c *platform.Client, prefix string) ([]string, cobra.ShellCompDirective) { + var list struct { + Environments []struct{ ID, Name string } `json:"environments"` + } + resp, err := c.GetEnvironments(ctx, nil) + if _, err := platform.Decode(resp, err, &list); err != nil { + return nil, cobra.ShellCompDirectiveError + } + var values []string + for _, e := range list.Environments { + values = append(values, e.ID+"\t"+e.Name) + } + return matching(values, prefix), cobra.ShellCompDirectiveDefault +} + +var completeServices = completePaged(func(ctx context.Context, c *platform.Client, page, size int32) (*http.Response, error) { + return c.GetServiceList(ctx, &api.GetServiceListParams{Page: api.PageRequestAO{Page: &page, Size: &size}}) +}) + +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) + case "team": + register("key", completeTeams) + case "environment": + register("id", completeEnvironments) + } + 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/environment.go b/internal/cli/environment.go new file mode 100644 index 0000000..53495c0 --- /dev/null +++ b/internal/cli/environment.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/environment" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +const environmentID = "0190d7b2-1c5e-7f3a-8e4b-2d6f9a1c3e57" + +func newEnvironment() *cobra.Command { + cmd := &cobra.Command{Use: "environment", Short: "Manage environments and their variables."} + + var l environment.ListOptions + list := &cobra.Command{ + Use: "list", + Short: "List environments.", + Args: cobra.NoArgs, + Example: examples("steadybit environment list", "steadybit environment list --search prod"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.List(ctx, c, l) }), + } + list.Flags().StringVar(&l.Search, "search", "", "Only list environments whose name, or the name or key of a team using them, matches.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) + + var g environment.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get an environment. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit environment get -i " + environmentID + " -f environment.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.Get(ctx, c, g) }), + } + idFlag(get, &g.ID, "The environment id.") + outputFlags(get, &g.File, &g.Type, "environment") + + var a environment.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update environments from files. A file without an id creates an environment, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples("steadybit environment apply -f environment.yml", "steadybit environment apply -f ./environments -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.Apply(ctx, c, a) }), + } + fileFlags(apply, &a.Files, &a.Recursive, "environment") + + var d environment.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete an environment.", + Args: cobra.NoArgs, + Example: examples("steadybit environment delete -i " + environmentID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return environment.Delete(ctx, c, d) }), + } + idFlag(del, &d.ID, "The environment id.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + variable := &cobra.Command{Use: "variable", Short: "Manage the variables of an environment."} + var vg environment.VariableGetOptions + vget := &cobra.Command{ + Use: "get", + Short: "Print the variables of an environment.", + Args: cobra.NoArgs, + Example: examples("steadybit environment variable get -i " + environmentID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return environment.GetVariables(ctx, c, vg) + }), + } + idFlag(vget, &vg.ID, "The environment id.") + vget.Flags().StringVarP(&vg.Type, "type", "t", "yaml", `The output format ("json" or "yaml").`) + var vs environment.VariableSetOptions + vset := &cobra.Command{ + Use: "set [KEY=VALUE...]", + Short: "Set variables of an environment, keeping the others. With --replace, the given variables become the only ones.", + Example: examples( + "steadybit environment variable set -i "+environmentID+" region=eu cluster=prod", + "steadybit environment variable set -i "+environmentID+" -f variables.yml --replace", + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, args []string) error { + return environment.SetVariables(ctx, c, args, vs) + }), + } + idFlag(vset, &vs.ID, "The environment id.") + vset.Flags().StringVarP(&vs.File, "file", "f", "", "A YAML/JSON file mapping variable names to values, which may be lists or select expressions.") + vset.Flags().BoolVar(&vs.Replace, "replace", false, "Remove every variable not given.") + variable.AddCommand(vget, vset) + + cmd.AddCommand(list, get, apply, del, variable) + return cmd +} diff --git a/internal/cli/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/hub.go b/internal/cli/hub.go new file mode 100644 index 0000000..bb7083b --- /dev/null +++ b/internal/cli/hub.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/hub" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +func newHub() *cobra.Command { + cmd := &cobra.Command{Use: "hub", Short: "Manage the hubs experiment templates are imported from."} + + var listType string + list := &cobra.Command{ + Use: "list", + Short: "List hubs.", + Args: cobra.NoArgs, + Example: examples("steadybit hub list"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.List(ctx, c, listType) }), + } + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) + + var g hub.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a hub. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit hub get -i " + hubID + " -f hub.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.Get(ctx, c, g) }), + } + idFlag(get, &g.ID, "The hub id.") + outputFlags(get, &g.File, &g.Type, "hub") + + var a hub.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update hubs from files. A file without an id creates a hub, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples("steadybit hub apply -f hub.yml --synchronize"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.Apply(ctx, c, a) }), + } + fileFlags(apply, &a.Files, &a.Recursive, "hub") + apply.Flags().BoolVar(&a.Synchronize, "synchronize", false, "Fetch the hub's templates from its repository, waiting until it is done.") + + var d hub.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a hub. The templates imported from it are kept unless --delete-imported-templates is given.", + Args: cobra.NoArgs, + Example: examples("steadybit hub delete -i " + hubID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.Delete(ctx, c, d) }), + } + idFlag(del, &d.ID, "The hub id.") + del.Flags().BoolVar(&d.Templates, "delete-imported-templates", false, "Also delete the templates imported from the hub.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + var resyncID string + resync := &cobra.Command{ + Use: "resync", + Short: "Fetch a hub's templates from its repository again, waiting until it is done.", + Args: cobra.NoArgs, + Example: examples("steadybit hub resync -i " + hubID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return hub.Resync(ctx, c, resyncID) }), + } + idFlag(resync, &resyncID, "The hub id.") + + cmd.AddCommand(list, get, apply, del, resync) + return cmd +} diff --git a/internal/cli/integration.go b/internal/cli/integration.go new file mode 100644 index 0000000..b39722e --- /dev/null +++ b/internal/cli/integration.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/integration" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +const integrationID = "0190d7b2-7d3e-7a4b-8c5d-6e7f8a9b0c1d" + +func newIntegration() *cobra.Command { + cmd := &cobra.Command{Use: "integration", Short: "Manage webhook, Slack and preflight integrations. Changing them needs an admin access token."} + for _, kind := range integration.Kinds { + cmd.AddCommand(newIntegrationKind(kind)) + } + return cmd +} + +func newIntegrationKind(k integration.Kind) *cobra.Command { + plural := k.Plural + // Slack keeps its capital in the middle of a sentence. + lower := k.Title + if k.Name != "slack" { + lower = strings.ToLower(k.Title[:1]) + k.Title[1:] + } + prefix := "steadybit integration " + k.Name + cmd := &cobra.Command{Use: k.Name, Short: "Manage " + plural + "."} + + var listType string + list := &cobra.Command{ + Use: "list", + Short: "List " + plural + ".", + Args: cobra.NoArgs, + Example: examples(prefix + " list"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return integration.List(ctx, c, k, listType) + }), + } + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) + + var g integration.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a " + lower + ". Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples(prefix + " get -i " + integrationID + " -f " + k.Name + ".yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return integration.Get(ctx, c, k, g) }), + } + idFlag(get, &g.ID, "The "+lower+" id.") + outputFlags(get, &g.File, &g.Type, lower) + + var a integration.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update " + plural + " from files. A file without an id creates one, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples(prefix+" apply -f "+k.Name+".yml", prefix+" apply -f ./integrations -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return integration.Apply(ctx, c, k, a) + }), + } + fileFlags(apply, &a.Files, &a.Recursive, lower) + + var d integration.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a " + lower + ".", + Args: cobra.NoArgs, + Example: examples(prefix + " delete -i " + integrationID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return integration.Delete(ctx, c, k, d) + }), + } + idFlag(del, &d.ID, "The "+lower+" id.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, get, apply, del) + return cmd +} diff --git a/internal/cli/killswitch.go b/internal/cli/killswitch.go new file mode 100644 index 0000000..1393794 --- /dev/null +++ b/internal/cli/killswitch.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/killswitch" + "github.com/steadybit/cli/internal/platform" +) + +func newKillswitch() *cobra.Command { + cmd := &cobra.Command{Use: "killswitch", Short: "Stop all experiments of the tenant at once, and keep them stopped."} + + var s killswitch.StatusOptions + status := &cobra.Command{ + Use: "status", + Short: "Show whether the kill switch is active.", + Args: cobra.NoArgs, + Example: examples("steadybit killswitch status", "steadybit killswitch status -t json"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return killswitch.Status(ctx, c, s) }), + } + status.Flags().StringVarP(&s.Type, "type", "t", "", `Print the status as "json" or "yaml".`) + + var yes bool + activate := &cobra.Command{ + Use: "activate", + Short: "Activate the kill switch: stop every running experiment of every team, and let none run until it is deactivated.", + Args: cobra.NoArgs, + Example: examples("steadybit killswitch activate", "steadybit killswitch activate --yes"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return killswitch.Activate(ctx, c, yes) + }), + } + activate.Flags().BoolVar(&yes, "yes", false, yesHelp) + + deactivate := &cobra.Command{ + Use: "deactivate", + Short: "Deactivate the kill switch, so that experiments can run again. Those it stopped are not restarted.", + Args: cobra.NoArgs, + Example: examples("steadybit killswitch deactivate"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return killswitch.Deactivate(ctx, c) }), + } + + cmd.AddCommand(status, activate, deactivate) + return cmd +} diff --git a/internal/cli/property.go b/internal/cli/property.go new file mode 100644 index 0000000..e20045a --- /dev/null +++ b/internal/cli/property.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/property" + "github.com/steadybit/cli/internal/resource" +) + +const associationID = "0190d7b2-9e8f-7c6d-b5a4-3f2e1d0c9b8a" + +func propertyKeyFlag(cmd *cobra.Command, key *string) { + cmd.Flags().StringVarP(key, "key", "k", "", "The property key.") + _ = cmd.MarkFlagRequired("key") +} + +func newProperty() *cobra.Command { + cmd := &cobra.Command{Use: "property", Short: "Manage the properties experiments and services carry."} + cmd.AddCommand(newPropertyDefinition(), newPropertyAssociation()) + return cmd +} + +func newPropertyDefinition() *cobra.Command { + cmd := &cobra.Command{Use: "definition", Short: "Manage property definitions: a property's key, label and type."} + + var listType string + list := &cobra.Command{ + Use: "list", + Short: "List property definitions.", + Args: cobra.NoArgs, + Example: examples("steadybit property definition list"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.ListDefinitions(ctx, c, listType) + }), + } + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) + + var g property.GetDefinitionOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a property definition. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit property definition get -k RESULT_COLOR -f result-color.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.GetDefinition(ctx, c, g) + }), + } + propertyKeyFlag(get, &g.Key) + outputFlags(get, &g.File, &g.Type, "property definition") + + var a property.ApplyDefinitionOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update property definitions from files. The key names the definition to update.", + Args: cobra.NoArgs, + Example: examples("steadybit property definition apply -f result-color.yml", "steadybit property definition apply -f ./properties -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.ApplyDefinitions(ctx, c, a) + }), + } + fileFlags(apply, &a.Files, &a.Recursive, "property definition") + apply.Flags().BoolVar(&a.DeleteValues, "delete-values", false, "Allow removing enum values still in use, deleting them where they are used.") + + var d property.DeleteDefinitionOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a property definition. Only one without associations can be deleted, unless --delete-associations is given.", + Args: cobra.NoArgs, + Example: examples("steadybit property definition delete -k RESULT_COLOR"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.DeleteDefinition(ctx, c, d) + }), + } + propertyKeyFlag(del, &d.Key) + del.Flags().BoolVar(&d.Associations, "delete-associations", false, "Also delete the associations of the property.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, get, apply, del) + return cmd +} + +func newPropertyAssociation() *cobra.Command { + cmd := &cobra.Command{Use: "association", Short: "Manage property associations: which experiments or services carry a property."} + + var l property.ListAssociationOptions + list := &cobra.Command{ + Use: "list", + Short: "List property associations.", + Args: cobra.NoArgs, + Example: examples("steadybit property association list", "steadybit property association list --key RESULT_COLOR --type EXPERIMENT"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.ListAssociations(ctx, c, l) + }), + } + list.Flags().StringVar(&l.Key, "key", "", "Only list associations of this property.") + list.Flags().StringVar(&l.Experiment, "experiment", "", "Only list associations given to this experiment, by key. Those for all experiments are not listed.") + list.Flags().StringVar(&l.Service, "service", "", "Only list associations given to this service, by id. Those for all services are not listed.") + list.Flags().StringVar(&l.Type, "type", "", `Only list "EXPERIMENT" or "SERVICE" associations.`) + list.Flags().StringVar(&l.Output, "output", "", resource.ListTypeHelp) + + var g property.GetAssociationOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a property association. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit property association get -i " + associationID + " -f association.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.GetAssociation(ctx, c, g) + }), + } + idFlag(get, &g.ID, "The property association id.") + outputFlags(get, &g.File, &g.Type, "property association") + + var a property.ApplyAssociationOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update property associations from files. A file without an id creates one, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples("steadybit property association apply -f association.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.ApplyAssociations(ctx, c, a) + }), + } + fileFlags(apply, &a.Files, &a.Recursive, "property association") + + var d property.DeleteAssociationOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a property association. Only one whose values are not used can be deleted, unless --delete-values is given.", + Args: cobra.NoArgs, + Example: examples("steadybit property association delete -i " + associationID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return property.DeleteAssociation(ctx, c, d) + }), + } + idFlag(del, &d.ID, "The property association id.") + del.Flags().BoolVar(&d.DeleteValues, "delete-values", false, "Also delete the values experiments and schedules have for it.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, get, apply, del) + return cmd +} diff --git a/internal/cli/report.go b/internal/cli/report.go new file mode 100644 index 0000000..b423e6d --- /dev/null +++ b/internal/cli/report.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/report" +) + +func newReport() *cobra.Command { + cmd := &cobra.Command{Use: "report", Short: "Print reports on the tenant as time series. Output is written to file or stdout."} + for _, kind := range report.Kinds { + cmd.AddCommand(newReportKind(kind)) + } + return cmd +} + +func newReportKind(k report.Kind) *cobra.Command { + var o report.Options + example := []string{"steadybit report " + k.Name + " --from 2026-01-01 --to 2026-06-30 --rollup MONTHLY"} + cmd := &cobra.Command{ + Use: k.Name, + Short: k.Short, + Args: cobra.NoArgs, + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return report.Run(ctx, c, k, o) }), + } + flags := cmd.Flags() + flags.StringVar(&o.From, "from", "", "The first day, e.g. 2026-09-01. (default: 30 days before --to)") + flags.StringVar(&o.To, "to", "", "The last day, e.g. 2026-09-30. (default: today)") + flags.StringVar(&o.Rollup, "rollup", "", `Sum up per "DAILY" or "MONTHLY" bucket.`) + outputFlags(cmd, &o.File, &o.Type, "report") + if len(k.GroupBy) > 0 { + flags.StringVar(&o.GroupBy, "group-by", "", "Split the series by one of "+strings.Join(k.GroupBy, ", ")+".") + example = append(example, "steadybit report "+k.Name+" --group-by "+k.GroupBy[1]+" --team ADM -t json") + } + var variadics []string + if k.Takes(report.Teams) { + flags.StringArrayVar(&o.Teams, "team", nil, "Only count these teams, by team key.") + variadics = append(variadics, "team") + } + if k.Takes(report.Environments) { + flags.StringArrayVar(&o.Environments, "environment", nil, "Only count these environments, by name.") + variadics = append(variadics, "environment") + } + if k.Takes(report.Services) { + flags.StringArrayVar(&o.Services, "service", nil, "Only count these services, by id.") + variadics = append(variadics, "service") + } + if k.Takes(report.ServiceProperties) { + flags.StringArrayVar(&o.ServiceProperties, "service-property", nil, "Only count services with this property, as KEY=VALUE. Repeat for more.") + } + if k.Takes(report.Categories) { + flags.StringArrayVar(&o.Categories, "category", nil, "Only count these risk categories.") + variadics = append(variadics, "category") + } + variadic(cmd, variadics...) + cmd.Example = examples(example...) + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0812837..5a739f2 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(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newHub(), newIntegration(), newKillswitch(), newProperty(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser(), + 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/target.go b/internal/cli/target.go new file mode 100644 index 0000000..0538346 --- /dev/null +++ b/internal/cli/target.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/action" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/target" +) + +const listTypeHelp = `Print the list as "json" or "yaml" instead of a table.` + +func environmentNameFlag(cmd *cobra.Command, environment *string) { + cmd.Flags().StringVarP(environment, "environment", "e", "", "The environment name.") + _ = cmd.MarkFlagRequired("environment") +} + +func newTarget() *cobra.Command { + cmd := &cobra.Command{Use: "target", Short: "Find the targets of an environment and their attributes."} + + var q target.QueryOptions + query := &cobra.Command{ + Use: "query", + Short: "List the targets of an environment, optionally of one type and matching a query.", + Args: cobra.NoArgs, + Example: examples( + "steadybit target query -e Global --target-type com.steadybit.extension_container.container", + `steadybit target query -e Global -q 'k8s.namespace="shop"' --attribute k8s.deployment k8s.pod.name --limit 0 -t json`, + ), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return target.Query(ctx, c, q) }), + } + environmentNameFlag(query, &q.Environment) + query.Flags().StringVar(&q.TargetType, "target-type", "", "Only list targets of this type.") + query.Flags().StringVarP(&q.Query, "query", "q", "", "Only list targets matching this target query.") + query.Flags().StringArrayVar(&q.Attributes, "attribute", nil, "Only fetch these attributes, shown as columns. (default: all, not shown)") + query.Flags().IntVar(&q.Limit, "limit", 100, "List at most this many targets; 0 lists all.") + query.Flags().StringVarP(&q.Type, "type", "t", "", listTypeHelp) + variadic(query, "attribute") + + attribute := &cobra.Command{Use: "attribute", Short: "List the attribute keys and values of targets."} + attributeFlags := func(c *cobra.Command, o *target.AttributeOptions) { + environmentNameFlag(c, &o.Environment) + c.Flags().StringVar(&o.TargetType, "target-type", "", "The target type. Either this or --action is required.") + c.Flags().StringVar(&o.Action, "action", "", "The action whose target selection to use, for actions with an extended one.") + c.Flags().StringVarP(&o.Type, "type", "t", "", listTypeHelp) + } + var k target.AttributeOptions + keys := &cobra.Command{ + Use: "keys", + Short: "List the attribute keys the targets of a type have.", + Args: cobra.NoArgs, + Example: examples("steadybit target attribute keys -e Global --target-type com.steadybit.extension_container.container"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return target.AttributeKeys(ctx, c, k) + }), + } + attributeFlags(keys, &k) + var v target.AttributeOptions + values := &cobra.Command{ + Use: "values", + Short: "List the values an attribute has on the targets of a type.", + Args: cobra.NoArgs, + Example: examples("steadybit target attribute values -e Global --target-type com.steadybit.extension_container.container --key k8s.namespace"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return target.AttributeValues(ctx, c, v) + }), + } + attributeFlags(values, &v) + values.Flags().StringVarP(&v.Key, "key", "k", "", "The attribute key.") + _ = values.MarkFlagRequired("key") + attribute.AddCommand(keys, values) + + cmd.AddCommand(query, attribute) + return cmd +} + +func newAction() *cobra.Command { + cmd := &cobra.Command{Use: "action", Short: "Find the actions experiments can use."} + + var l action.ListOptions + list := &cobra.Command{ + Use: "list", + Short: "List the actions the extensions provide.", + Args: cobra.NoArgs, + Example: examples("steadybit action list", "steadybit action list --kind ATTACK CHECK"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return action.List(ctx, c, l) }), + } + list.Flags().StringArrayVar(&l.Kinds, "kind", nil, `Only list actions of these kinds: "ATTACK", "CHECK", "LOAD_TEST", "OTHER" or "BASIC".`) + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) + variadic(list, "kind") + + var g action.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get an action with its parameters. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit action get -i com.steadybit.extension_container.stress_cpu"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return action.Get(ctx, c, g) }), + } + idFlag(get, &g.ID, "The action id.") + outputFlags(get, &g.File, &g.Type, "action") + + cmd.AddCommand(list, get) + return cmd +} diff --git a/internal/cli/team.go b/internal/cli/team.go new file mode 100644 index 0000000..7d7c71e --- /dev/null +++ b/internal/cli/team.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/team" +) + +func teamKeyFlag(cmd *cobra.Command, key *string) { + cmd.Flags().StringVarP(key, "key", "k", "", "The team key.") + _ = cmd.MarkFlagRequired("key") +} + +func newTeam() *cobra.Command { + cmd := &cobra.Command{Use: "team", Short: "Manage teams, their members and environments."} + + var l team.ListOptions + list := &cobra.Command{ + Use: "list", + Short: "List teams.", + Args: cobra.NoArgs, + Example: examples("steadybit team list", "steadybit team list --search jane@example.com"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.List(ctx, c, l) }), + } + list.Flags().StringVar(&l.Search, "search", "", "Only list teams whose name or key, or a member's name or email, matches.") + list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp) + + var g team.GetOptions + get := &cobra.Command{ + Use: "get", + Short: "Get a team. Output is written to file or stdout.", + Args: cobra.NoArgs, + Example: examples("steadybit team get -k ADM -f team.yml"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.Get(ctx, c, g) }), + } + teamKeyFlag(get, &g.Key) + outputFlags(get, &g.File, &g.Type, "team") + + var a team.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update teams from files. The team key names the team to update.", + Args: cobra.NoArgs, + Example: examples("steadybit team apply -f team.yml", "steadybit team apply -f ./teams -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.Apply(ctx, c, a) }), + } + fileFlags(apply, &a.Files, &a.Recursive, "team") + + var d team.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete a team, with --purge-experiments, which the platform requires. Nothing may be running in it.", + Args: cobra.NoArgs, + Example: examples("steadybit team delete -k OPS --purge-experiments", "steadybit team delete -k OPS --purge-experiments --yes"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return team.Delete(ctx, c, d) }), + } + teamKeyFlag(del, &d.Key) + del.Flags().BoolVar(&d.Experiments, "purge-experiments", false, "Also delete the team's experiments and their runs.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + cmd.AddCommand(list, get, apply, del, newTeamMember(), newTeamEnvironment()) + return cmd +} + +func newTeamMember() *cobra.Command { + cmd := &cobra.Command{Use: "member", Short: "Manage the members of a team."} + + var key, listType string + list := &cobra.Command{ + Use: "list", + Short: "List the members of a team.", + Args: cobra.NoArgs, + Example: examples("steadybit team member list -k ADM"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return team.ListMembers(ctx, c, key, listType) + }), + } + teamKeyFlag(list, &key) + list.Flags().StringVarP(&listType, "type", "t", "", resource.ListTypeHelp) + + change := func(use, short string, withRole, confirm bool, example string, run func(context.Context, *platform.Client, team.MemberOptions) error) *cobra.Command { + var o team.MemberOptions + c := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.NoArgs, + Example: examples(example), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return run(ctx, c, o) }), + } + teamKeyFlag(c, &o.Key) + c.Flags().StringArrayVar(&o.Usernames, "username", nil, "Members by username.") + c.Flags().StringArrayVar(&o.Emails, "email", nil, "Members by email.") + variadic(c, "username", "email") + if withRole { + c.Flags().StringVar(&o.Role, "role", "MEMBER", `The role of the given members, "MEMBER" or "OWNER".`) + c.Flags().BoolVar(&o.Validate, "validate", false, "Fail on users the platform does not know, instead of skipping them.") + } + if confirm { + c.Flags().BoolVar(&o.Yes, "yes", false, yesHelp) + } + return c + } + cmd.AddCommand(list, + change("add", "Add members to a team, or change the role of existing ones.", true, false, + "steadybit team member add -k ADM --email jane@example.com joe@example.com --role OWNER", team.AddMembers), + change("remove", "Remove members from a team. They keep their account.", false, true, + "steadybit team member remove -k ADM --email jane@example.com", team.RemoveMembers), + change("set", "Make the given users the only members of a team, removing everyone else.", true, true, + "steadybit team member set -k ADM --email jane@example.com --role OWNER --yes", team.SetMembers), + ) + return cmd +} + +func newTeamEnvironment() *cobra.Command { + cmd := &cobra.Command{Use: "environment", Short: "Manage the environments a team may use."} + + var key, envListType string + list := &cobra.Command{ + Use: "list", + Short: "List the environments of a team.", + Args: cobra.NoArgs, + Example: examples("steadybit team environment list -k ADM"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { + return team.ListEnvironments(ctx, c, key, envListType) + }), + } + teamKeyFlag(list, &key) + list.Flags().StringVarP(&envListType, "type", "t", "", resource.ListTypeHelp) + + change := func(use, short string, validate, confirm bool, example string, run func(context.Context, *platform.Client, team.EnvironmentOptions) error) *cobra.Command { + var o team.EnvironmentOptions + c := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.NoArgs, + Example: examples(example), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return run(ctx, c, o) }), + } + teamKeyFlag(c, &o.Key) + c.Flags().StringArrayVar(&o.Environments, "environment", nil, "Environments by name.") + variadic(c, "environment") + if validate { + c.Flags().BoolVar(&o.Validate, "validate", false, "Fail on environments the platform does not know, instead of skipping them.") + } + if confirm { + c.Flags().BoolVar(&o.Yes, "yes", false, yesHelp) + } + return c + } + cmd.AddCommand(list, + change("add", "Allow a team to use environments.", true, false, + `steadybit team environment add -k ADM --environment Global "Online Shop"`, team.AddEnvironments), + change("remove", "Stop a team from using environments.", false, false, + "steadybit team environment remove -k ADM --environment Global", team.RemoveEnvironments), + change("set", "Make the given environments the only ones a team may use.", true, true, + "steadybit team environment set -k ADM --environment Global --yes", team.SetEnvironments), + ) + return cmd +} diff --git a/internal/cli/template.go b/internal/cli/template.go index fd90864..a44539d 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -8,13 +8,33 @@ import ( "github.com/spf13/cobra" "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" "github.com/steadybit/cli/internal/template" ) -const typeHelp = `The output format ("json" or "yaml"). (default: "json" if the file ends in ".json", "yaml" otherwise.)` +const ( + typeHelp = `The output format ("json" or "yaml"). (default: "json" if the file ends in ".json", "yaml" otherwise.)` + yesHelp = "Skip the confirmation prompt. Not necessary when no TTY is attached." + templateID = "d7e65100-1d20-4980-be87-c351704910b8" + hubID = "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a10" +) + +// fileFlags adds the flags every `apply` takes. +func fileFlags(cmd *cobra.Command, files *[]string, recursive *bool, what string) { + cmd.Flags().StringArrayVarP(files, "file", "f", nil, "The path to the "+what+" file or a directory containing multiple files.") + cmd.Flags().BoolVarP(recursive, "recursive", "R", false, "Process the directory used in -f, --file recursively.") + _ = cmd.MarkFlagRequired("file") + variadic(cmd, "file") +} + +// outputFlags adds the flags every `get` takes. +func outputFlags(cmd *cobra.Command, file, datatype *string, what string) { + cmd.Flags().StringVarP(file, "file", "f", "", "The path to write the "+what+" to.") + cmd.Flags().StringVarP(datatype, "type", "t", "", typeHelp) +} func newTemplate() *cobra.Command { - cmd := &cobra.Command{Use: "template", Short: "Find experiment templates to create experiments from."} + cmd := &cobra.Command{Use: "template", Short: "Manage the experiment templates to create experiments from."} var l template.ListOptions list := &cobra.Command{ @@ -31,6 +51,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 @@ -39,8 +60,8 @@ func newTemplate() *cobra.Command { Short: "Get an experiment template. Output is written to file or stdout.", Args: cobra.NoArgs, Example: examples( - "steadybit template get -i d7e65100-1d20-4980-be87-c351704910b8", - "steadybit template get -i d7e65100-1d20-4980-be87-c351704910b8 --placeholders -f values.yml", + "steadybit template get -i "+templateID+" -f template.yml", + "steadybit template get -i "+templateID+" --placeholders -f values.yml", ), RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return template.Get(ctx, c, g) }), } @@ -50,6 +71,42 @@ func newTemplate() *cobra.Command { get.Flags().BoolVar(&g.Placeholders, "placeholders", false, "Only output the template placeholders, as a file to fill in and pass to --placeholders.") _ = get.MarkFlagRequired("id") - cmd.AddCommand(list, get) + var a template.ApplyOptions + apply := &cobra.Command{ + Use: "apply", + Short: "Create or update experiment templates from files. A file without an id creates a template, and the new id is written back to it.", + Args: cobra.NoArgs, + Example: examples("steadybit template apply -f template.yml", "steadybit template apply -f ./templates -R"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return template.Apply(ctx, c, a) }), + } + fileFlags(apply, &a.Files, &a.Recursive, "template") + + var d template.DeleteOptions + del := &cobra.Command{ + Use: "delete", + Short: "Delete an experiment template. Service profiles lose it, and the experiments they provided from it are deleted.", + Args: cobra.NoArgs, + Example: examples("steadybit template delete -i " + templateID), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return template.Delete(ctx, c, d) }), + } + idFlag(del, &d.ID, "The experiment template id.") + del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp) + + var im template.ImportOptions + imp := &cobra.Command{ + Use: "import", + Short: "Import experiment templates from a connected hub.", + Args: cobra.NoArgs, + Example: examples("steadybit template import --hub " + hubID + " --template " + templateID + " --overwrite"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return template.Import(ctx, c, im) }), + } + imp.Flags().StringVar(&im.Hub, "hub", "", "The id of the hub, see `steadybit hub list`.") + imp.Flags().StringArrayVar(&im.Templates, "template", nil, "The ids of the hub's templates to import.") + imp.Flags().BoolVar(&im.Overwrite, "overwrite", false, "Replace templates that exist already. Without it, the import fails if any does.") + _ = imp.MarkFlagRequired("hub") + _ = imp.MarkFlagRequired("template") + variadic(imp, "template") + + cmd.AddCommand(list, get, apply, del, imp) return cmd } diff --git a/internal/cli/user.go b/internal/cli/user.go new file mode 100644 index 0000000..435f530 --- /dev/null +++ b/internal/cli/user.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/user" +) + +func newUser() *cobra.Command { + cmd := &cobra.Command{Use: "user", Short: "Manage the users of the tenant."} + + var o user.InviteOptions + invite := &cobra.Command{ + Use: "invite", + Short: "Invite users by email. Each receives an email with a link to join. Needs an admin access token.", + Args: cobra.NoArgs, + Example: examples("steadybit user invite --email jane@example.com joe@example.com --team ADM"), + RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return user.Invite(ctx, c, o) }), + } + invite.Flags().StringArrayVar(&o.Emails, "email", nil, "The email addresses to invite.") + invite.Flags().StringVar(&o.Role, "role", "", `The role in the tenant, "USER" or "ADMIN". (default: the platform's default role)`) + invite.Flags().StringVar(&o.Team, "team", "", "The key of a team to add them to.") + _ = invite.MarkFlagRequired("email") + variadic(invite, "email") + + cmd.AddCommand(invite) + return cmd +} diff --git a/internal/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/environment/environment.go b/internal/environment/environment.go new file mode 100644 index 0000000..c685706 --- /dev/null +++ b/internal/environment/environment.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package environment implements the `environment` commands. +package environment + +import ( + "context" + "fmt" + "net/http" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// The state is the platform's, and the version is dropped as `service get` drops it: kept +// in a file, it turns every apply after an edit in the UI into a conflict. +var readOnly = []string{"version", "state"} + +func uuid(id string) (openapi_types.UUID, error) { + u, ok := resource.UUID(id) + if !ok { + return u, fmt.Errorf("Environment %s not found.", id) + } + return u, nil +} + +func notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Environment %s not found.", id) + } + return platform.Failed(err, format, id) +} + +type ListOptions struct { + Search string + Type string +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + var summaries struct { + Environments []struct { + ID, Name, State, Query string + } `json:"environments"` + } + params := &api.GetEnvironmentsParams{} + if o.Search != "" { + params.Search = &o.Search + } + resp, err := c.GetEnvironments(ctx, params) + raw, err := resource.DecodeListed(resp, err, "environments", &summaries) + if err != nil { + return platform.Failed(err, "Failed to get the environments") + } + if resource.Machine(o.Type) { + return resource.List(raw, o.Type, nil) + } + if len(summaries.Environments) == 0 { + fmt.Println("No environments found.") + return nil + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "state", Title: "State", Alignment: table.Left}, + ) + for _, e := range summaries.Environments { + t.AddRow(table.Default, table.Cell("id", e.ID), table.Cell("name", e.Name), table.Cell("state", e.State)) + } + t.Print() + return nil +} + +type GetOptions struct { + ID, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, o GetOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + doc, _, err := platform.ReadDocument(c.GetEnvironment(ctx, id)) + if err != nil { + return notFoundOr(err, o.ID, "Failed to get environment %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Environment %s written to %s.\n", o.ID, o.File) + } + return nil +} + +type ApplyOptions struct { + Files []string + Recursive bool +} + +func Apply(ctx context.Context, c *platform.Client, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "environment", func(file string, doc *output.Document) (resource.Applied, error) { + name, _ := doc.Get("name") + if name == "" { + return resource.Applied{}, fmt.Errorf("Environment file '%s' does not name the environment.", file) + } + var saved struct{ ID, Name string } + resp, err := c.UpsertEnvironmentWithBody(ctx, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save environment %s", name) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Environment %s (%s) %s.\n", saved.Name, saved.ID, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteOptions struct { + ID string + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Delete environment %s?", o.ID)); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteEnvironment(ctx, id)); err != nil { + return notFoundOr(err, o.ID, "Failed to delete environment %s") + } + fmt.Printf("Environment %s deleted.\n", o.ID) + return nil +} + +type VariableGetOptions struct { + ID, Type string +} + +func GetVariables(ctx context.Context, c *platform.Client, o VariableGetOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + // The spec declares a string; the platform answers with the map of variables. + doc, _, err := platform.ReadDocument(c.GetEnvironmentVariables(ctx, id)) + if err != nil { + return notFoundOr(err, o.ID, "Failed to get the variables of environment %s") + } + return resource.Output(doc, "", o.Type) +} + +type VariableSetOptions struct { + ID string + File string + Replace bool +} + +// SetVariables merges KEY=VALUE arguments over a file's variables with PUT; with +// --replace, POST makes them the only ones. +func SetVariables(ctx context.Context, c *platform.Client, pairs []string, o VariableSetOptions) error { + variables, err := resource.Variables(pairs, o.File, o.Replace) + if err != nil { + return err + } + id, err := uuid(o.ID) + if err != nil { + return err + } + body := resource.Body(variables) + if o.Replace { + _, _, err = platform.Read(c.SetEnvironmentVariablesWithBody(ctx, id, "application/json", body)) + } else { + _, _, err = platform.Read(c.UpdateEnvironmentVariablesWithBody(ctx, id, "application/json", body)) + } + if err != nil { + return notFoundOr(err, o.ID, "Failed to update the variables of environment %s") + } + fmt.Printf("%d variable(s) of environment %s %s.\n", variables.Len(), o.ID, resource.VariablesOutcome(o.Replace)) + return nil +} diff --git a/internal/environment/environment_test.go b/internal/environment/environment_test.go new file mode 100644 index 0000000..a5011da --- /dev/null +++ b/internal/environment/environment_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package environment_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/environment" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "0190d7b2-1c5e-7f3a-8e4b-2d6f9a1c3e57" + +func TestListSearches(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/environments", platformtest.Reply{JSON: map[string]any{"environments": []any{map[string]any{"id": id, "name": "Prod", "state": "READY"}}}}) + + out, err := platformtest.Stdout(t, func() error { return environment.List(ctx, p.Client, environment.ListOptions{Search: "pro"}) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ Prod │ READY │") + assert.Equal(t, []string{"pro"}, p.Requests("GET /api/environments")[0].Query["search"]) +} + +func TestListSaysWhenThereIsNone(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/environments", platformtest.Reply{JSON: map[string]any{"environments": []any{}}}) + + out, err := platformtest.Stdout(t, func() error { return environment.List(ctx, p.Client, environment.ListOptions{}) }) + + require.NoError(t, err) + assert.Equal(t, "No environments found.\n", out) + assert.NotContains(t, p.Requests("GET /api/environments")[0].Query, "search") +} + +func TestGetAndApplyRoundTrip(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/environments/"+id, platformtest.Reply{Body: `{"id":"` + id + `","name":"Prod","version":4,"query":"k8s.cluster-name=\"prod\"","state":"READY"}`}) + p.Reply("POST /api/environments", platformtest.Reply{JSON: map[string]any{"id": id, "name": "Prod"}}) + file := filepath.Join(t.TempDir(), "environment.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := environment.Get(ctx, p.Client, environment.GetOptions{ID: id, File: file}); err != nil { + return err + } + return environment.Apply(ctx, p.Client, environment.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Environment Prod ("+id+") updated.") + content, _ := os.ReadFile(file) + assert.Equal(t, "id: "+id+"\nname: Prod\nquery: k8s.cluster-name=\"prod\"\n", string(content)) + assert.Equal(t, map[string]any{"id": id, "name": "Prod", "query": `k8s.cluster-name="prod"`}, p.Requests("POST /api/environments")[0].JSON(t)) +} + +func TestApplyCreatesAndWritesTheIdBack(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/environments", platformtest.Reply{Status: http.StatusCreated, JSON: map[string]any{"id": id, "name": "New"}}) + file := filepath.Join(t.TempDir(), "new.json") + require.NoError(t, os.WriteFile(file, []byte(`{"name":"New","query":"x"}`), 0o644)) + + out, err := platformtest.Stdout(t, func() error { return environment.Apply(ctx, p.Client, environment.ApplyOptions{Files: []string{file}}) }) + + require.NoError(t, err) + assert.Equal(t, "Environment New ("+id+") created.\n", out) + content, _ := os.ReadFile(file) + assert.Equal(t, "{\n \"id\": \""+id+"\",\n \"name\": \"New\",\n \"query\": \"x\"\n}", string(content)) +} + +func TestDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/environments/"+id, platformtest.Reply{Status: http.StatusNotFound}) + + err := environment.Delete(ctx, p.Client, environment.DeleteOptions{ID: id, Yes: true}) + + assert.EqualError(t, err, "Environment "+id+" not found.") + assert.EqualError(t, environment.Delete(ctx, p.Client, environment.DeleteOptions{ID: "Prod", Yes: true}), "Environment Prod not found.") +} + +func TestVariablesMergeUnlessReplaced(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/environments/"+id+"/variables", platformtest.Reply{Body: `{"region":"eu"}`}) + p.Reply("PUT /api/environments/"+id+"/variables", platformtest.Reply{}) + p.Reply("POST /api/environments/"+id+"/variables", platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + if err := environment.GetVariables(ctx, p.Client, environment.VariableGetOptions{ID: id, Type: "json"}); err != nil { + return err + } + if err := environment.SetVariables(ctx, p.Client, []string{"region=us"}, environment.VariableSetOptions{ID: id}); err != nil { + return err + } + return environment.SetVariables(ctx, p.Client, nil, environment.VariableSetOptions{ID: id, Replace: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "{\n \"region\": \"eu\"\n}\n1 variable(s) of environment "+id+" set.\n0 variable(s) of environment "+id+" set, all others removed.\n", out) + assert.Equal(t, map[string]any{"region": "us"}, p.Requests("PUT /api/environments/" + id + "/variables")[0].JSON(t)) + assert.Equal(t, map[string]any{}, p.Requests("POST /api/environments/" + id + "/variables")[0].JSON(t)) + assert.EqualError(t, environment.SetVariables(ctx, p.Client, nil, environment.VariableSetOptions{ID: id}), "No variables given. Pass KEY=VALUE arguments or --file.") +} diff --git a/internal/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/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..c2abd3f --- /dev/null +++ b/internal/hub/hub.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package hub implements the `hub` commands. +package hub + +import ( + "context" + "fmt" + "net/http" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// What the last synchronisation found and who edited the hub is the platform's. The +// version is dropped as `service get` drops it. +var readOnly = []string{"version", "templates", "lastSync", "lastRepositoryChange", "syncError", "created", "createdBy", "edited", "editedBy"} + +func uuid(id string) (openapi_types.UUID, error) { + u, ok := resource.UUID(id) + if !ok { + return u, fmt.Errorf("Hub %s not found.", id) + } + return u, nil +} + +func notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Hub %s not found.", id) + } + return platform.Failed(err, format, id) +} + +func List(ctx context.Context, c *platform.Client, explicitType string) error { + var summaries struct { + Hubs []struct { + ID string `json:"id"` + HubName string `json:"hubName"` + } `json:"hubs"` + } + resp, err := c.GetHubs(ctx) + raw, err := resource.DecodeListed(resp, err, "hubs", &summaries) + if err != nil { + return platform.Failed(err, "Failed to get the hubs") + } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } + if len(summaries.Hubs) == 0 { + fmt.Println("No hubs found.") + return nil + } + t := table.New(table.Column{Name: "id", Title: "Id", Alignment: table.Left}, table.Column{Name: "name", Title: "Name", Alignment: table.Left}) + for _, h := range summaries.Hubs { + t.AddRow(table.Default, table.Cell("id", h.ID), table.Cell("name", h.HubName)) + } + t.Print() + return nil +} + +type GetOptions struct { + ID, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, o GetOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + doc, _, err := platform.ReadDocument(c.GetHubById(ctx, id)) + if err != nil { + return notFoundOr(err, o.ID, "Failed to get hub %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Hub %s written to %s.\n", o.ID, o.File) + } + return nil +} + +type ApplyOptions struct { + Files []string + Recursive bool + Synchronize bool +} + +func Apply(ctx context.Context, c *platform.Client, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "hub", func(file string, doc *output.Document) (resource.Applied, error) { + name, _ := doc.Get("hubName") + if name == "" { + return resource.Applied{}, fmt.Errorf("Hub file '%s' does not name a hubName.", file) + } + var saved struct { + ID string `json:"id"` + HubName string `json:"hubName"` + } + resp, err := c.UpsertHubWithBody(ctx, &api.UpsertHubParams{Synchronize: &o.Synchronize}, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save hub %s", name) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Hub %s (%s) %s.\n", saved.HubName, saved.ID, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteOptions struct { + ID string + Templates bool + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + id, err := uuid(o.ID) + if err != nil { + return err + } + question := fmt.Sprintf("Delete hub %s? The templates imported from it are kept.", o.ID) + if o.Templates { + question = fmt.Sprintf("Delete hub %s and the templates imported from it?", o.ID) + } + if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteHub(ctx, id, &api.DeleteHubParams{DeleteImportedTemplates: &o.Templates})); err != nil { + return notFoundOr(err, o.ID, "Failed to delete hub %s") + } + fmt.Printf("Hub %s deleted.\n", o.ID) + return nil +} + +// Resync fetches the hub's repository again; the platform answers once it is done. +func Resync(ctx context.Context, c *platform.Client, idText string) error { + id, err := uuid(idText) + if err != nil { + return err + } + var hub struct { + HubName string `json:"hubName"` + Templates []any `json:"templates"` + SyncError string `json:"syncError"` + } + resp, err := c.ResyncHub(ctx, id) + if _, err := platform.Decode(resp, err, &hub); err != nil { + return notFoundOr(err, idText, "Failed to resynchronize hub %s") + } + if hub.SyncError != "" { + return fmt.Errorf("Hub %s could not be synchronized: %s", hub.HubName, hub.SyncError) + } + fmt.Printf("Hub %s synchronized, %d template(s).\n", hub.HubName, len(hub.Templates)) + return nil +} diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go new file mode 100644 index 0000000..6f277d9 --- /dev/null +++ b/internal/hub/hub_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package hub_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/hub" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a10" + +const stored = `{"hubName":"Reliability Hub","hubLink":"https://hub.example.com","repositoryUrl":"https://example.com/index.json","id":"` + id + `","version":2,` + + `"templates":[{"id":"t-1"}],"lastSync":"s","lastRepositoryChange":"r","syncError":null,"created":"c","createdBy":{},"edited":"e","editedBy":{}}` + +func TestList(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/hubs", platformtest.Reply{JSON: map[string]any{"hubs": []any{map[string]any{"id": id, "hubName": "Reliability Hub"}}}}) + + out, err := platformtest.Stdout(t, func() error { return hub.List(ctx, p.Client, "") }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ Reliability Hub │") +} + +func TestGetAndApplyRoundTrip(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/hubs/"+id, platformtest.Reply{Body: stored}) + p.Reply("POST /api/hubs", platformtest.Reply{Body: stored}) + file := filepath.Join(t.TempDir(), "hub.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := hub.Get(ctx, p.Client, hub.GetOptions{ID: id, File: file}); err != nil { + return err + } + return hub.Apply(ctx, p.Client, hub.ApplyOptions{Files: []string{file}, Synchronize: true}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Hub Reliability Hub ("+id+") updated.") + content, _ := os.ReadFile(file) + assert.Equal(t, "hubName: Reliability Hub\nhubLink: https://hub.example.com\nrepositoryUrl: https://example.com/index.json\nid: "+id+"\n", string(content)) + sent := p.Requests("POST /api/hubs")[0] + assert.Equal(t, []string{"true"}, sent.Query["synchronize"]) + assert.Equal(t, map[string]any{"hubName": "Reliability Hub", "hubLink": "https://hub.example.com", "repositoryUrl": "https://example.com/index.json", "id": id}, sent.JSON(t)) +} + +func TestDeleteKeepsTemplatesUnlessAsked(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/hubs/"+id, platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + if err := hub.Delete(ctx, p.Client, hub.DeleteOptions{ID: id, Yes: true}); err != nil { + return err + } + return hub.Delete(ctx, p.Client, hub.DeleteOptions{ID: id, Templates: true, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Hub "+id+" deleted.\nHub "+id+" deleted.\n", out) + requests := p.Requests("DELETE /api/hubs/" + id) + assert.Equal(t, []string{"false"}, requests[0].Query["deleteImportedTemplates"]) + assert.Equal(t, []string{"true"}, requests[1].Query["deleteImportedTemplates"]) +} + +func TestResync(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/hubs/"+id+"/resync", platformtest.Reply{Body: stored}) + p.Reply("POST /api/hubs/0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a11/resync", platformtest.Reply{Body: `{"hubName":"Broken","templates":[],"syncError":"index.json not found"}`}) + p.Reply("POST /api/hubs/0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a12/resync", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { return hub.Resync(ctx, p.Client, id) }) + + require.NoError(t, err) + assert.Equal(t, "Hub Reliability Hub synchronized, 1 template(s).\n", out) + assert.EqualError(t, hub.Resync(ctx, p.Client, "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a11"), "Hub Broken could not be synchronized: index.json not found") + assert.EqualError(t, hub.Resync(ctx, p.Client, "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a12"), "Hub 0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a12 not found.") +} diff --git a/internal/integration/integration.go b/internal/integration/integration.go new file mode 100644 index 0000000..ccf778e --- /dev/null +++ b/internal/integration/integration.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package integration implements the `integration` commands. The four kinds of integration +// have the same endpoints, so one implementation serves them all. +package integration + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// The version is dropped as `service get` drops it: kept in a file, it turns every apply +// after an edit in the UI into a conflict. +var readOnly = []string{"version"} + +type Kind struct { + Name string // as the command names it + Title string // as messages name it + Plural string + // The column that says where the integration reports to. + Column, ColumnTitle string + + list func(ctx context.Context, c *platform.Client) (*http.Response, error) + get func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) + upsert func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) + delete func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) +} + +var ( + Webhook = Kind{ + Name: "webhook", Title: "Webhook integration", Plural: "webhook integrations", Column: "url", ColumnTitle: "URL", + list: func(ctx context.Context, c *platform.Client) (*http.Response, error) { return c.GetCustomWebhooks(ctx) }, + get: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.GetCustomWebhook(ctx, id) + }, + upsert: func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) { + return c.UpsertCustomWebhookWithBody(ctx, "application/json", body) + }, + delete: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.DeleteCustomWebhook(ctx, id) + }, + } + Slack = Kind{ + Name: "slack", Title: "Slack integration", Plural: "Slack integrations", Column: "channel", ColumnTitle: "Channel", + list: func(ctx context.Context, c *platform.Client) (*http.Response, error) { + return c.GetSlackIntegrations(ctx) + }, + get: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.GetSlackIntegration(ctx, id) + }, + upsert: func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) { + return c.UpsertSlackIntegrationWithBody(ctx, "application/json", body) + }, + delete: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.DeleteSlackIntegration(ctx, id) + }, + } + Preflight = Kind{ + Name: "preflight", Title: "Preflight webhook", Plural: "preflight webhooks", Column: "url", ColumnTitle: "URL", + list: func(ctx context.Context, c *platform.Client) (*http.Response, error) { + return c.GetPreflightWebhooks(ctx) + }, + get: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.GetPreflightWebhook(ctx, id) + }, + upsert: func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) { + return c.UpsertPreflightWebhookWithBody(ctx, "application/json", body) + }, + delete: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.DeletePreflightWebhook(ctx, id) + }, + } + PreflightAction = Kind{ + Name: "preflight-action", Title: "Preflight action integration", Plural: "preflight action integrations", Column: "preflightActionId", ColumnTitle: "Preflight action", + list: func(ctx context.Context, c *platform.Client) (*http.Response, error) { + return c.GetPreflightActionIntegrations(ctx) + }, + get: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.GetPreflightActionIntegration(ctx, id) + }, + upsert: func(ctx context.Context, c *platform.Client, body io.Reader) (*http.Response, error) { + return c.UpsertPreflightActionIntegrationWithBody(ctx, "application/json", body) + }, + delete: func(ctx context.Context, c *platform.Client, id openapi_types.UUID) (*http.Response, error) { + return c.DeletePreflightActionIntegration(ctx, id) + }, + } + Kinds = []Kind{Webhook, Slack, Preflight, PreflightAction} +) + +func (k Kind) uuid(id string) (openapi_types.UUID, error) { + u, ok := resource.UUID(id) + if !ok { + return u, fmt.Errorf("%s %s not found.", k.Title, id) + } + return u, nil +} + +func (k Kind) notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("%s %s not found.", k.Title, id) + } + return platform.Failed(err, format, k.Title, id) +} + +func List(ctx context.Context, c *platform.Client, k Kind, explicitType string) error { + var result struct { + Content []map[string]any `json:"content"` + } + resp, err := k.list(ctx, c) + raw, err := resource.DecodeListed(resp, err, "content", &result) + if err != nil { + return platform.Failed(err, "Failed to get the %s", k.Plural) + } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } + if len(result.Content) == 0 { + fmt.Printf("No %s found.\n", k.Plural) + return nil + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "scope", Title: "Scope", Alignment: table.Left}, + table.Column{Name: "team", Title: "Team", Alignment: table.Left}, + table.Column{Name: k.Column, Title: k.ColumnTitle, Alignment: table.Left}, + ) + for _, i := range result.Content { + t.AddRow(table.Default, table.Cell("id", i["id"]), table.Cell("name", i["name"]), table.Cell("scope", i["scope"]), + table.Cell("team", i["team"]), table.Cell(k.Column, i[k.Column])) + } + t.Print() + return nil +} + +type GetOptions struct { + ID, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, k Kind, o GetOptions) error { + id, err := k.uuid(o.ID) + if err != nil { + return err + } + doc, _, err := platform.ReadDocument(k.get(ctx, c, id)) + if err != nil { + return k.notFoundOr(err, o.ID, "Failed to get %s %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("%s %s written to %s.\n", k.Title, o.ID, o.File) + } + return nil +} + +type ApplyOptions struct { + Files []string + Recursive bool +} + +func Apply(ctx context.Context, c *platform.Client, k Kind, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, k.Name+" integration", func(file string, doc *output.Document) (resource.Applied, error) { + name, _ := doc.Get("name") + if name == "" { + return resource.Applied{}, fmt.Errorf("%s file '%s' does not name the integration.", k.Title, file) + } + // The platform masks secrets when reading them back and rejects the mask, while + // leaving the secret out removes it. Neither is what a file from `get` means. + if secret, _ := doc.Get("secret"); secret != "" && strings.Trim(secret, "*") == "" { + return resource.Applied{}, fmt.Errorf("%s file '%s' holds the masked secret `get` writes. Put the secret in, or remove it for none.", k.Title, file) + } + var saved struct{ ID, Name string } + resp, err := k.upsert(ctx, c, resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save %s %s", k.Title, name) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("%s %s (%s) %s.\n", k.Title, saved.Name, saved.ID, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteOptions struct { + ID string + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, k Kind, o DeleteOptions) error { + id, err := k.uuid(o.ID) + if err != nil { + return err + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Delete %s %s?", k.Title, o.ID)); !ok || err != nil { + return err + } + if _, _, err := platform.Read(k.delete(ctx, c, id)); err != nil { + return k.notFoundOr(err, o.ID, "Failed to delete %s %s") + } + fmt.Printf("%s %s deleted.\n", k.Title, o.ID) + return nil +} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go new file mode 100644 index 0000000..25255d6 --- /dev/null +++ b/internal/integration/integration_test.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package integration_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/integration" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "0190d7b2-7d3e-7a4b-8c5d-6e7f8a9b0c1d" + +func TestEveryKindListsFromItsOwnEndpoint(t *testing.T) { + paths := map[string]string{ + "webhook": "/api/integrations/webhook", "slack": "/api/integrations/slack", + "preflight": "/api/integrations/preflight", "preflight-action": "/api/integrations/preflight-action", + } + for _, k := range integration.Kinds { + t.Run(k.Name, func(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET "+paths[k.Name], platformtest.Reply{JSON: map[string]any{"content": []any{ + map[string]any{"id": id, "name": "Notify", "scope": "TEAM", "team": "ADM", k.Column: "where"}, + }}}) + + out, err := platformtest.Stdout(t, func() error { return integration.List(ctx, p.Client, k, "") }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ Notify │ TEAM │ ADM │ where") + }) + } +} + +func TestListSaysWhenThereIsNone(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/integrations/slack", platformtest.Reply{JSON: map[string]any{"content": []any{}}}) + + out, err := platformtest.Stdout(t, func() error { return integration.List(ctx, p.Client, integration.Slack, "") }) + + require.NoError(t, err) + assert.Equal(t, "No Slack integrations found.\n", out) +} + +func TestGetAndApplyRoundTrip(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/integrations/preflight-action/"+id, platformtest.Reply{Body: `{"id":"` + id + `","version":3,"scope":"GLOBAL","name":"Gate","preflightActionId":"com.example.gate"}`}) + p.Reply("POST /api/integrations/preflight-action", platformtest.Reply{JSON: map[string]any{"id": id, "name": "Gate"}}) + file := filepath.Join(t.TempDir(), "gate.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := integration.Get(ctx, p.Client, integration.PreflightAction, integration.GetOptions{ID: id, File: file}); err != nil { + return err + } + return integration.Apply(ctx, p.Client, integration.PreflightAction, integration.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Equal(t, "Preflight action integration "+id+" written to "+file+".\nPreflight action integration Gate ("+id+") updated.\n", out) + assert.Equal(t, map[string]any{"id": id, "scope": "GLOBAL", "name": "Gate", "preflightActionId": "com.example.gate"}, + p.Requests("POST /api/integrations/preflight-action")[0].JSON(t)) +} + +func TestApplyCreatesAndRefusesAMaskedSecret(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/integrations/webhook", platformtest.Reply{Status: http.StatusCreated, JSON: map[string]any{"id": id, "name": "Notify"}}) + dir := t.TempDir() + file := filepath.Join(dir, "webhook.yml") + require.NoError(t, os.WriteFile(file, []byte("name: Notify\nscope: GLOBAL\nurl: https://example.com\nsecret: s3cret\n"), 0o644)) + masked := filepath.Join(dir, "masked.yml") + require.NoError(t, os.WriteFile(masked, []byte("id: "+id+"\nname: Notify\nsecret: '******'\n"), 0o644)) + + out, err := platformtest.Stdout(t, func() error { + return integration.Apply(ctx, p.Client, integration.Webhook, integration.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Equal(t, "Webhook integration Notify ("+id+") created.\n", out) + content, _ := os.ReadFile(file) + assert.Equal(t, "id: "+id+"\nname: Notify\nscope: GLOBAL\nurl: https://example.com\nsecret: s3cret\n", string(content)) + err = integration.Apply(ctx, p.Client, integration.Webhook, integration.ApplyOptions{Files: []string{masked}}) + assert.EqualError(t, err, "Webhook integration file '"+masked+"' holds the masked secret `get` writes. Put the secret in, or remove it for none.") + assert.Len(t, p.Requests("POST /api/integrations/webhook"), 1) +} + +func TestDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/integrations/slack/"+id, platformtest.Reply{JSON: map[string]any{"id": id}}) + p.Reply("DELETE /api/integrations/preflight/"+id, platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + return integration.Delete(ctx, p.Client, integration.Slack, integration.DeleteOptions{ID: id, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Slack integration "+id+" deleted.\n", out) + assert.EqualError(t, integration.Delete(ctx, p.Client, integration.Preflight, integration.DeleteOptions{ID: id, Yes: true}), "Preflight webhook "+id+" not found.") + assert.EqualError(t, integration.Delete(ctx, p.Client, integration.Preflight, integration.DeleteOptions{ID: "x", Yes: true}), "Preflight webhook x not found.") +} 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/killswitch/killswitch.go b/internal/killswitch/killswitch.go new file mode 100644 index 0000000..4ed5bd4 --- /dev/null +++ b/internal/killswitch/killswitch.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package killswitch implements the `killswitch` commands. +package killswitch + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +type StatusOptions struct { + Type string +} + +func Status(ctx context.Context, c *platform.Client, o StatusOptions) error { + body, _, err := platform.Read(c.GetKillswitch(ctx)) + if err != nil { + return platform.Failed(err, "Failed to get the kill switch status") + } + if o.Type != "" { + doc, err := output.ParseDocument(body) + if err != nil { + return err + } + return resource.Output(doc, "", o.Type) + } + var status struct { + Active bool `json:"active"` + EngagedBy string `json:"engagedBy"` + Engaged string `json:"engaged"` + EngagedByDetails *struct { + Name string `json:"name"` + } `json:"engagedByDetails"` + } + if err := json.Unmarshal(body, &status); err != nil { + return err + } + if !status.Active { + fmt.Println("The kill switch is inactive: experiments can run.") + return nil + } + by := status.EngagedBy + if status.EngagedByDetails != nil && status.EngagedByDetails.Name != "" { + by = status.EngagedByDetails.Name + } + fmt.Printf("The kill switch is active since %s, activated by %s: no experiment can run.\n", status.Engaged, by) + return nil +} + +// Activate stops every experiment running in the tenant, of every team, and keeps new +// ones from starting until the kill switch is deactivated. +func Activate(ctx context.Context, c *platform.Client, yes bool) error { + if ok, err := resource.Confirmed(yes, "Activate the kill switch? It stops every running experiment of every team, and no experiment can run until it is deactivated."); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.EngageKillswitch(ctx)); err != nil { + return platform.Failed(err, "Failed to activate the kill switch") + } + fmt.Println("Kill switch activated. Running experiments are stopped, and none can run until `steadybit killswitch deactivate`.") + return nil +} + +func Deactivate(ctx context.Context, c *platform.Client) error { + if _, _, err := platform.Read(c.DisengageKillswitch(ctx)); err != nil { + return platform.Failed(err, "Failed to deactivate the kill switch") + } + fmt.Println("Kill switch deactivated. Experiments can run again; those it stopped are not restarted.") + return nil +} diff --git a/internal/killswitch/killswitch_test.go b/internal/killswitch/killswitch_test.go new file mode 100644 index 0000000..9e13f5d --- /dev/null +++ b/internal/killswitch/killswitch_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package killswitch_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/killswitch" + "github.com/steadybit/cli/internal/platformtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func TestStatus(t *testing.T) { + p := platformtest.New(t) + active := false + p.Handle("GET /api/killswitch", func(platformtest.Request) platformtest.Reply { + if !active { + return platformtest.Reply{Body: `{"active":false}`} + } + return platformtest.Reply{Body: `{"active":true,"engagedBy":"u-1","engagedByDetails":{"username":"u-1","name":"Jane Doe"},"engaged":"2026-09-01T10:00:00Z"}`} + }) + + out, err := platformtest.Stdout(t, func() error { + if err := killswitch.Status(ctx, p.Client, killswitch.StatusOptions{}); err != nil { + return err + } + active = true + if err := killswitch.Status(ctx, p.Client, killswitch.StatusOptions{}); err != nil { + return err + } + return killswitch.Status(ctx, p.Client, killswitch.StatusOptions{Type: "yaml"}) + }) + + require.NoError(t, err) + assert.Equal(t, "The kill switch is inactive: experiments can run.\n"+ + "The kill switch is active since 2026-09-01T10:00:00Z, activated by Jane Doe: no experiment can run.\n"+ + "active: true\nengagedBy: u-1\nengagedByDetails:\n username: u-1\n name: Jane Doe\nengaged: '2026-09-01T10:00:00Z'\n\n", out) +} + +func TestActivateAndDeactivate(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/killswitch", platformtest.Reply{}) + p.Reply("DELETE /api/killswitch", platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + if err := killswitch.Activate(ctx, p.Client, true); err != nil { + return err + } + return killswitch.Deactivate(ctx, p.Client) + }) + + require.NoError(t, err) + assert.Equal(t, "Kill switch activated. Running experiments are stopped, and none can run until `steadybit killswitch deactivate`.\n"+ + "Kill switch deactivated. Experiments can run again; those it stopped are not restarted.\n", out) + assert.Len(t, p.Requests("POST /api/killswitch"), 1) + assert.Len(t, p.Requests("DELETE /api/killswitch"), 1) +} + +func TestActivateReportsAFailure(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/killswitch", platformtest.Reply{Status: http.StatusForbidden}) + + err := killswitch.Activate(ctx, p.Client, true) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Failed to activate the kill switch: ") +} 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/property/property.go b/internal/property/property.go new file mode 100644 index 0000000..7ec0877 --- /dev/null +++ b/internal/property/property.go @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package property implements the `property` commands: the definitions of the properties +// experiments and services carry, and their associations, which say who carries them. +package property + +import ( + "context" + "fmt" + "net/http" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// The version is dropped as `service get` drops it: kept in a file, it turns every apply +// after an edit in the UI into a conflict. +var readOnly = []string{"version"} + +func definitionNotFoundOr(err error, key, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Property definition %s not found.", key) + } + return platform.Failed(err, format, key) +} + +func ListDefinitions(ctx context.Context, c *platform.Client, explicitType string) error { + type definition struct { + Key, Label, DataType string + EnumValues []string `json:"enumValues"` + } + raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) { + return c.GetPropertyDefinitions(ctx, &api.GetPropertyDefinitionsParams{Page: api.PageRequestAO{Page: &page, Size: &size}}) + }) + if err != nil { + return platform.Failed(err, "Failed to get the property definitions") + } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } + var definitions []definition + if err := resource.DecodeEach(raw, &definitions); err != nil { + return err + } + if len(definitions) == 0 { + fmt.Println("No property definitions found.") + return nil + } + t := table.New( + table.Column{Name: "key", Title: "Key", Alignment: table.Left}, + table.Column{Name: "label", Title: "Label", Alignment: table.Left}, + table.Column{Name: "dataType", Title: "Type", Alignment: table.Left}, + table.Column{Name: "enumValues", Title: "Values", Alignment: table.Left}, + ) + for _, d := range definitions { + t.AddRow(table.Default, table.Cell("key", d.Key), table.Cell("label", d.Label), table.Cell("dataType", d.DataType), + table.Cell("enumValues", values(d.EnumValues))) + } + t.Print() + return nil +} + +// values shows the first few enum values; `get` has them all. +func values(enum []string) string { + const shown = 5 + if len(enum) > shown { + return strings.Join(enum[:shown], ", ") + fmt.Sprintf(" and %d more", len(enum)-shown) + } + return strings.Join(enum, ", ") +} + +type GetDefinitionOptions struct { + Key, File, Type string +} + +func GetDefinition(ctx context.Context, c *platform.Client, o GetDefinitionOptions) error { + doc, _, err := platform.ReadDocument(c.GetPropertyDefinition(ctx, o.Key)) + if err != nil { + return definitionNotFoundOr(err, o.Key, "Failed to get property definition %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Property definition %s written to %s.\n", o.Key, o.File) + } + return nil +} + +type ApplyDefinitionOptions struct { + Files []string + Recursive bool + DeleteValues bool +} + +// ApplyDefinitions upserts definitions by their key; there is no id to write back. +func ApplyDefinitions(ctx context.Context, c *platform.Client, o ApplyDefinitionOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "property definition", func(file string, doc *output.Document) (resource.Applied, error) { + key, _ := doc.Get("key") + if key == "" { + return resource.Applied{}, fmt.Errorf("Property definition file '%s' does not name the key.", file) + } + resp, err := c.UpsertPropertyDefinitionWithBody(ctx, &api.UpsertPropertyDefinitionParams{DeleteValues: &o.DeleteValues}, "application/json", + resource.Body(resource.Strip(doc, readOnly...).Value())) + _, resp, err = platform.Read(resp, err) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save property definition %s", key) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Property definition %s %s.\n", key, resource.CreatedOrUpdated(created)) + return resource.Applied{Created: created}, nil + }) +} + +type DeleteDefinitionOptions struct { + Key string + Associations bool + Yes bool +} + +func DeleteDefinition(ctx context.Context, c *platform.Client, o DeleteDefinitionOptions) error { + question := fmt.Sprintf("Delete property definition %s?", o.Key) + if o.Associations { + question = fmt.Sprintf("Delete property definition %s and its associations?", o.Key) + } + if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { + return err + } + params := &api.DeletePropertyDefinitionParams{DeleteAssociations: &o.Associations} + if _, _, err := platform.Read(c.DeletePropertyDefinition(ctx, o.Key, params)); err != nil { + return definitionNotFoundOr(err, o.Key, "Failed to delete property definition %s") + } + fmt.Printf("Property definition %s deleted.\n", o.Key) + return nil +} + +func associationUUID(id string) (openapi_types.UUID, error) { + u, ok := resource.UUID(id) + if !ok { + return u, fmt.Errorf("Property association %s not found.", id) + } + return u, nil +} + +func associationNotFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Property association %s not found.", id) + } + return platform.Failed(err, format, id) +} + +type ListAssociationOptions struct { + Key, Experiment, Service, Type string + // Output is -t for the other listings; --type already names the association type here. + Output string +} + +func ListAssociations(ctx context.Context, c *platform.Client, o ListAssociationOptions) error { + params := api.GetAssociationsParams{} + if o.Key != "" { + params.Key = &o.Key + } + if o.Experiment != "" { + params.ExperimentKey = &o.Experiment + } + if o.Service != "" { + service, ok := resource.UUID(o.Service) + if !ok { + return fmt.Errorf("Service %s not found.", o.Service) + } + params.ServiceId = &service + } + if o.Type != "" { + kind := api.GetAssociationsParamsAssociationTypeAO(strings.ToUpper(o.Type)) + if !kind.Valid() { + return fmt.Errorf("--type must be EXPERIMENT or SERVICE, not '%s'.", o.Type) + } + params.AssociationTypeAO = &kind + } + type association struct { + ID, Key, AssociationType string + ExperimentKey, ServiceID *string + Required, EditableInExecution *bool + } + // The page size is the platform's; it takes only the page number. + raw, err := platform.AllPagesRaw(func(page, _ int32) (*http.Response, error) { + params.Page = &page + return c.GetAssociations(ctx, ¶ms) + }) + if err != nil { + return platform.Failed(err, "Failed to get the property associations") + } + if resource.Machine(o.Output) { + return resource.List(raw, o.Output, nil) + } + var associations []association + if err := resource.DecodeEach(raw, &associations); err != nil { + return err + } + if len(associations) == 0 { + fmt.Println("No property associations found.") + return nil + } + t := table.New( + table.Column{Name: "id", Title: "Id", Alignment: table.Left}, + table.Column{Name: "key", Title: "Key", Alignment: table.Left}, + table.Column{Name: "type", Title: "Type", Alignment: table.Left}, + table.Column{Name: "for", Title: "For", Alignment: table.Left}, + table.Column{Name: "required", Title: "Required", Alignment: table.Left}, + table.Column{Name: "editable", Title: "Editable in runs", Alignment: table.Left}, + ) + boolOr := func(b *bool) string { return fmt.Sprint(b != nil && *b) } + for _, a := range associations { + // Without an experiment or service, an association is for all of them. + target := "all" + switch { + case a.ExperimentKey != nil: + target = *a.ExperimentKey + case a.ServiceID != nil: + target = *a.ServiceID + } + t.AddRow(table.Default, table.Cell("id", a.ID), table.Cell("key", a.Key), table.Cell("type", a.AssociationType), table.Cell("for", target), + table.Cell("required", boolOr(a.Required)), table.Cell("editable", boolOr(a.EditableInExecution))) + } + t.Print() + return nil +} + +type GetAssociationOptions struct { + ID, File, Type string +} + +func GetAssociation(ctx context.Context, c *platform.Client, o GetAssociationOptions) error { + id, err := associationUUID(o.ID) + if err != nil { + return err + } + doc, _, err := platform.ReadDocument(c.GetPropertyDefinition1(ctx, id)) + if err != nil { + return associationNotFoundOr(err, o.ID, "Failed to get property association %s") + } + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Property association %s written to %s.\n", o.ID, o.File) + } + return nil +} + +type ApplyAssociationOptions struct { + Files []string + Recursive bool +} + +func ApplyAssociations(ctx context.Context, c *platform.Client, o ApplyAssociationOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "property association", func(file string, doc *output.Document) (resource.Applied, error) { + key, _ := doc.Get("key") + if key == "" { + return resource.Applied{}, fmt.Errorf("Property association file '%s' does not name the property key.", file) + } + var saved struct{ ID, Key string } + resp, err := c.UpsertPropertyAssociationWithBody(ctx, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save the property association of %s", key) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Property association %s of %s %s.\n", saved.ID, saved.Key, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteAssociationOptions struct { + ID string + DeleteValues bool + Yes bool +} + +func DeleteAssociation(ctx context.Context, c *platform.Client, o DeleteAssociationOptions) error { + id, err := associationUUID(o.ID) + if err != nil { + return err + } + question := fmt.Sprintf("Delete property association %s?", o.ID) + if o.DeleteValues { + question = fmt.Sprintf("Delete property association %s and the values experiments and schedules have for it?", o.ID) + } + if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeletePropertyAssociation(ctx, id, &api.DeletePropertyAssociationParams{DeleteValues: &o.DeleteValues})); err != nil { + return associationNotFoundOr(err, o.ID, "Failed to delete property association %s") + } + fmt.Printf("Property association %s deleted.\n", o.ID) + return nil +} diff --git a/internal/property/property_test.go b/internal/property/property_test.go new file mode 100644 index 0000000..5c55e32 --- /dev/null +++ b/internal/property/property_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package property_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/property" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const id = "0190d7b2-9e8f-7c6d-b5a4-3f2e1d0c9b8a" + +func TestListDefinitionsWalksEveryPage(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/properties/definitions", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"key": "COLOR", "label": "Color", "dataType": "ENUM", + "enumValues": []any{"a", "b", "c", "d", "e", "f", "g"}}}, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{map[string]any{"key": "OWNER", "label": "Owner", "dataType": "STRING"}}}} + }) + + out, err := platformtest.Stdout(t, func() error { return property.ListDefinitions(ctx, p.Client, "") }) + + require.NoError(t, err) + assert.Contains(t, out, "│ COLOR │ Color │ ENUM │ a, b, c, d, e and 2 more │") + assert.Contains(t, out, "│ OWNER │ Owner │ STRING │ │") + assert.Equal(t, []string{"100"}, p.Requests("GET /api/properties/definitions")[0].Query["size"]) +} + +func TestDefinitionRoundTripByKey(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/properties/definitions/COLOR", platformtest.Reply{Body: `{"key":"COLOR","label":"Color","dataType":"ENUM","enumValues":["red"],"version":3}`}) + p.Reply("POST /api/properties/definitions", platformtest.Reply{Body: `{"key":"COLOR"}`}) + file := filepath.Join(t.TempDir(), "color.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := property.GetDefinition(ctx, p.Client, property.GetDefinitionOptions{Key: "COLOR", File: file}); err != nil { + return err + } + return property.ApplyDefinitions(ctx, p.Client, property.ApplyDefinitionOptions{Files: []string{file}, DeleteValues: true}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Property definition COLOR updated.") + content, _ := os.ReadFile(file) + assert.Equal(t, "key: COLOR\nlabel: Color\ndataType: ENUM\nenumValues:\n - red\n", string(content)) + sent := p.Requests("POST /api/properties/definitions")[0] + assert.Equal(t, []string{"true"}, sent.Query["deleteValues"]) + assert.Equal(t, map[string]any{"key": "COLOR", "label": "Color", "dataType": "ENUM", "enumValues": []any{"red"}}, sent.JSON(t)) +} + +func TestDeleteDefinition(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/properties/definitions/COLOR", platformtest.Reply{}) + p.Reply("DELETE /api/properties/definitions/NOPE", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + return property.DeleteDefinition(ctx, p.Client, property.DeleteDefinitionOptions{Key: "COLOR", Associations: true, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Property definition COLOR deleted.\n", out) + assert.Equal(t, []string{"true"}, p.Requests("DELETE /api/properties/definitions/COLOR")[0].Query["deleteAssociations"]) + assert.EqualError(t, property.DeleteDefinition(ctx, p.Client, property.DeleteDefinitionOptions{Key: "NOPE", Yes: true}), "Property definition NOPE not found.") +} + +func TestListAssociationsWithFilters(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/properties/associations", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"items": []any{ + map[string]any{"id": id, "key": "COLOR", "associationType": "EXPERIMENT", "required": true}, + }, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{ + map[string]any{"id": "b", "key": "COLOR", "associationType": "EXPERIMENT", "experimentKey": "ADM-1", "editableInExecution": true}, + }}} + }) + + out, err := platformtest.Stdout(t, func() error { + return property.ListAssociations(ctx, p.Client, property.ListAssociationOptions{Key: "COLOR", Type: "experiment"}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ "+id+" │ COLOR │ EXPERIMENT │ all │ true │ false │") + assert.Contains(t, out, "│ ADM-1 │ false │ true │") + q := p.Requests("GET /api/properties/associations")[0].Query + assert.Equal(t, []string{"COLOR"}, q["key"]) + assert.Equal(t, []string{"EXPERIMENT"}, q["associationTypeAO"]) + assert.NotContains(t, q, "size") + assert.EqualError(t, property.ListAssociations(ctx, p.Client, property.ListAssociationOptions{Type: "team"}), "--type must be EXPERIMENT or SERVICE, not 'team'.") +} + +func TestAssociationGetApplyDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/properties/associations/"+id, platformtest.Reply{Body: `{"key":"COLOR","associationType":"EXPERIMENT","id":"` + id + `","version":1}`}) + p.Reply("POST /api/properties/associations", platformtest.Reply{Status: http.StatusCreated, JSON: map[string]any{"id": id, "key": "COLOR"}}) + p.Reply("DELETE /api/properties/associations/"+id, platformtest.Reply{}) + dir := t.TempDir() + got := filepath.Join(dir, "got.yml") + created := filepath.Join(dir, "new.yml") + require.NoError(t, os.WriteFile(created, []byte("key: COLOR\nassociationType: SERVICE\n"), 0o644)) + + out, err := platformtest.Stdout(t, func() error { + if err := property.GetAssociation(ctx, p.Client, property.GetAssociationOptions{ID: id, File: got}); err != nil { + return err + } + if err := property.ApplyAssociations(ctx, p.Client, property.ApplyAssociationOptions{Files: []string{created}}); err != nil { + return err + } + return property.DeleteAssociation(ctx, p.Client, property.DeleteAssociationOptions{ID: id, Yes: true}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Property association "+id+" of COLOR created.\nProperty association "+id+" deleted.\n") + content, _ := os.ReadFile(got) + assert.Equal(t, "key: COLOR\nassociationType: EXPERIMENT\nid: "+id+"\n", string(content)) + content, _ = os.ReadFile(created) + assert.Equal(t, "id: "+id+"\nkey: COLOR\nassociationType: SERVICE\n", string(content)) + assert.Equal(t, []string{"false"}, p.Requests("DELETE /api/properties/associations/" + id)[0].Query["deleteValues"]) + assert.EqualError(t, property.GetAssociation(ctx, p.Client, property.GetAssociationOptions{ID: "x"}), "Property association x not found.") +} diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..5f9c082 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package report implements the `report` commands: time series over the tenant, printed as +// the platform sends them. +package report + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" +) + +// Filter says which filters a report takes beyond the time range. +type Filter int + +const ( + Teams Filter = 1 << iota + Environments + Services + ServiceProperties + Categories +) + +type Kind struct { + Name, Short string + Filters Filter + // The groupings the report offers; none means it cannot be grouped. + GroupBy []string + send func(ctx context.Context, c *platform.Client, groupBy string, body io.Reader) (*http.Response, error) +} + +func (k Kind) Takes(f Filter) bool { return k.Filters&f != 0 } + +const contentType = "application/json" + +var serviceFilters = Teams | Environments | Services | ServiceProperties | Categories + +var Kinds = []Kind{ + {Name: "users", Short: "Users in the tenant over time.", send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetUserCountsWithBody(ctx, contentType, body) + }}, + {Name: "teams", Short: "Teams in the tenant over time.", send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetTeamCountsWithBody(ctx, contentType, body) + }}, + {Name: "environments", Short: "Environments in the tenant over time.", send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetEnvironmentCountsWithBody(ctx, contentType, body) + }}, + { + Name: "experiments-executed", Short: "Experiment runs over time.", Filters: Teams | Environments | Services, + GroupBy: []string{"NONE", "STATE", "TRIGGER", "ACTION", "ISSUES_FIXED", "ISSUES_DISCOVERED"}, + send: func(ctx context.Context, c *platform.Client, groupBy string, body io.Reader) (*http.Response, error) { + params := &api.GetExperimentExecutionsParams{} + if groupBy != "" { + g := api.GetExperimentExecutionsParamsGroupBy(groupBy) + params.GroupBy = &g + } + return c.GetExperimentExecutionsWithBody(ctx, params, contentType, body) + }, + }, + { + Name: "experiments-created", Short: "Experiments created over time.", Filters: Teams | Environments, + GroupBy: []string{"NONE", "CREATED_VIA", "ORIGIN"}, + send: func(ctx context.Context, c *platform.Client, groupBy string, body io.Reader) (*http.Response, error) { + params := &api.GetExperimentCreationsParams{} + if groupBy != "" { + g := api.GetExperimentCreationsParamsGroupBy(groupBy) + params.GroupBy = &g + } + return c.GetExperimentCreationsWithBody(ctx, params, contentType, body) + }, + }, + {Name: "services-distribution", Short: "Services per risk level (low, medium, high) over time.", Filters: serviceFilters, + send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetRiskDistributionWithBody(ctx, contentType, body) + }}, + {Name: "services-by-category", Short: "The average risk of services per category over time.", Filters: serviceFilters, + send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetRiskByCategoryWithBody(ctx, contentType, body) + }}, + {Name: "services-average", Short: "The average risk of services, 0 to 100, over time.", Filters: serviceFilters, + send: func(ctx context.Context, c *platform.Client, _ string, body io.Reader) (*http.Response, error) { + return c.GetAverageRiskWithBody(ctx, contentType, body) + }}, +} + +type Options struct { + From, To, Rollup, GroupBy string + // Teams by key and environments by name, as users know them; the platform takes ids. + Teams, Environments, Services, Categories []string + ServiceProperties []string + File, Type string +} + +// Today is when a report ends by default. A variable, so that tests can fix it. +var Today = func() time.Time { return time.Now().UTC() } + +func date(flag, value string) (string, error) { + if _, err := time.Parse(time.DateOnly, value); err != nil { + return "", fmt.Errorf("--%s '%s' is not a date like 2026-09-01.", flag, value) + } + return value, nil +} + +func Run(ctx context.Context, c *platform.Client, k Kind, o Options) error { + body := jsyaml.NewMap() + to := Today().Format(time.DateOnly) + if o.To != "" { + var err error + if to, err = date("to", o.To); err != nil { + return err + } + } + // 30 days back, unless told otherwise. + end, _ := time.Parse(time.DateOnly, to) + from := end.AddDate(0, 0, -30).Format(time.DateOnly) + if o.From != "" { + var err error + if from, err = date("from", o.From); err != nil { + return err + } + } + body.Set("from", from) + body.Set("to", to) + if o.Rollup != "" { + rollup := strings.ToUpper(o.Rollup) + if rollup != "DAILY" && rollup != "MONTHLY" { + return fmt.Errorf("--rollup must be DAILY or MONTHLY, not '%s'.", o.Rollup) + } + body.Set("rollup", rollup) + } + groupBy := strings.ToUpper(o.GroupBy) + if groupBy != "" && !contains(k.GroupBy, groupBy) { + return fmt.Errorf("--group-by must be one of %s, not '%s'.", strings.Join(k.GroupBy, ", "), o.GroupBy) + } + + if len(o.Teams) > 0 { + ids, err := teamIDs(ctx, c, o.Teams) + if err != nil { + return err + } + body.Set("teamIds", ids) + } + if len(o.Environments) > 0 { + ids, err := environmentIDs(ctx, c, o.Environments) + if err != nil { + return err + } + body.Set("environmentIds", ids) + } + if len(o.Services) > 0 { + body.Set("serviceIds", list(o.Services)) + } + if len(o.ServiceProperties) > 0 { + properties := jsyaml.NewMap() + for _, pair := range o.ServiceProperties { + key, value, ok := strings.Cut(pair, "=") + if !ok || key == "" { + return fmt.Errorf("'%s' is not in the form KEY=VALUE.", pair) + } + values, _ := properties.Get(key) + existing, _ := values.([]any) + properties.Set(key, append(existing, value)) + } + body.Set("serviceProperties", properties) + } + if len(o.Categories) > 0 { + body.Set("categoryKeys", list(o.Categories)) + } + + doc, _, err := platform.ReadDocument(k.send(ctx, c, groupBy, resource.Body(body))) + if err != nil { + return platform.Failed(err, "Failed to get the %s report", k.Name) + } + if err := resource.Output(doc, o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Report %s written to %s.\n", k.Name, o.File) + } + return nil +} + +func contains(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + +func list(values []string) []any { + out := make([]any, len(values)) + for i, v := range values { + out[i] = v + } + return out +} + +func teamIDs(ctx context.Context, c *platform.Client, keys []string) ([]any, error) { + ids := make([]any, len(keys)) + for i, key := range keys { + var team struct{ ID string } + resp, err := c.GetTeam(ctx, key) + if _, err := platform.Decode(resp, err, &team); err != nil { + if platform.IsStatus(err, http.StatusNotFound) { + return nil, fmt.Errorf("Team %s not found.", key) + } + return nil, platform.Failed(err, "Failed to get team %s", key) + } + ids[i] = team.ID + } + return ids, nil +} + +func environmentIDs(ctx context.Context, c *platform.Client, names []string) ([]any, error) { + var summaries struct { + Environments []struct{ ID, Name string } `json:"environments"` + } + resp, err := c.GetEnvironments(ctx, &api.GetEnvironmentsParams{}) + if _, err := platform.Decode(resp, err, &summaries); err != nil { + return nil, platform.Failed(err, "Failed to get the environments") + } + ids := make([]any, len(names)) + for i, name := range names { + for _, e := range summaries.Environments { + if e.Name == name { + ids[i] = e.ID + } + } + if ids[i] == nil { + return nil, fmt.Errorf("Environment %s not found.", name) + } + } + return ids, nil +} diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..0cc7559 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package report_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/report" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func kind(t *testing.T, name string) report.Kind { + for _, k := range report.Kinds { + if k.Name == name { + return k + } + } + t.Fatalf("no report %s", name) + return report.Kind{} +} + +const series = `{"from":"2026-08-01","to":"2026-09-01","rollup":"MONTHLY","groupBy":"NONE","series":[{"name":"users","values":[["2026-08-01",16]]}]}` + +func TestEveryReportPostsToItsEndpoint(t *testing.T) { + paths := map[string]string{ + "users": "/api/reports/users", "teams": "/api/reports/teams", "environments": "/api/reports/environments", + "experiments-executed": "/api/reports/experiments/executed", "experiments-created": "/api/reports/experiments/created", + "services-distribution": "/api/reports/services/distribution", "services-by-category": "/api/reports/services/by-category", + "services-average": "/api/reports/services/average", + } + require.Len(t, report.Kinds, len(paths)) + for _, k := range report.Kinds { + t.Run(k.Name, func(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST "+paths[k.Name], platformtest.Reply{Body: series}) + + out, err := platformtest.Stdout(t, func() error { + return report.Run(ctx, p.Client, k, report.Options{From: "2026-08-01", To: "2026-09-01"}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "series:\n - name: users\n") + assert.Equal(t, map[string]any{"from": "2026-08-01", "to": "2026-09-01"}, p.Requests("POST " + paths[k.Name])[0].JSON(t)) + }) + } +} + +func TestDefaultsToTheLast30Days(t *testing.T) { + report.Today = func() time.Time { return time.Date(2026, 3, 31, 15, 0, 0, 0, time.UTC) } + t.Cleanup(func() { report.Today = func() time.Time { return time.Now().UTC() } }) + p := platformtest.New(t) + p.Reply("POST /api/reports/users", platformtest.Reply{Body: series}) + + _, err := platformtest.Stdout(t, func() error { return report.Run(ctx, p.Client, kind(t, "users"), report.Options{Rollup: "daily"}) }) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"from": "2026-03-01", "to": "2026-03-31", "rollup": "DAILY"}, p.Requests("POST /api/reports/users")[0].JSON(t)) +} + +func TestFiltersAreSentAsIds(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams/ADM", platformtest.Reply{JSON: map[string]any{"id": "team-1", "key": "ADM"}}) + p.Reply("GET /api/environments", platformtest.Reply{JSON: map[string]any{"environments": []any{map[string]any{"id": "env-1", "name": "Global"}, map[string]any{"id": "env-2", "name": "Global 2"}}}}) + p.Reply("POST /api/reports/services/average", platformtest.Reply{Body: series}) + + _, err := platformtest.Stdout(t, func() error { + return report.Run(ctx, p.Client, kind(t, "services-average"), report.Options{ + From: "2026-08-01", To: "2026-09-01", Teams: []string{"ADM"}, Environments: []string{"Global"}, Services: []string{"svc-1"}, + ServiceProperties: []string{"tier=gold", "tier=silver", "region=eu"}, Categories: []string{"Redundancy"}, Type: "json", + }) + }) + + require.NoError(t, err) + assert.Equal(t, map[string]any{ + "from": "2026-08-01", "to": "2026-09-01", "teamIds": []any{"team-1"}, "environmentIds": []any{"env-1"}, "serviceIds": []any{"svc-1"}, + "serviceProperties": map[string]any{"tier": []any{"gold", "silver"}, "region": []any{"eu"}}, "categoryKeys": []any{"Redundancy"}, + }, p.Requests("POST /api/reports/services/average")[0].JSON(t)) +} + +func TestGroupBy(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/reports/experiments/executed", platformtest.Reply{Body: series}) + + _, err := platformtest.Stdout(t, func() error { + return report.Run(ctx, p.Client, kind(t, "experiments-executed"), report.Options{From: "2026-08-01", To: "2026-09-01", GroupBy: "state"}) + }) + + require.NoError(t, err) + assert.Equal(t, []string{"STATE"}, p.Requests("POST /api/reports/experiments/executed")[0].Query["groupBy"]) + err = report.Run(ctx, p.Client, kind(t, "experiments-created"), report.Options{GroupBy: "state"}) + assert.EqualError(t, err, "--group-by must be one of NONE, CREATED_VIA, ORIGIN, not 'state'.") +} + +func TestRefusals(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams/NOPE", platformtest.Reply{Status: http.StatusNotFound}) + p.Reply("GET /api/environments", platformtest.Reply{JSON: map[string]any{"environments": []any{}}}) + users, executed := kind(t, "users"), kind(t, "experiments-executed") + + assert.EqualError(t, report.Run(ctx, p.Client, users, report.Options{From: "yesterday"}), "--from 'yesterday' is not a date like 2026-09-01.") + assert.EqualError(t, report.Run(ctx, p.Client, users, report.Options{Rollup: "weekly"}), "--rollup must be DAILY or MONTHLY, not 'weekly'.") + assert.EqualError(t, report.Run(ctx, p.Client, executed, report.Options{Teams: []string{"NOPE"}}), "Team NOPE not found.") + assert.EqualError(t, report.Run(ctx, p.Client, executed, report.Options{Environments: []string{"Nowhere"}}), "Environment Nowhere not found.") + assert.EqualError(t, report.Run(ctx, p.Client, kind(t, "services-average"), report.Options{ServiceProperties: []string{"tier"}}), "'tier' is not in the form KEY=VALUE.") +} diff --git a/internal/resource/list.go b/internal/resource/list.go new file mode 100644 index 0000000..63b9d14 --- /dev/null +++ b/internal/resource/list.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package resource + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" +) + +// ListTypeHelp describes the -t flag of listings. +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 +} + +// DecodeListed reads a listing response once: into typed, for the table, and as the raw +// items under field ("" for a response that is the array itself), for -t and --jq. +func DecodeListed(resp *http.Response, err error, field string, typed any) ([]json.RawMessage, error) { + body, _, err := platform.Read(resp, err) + if err != nil { + return nil, err + } + if err := json.Unmarshal(body, typed); err != nil { + return nil, err + } + var raw []json.RawMessage + if field == "" { + err = json.Unmarshal(body, &raw) + } else { + var wrapper map[string]json.RawMessage + if err = json.Unmarshal(body, &wrapper); err == nil && wrapper[field] != nil { + err = json.Unmarshal(wrapper[field], &raw) + } + } + return raw, err +} diff --git a/internal/resource/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/listed_test.go b/internal/resource/listed_test.go new file mode 100644 index 0000000..4adbb2e --- /dev/null +++ b/internal/resource/listed_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package resource_test + +import ( + "context" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeListedKeepsTheRawItemsBesideTheTypedOnes(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams", platformtest.Reply{Body: `{"teams":[{"key":"ADM","name":"Admins","extra":{"kept":true}}]}`}) + + var typed struct { + Teams []struct{ Key string } `json:"teams"` + } + resp, err := p.Client.GetTeams(context.Background(), nil) + items, err := resource.DecodeListed(resp, err, "teams", &typed) + + require.NoError(t, err) + assert.Equal(t, "ADM", typed.Teams[0].Key) + assert.JSONEq(t, `{"key":"ADM","name":"Admins","extra":{"kept":true}}`, string(items[0])) +} diff --git a/internal/resource/resource.go b/internal/resource/resource.go index f7183b8..2dd9779 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -7,18 +7,28 @@ package resource import ( + "bytes" + "errors" "fmt" + "io" "os" "strings" + "time" + openapi_types "github.com/oapi-codegen/runtime/types" "github.com/steadybit/cli/internal/experiment" "github.com/steadybit/cli/internal/jsyaml" "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/prompt" ) // Output writes to the file when one is given and to stdout otherwise, as JSON // 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 @@ -103,7 +113,8 @@ func ApplyFiles(paths []string, recursive bool, what string, upsert func(file st if err != nil { return err } - if existingID == nil || existingID == "" { + // Resources named by a key, like teams, have no id to write back. + if (existingID == nil || existingID == "") && result.ID != "" { doc.Value().SetFirst("id", result.ID) if err := os.WriteFile(file, []byte(format(doc, datatype)), 0o644); err != nil { return err @@ -119,3 +130,83 @@ func CreatedOrUpdated(created bool) string { } return "updated" } + +// Body sends a document or value as the JSON the platform expects. +func Body(value any) io.Reader { return bytes.NewReader([]byte(jsyaml.CompactJSON(value))) } + +// Optional leaves an empty filter out of the request. +func Optional(values []string) *[]string { + if len(values) == 0 { + return nil + } + return &values +} + +// UUID parses an id; a malformed one cannot name anything, so callers report it as not found. +func UUID(id string) (openapi_types.UUID, bool) { + var u openapi_types.UUID + return u, u.UnmarshalText([]byte(id)) == nil +} + +// Confirmed asks before something that cannot be undone, unless --yes was given. +// Without a terminal, as in a pipeline, it goes ahead, as `experiment run` does. +func Confirmed(yes bool, question string) (bool, error) { + if yes { + return true, nil + } + ok, err := prompt.Confirm(question, false, true) + if err == nil && !ok { + fmt.Println("Aborted.") + } + return ok, err +} + +// Variables merges KEY=VALUE arguments, always strings, over a file's variables, which +// may be lists or select expressions. Nothing given is only allowed when replacing, where +// it removes every variable. +func Variables(pairs []string, file string, replace bool) (*jsyaml.Map, error) { + given := jsyaml.NewMap() + for _, pair := range pairs { + i := strings.Index(pair, "=") + if i <= 0 { + return nil, fmt.Errorf("'%s' is not in the form KEY=VALUE.", pair) + } + given.Set(pair[:i], pair[i+1:]) + } + variables := jsyaml.NewMap() + if file != "" { + doc, _, err := Read(file, "variables") + if err != nil { + return nil, fmt.Errorf("Variables file '%s' must be a map of variable names to values.", file) + } + variables = doc.Value() + } + for _, k := range given.Keys() { + v, _ := given.Get(k) + variables.Set(k, v) + } + if variables.Len() == 0 && !replace { + return nil, errors.New("No variables given. Pass KEY=VALUE arguments or --file.") + } + return variables, nil +} + +func VariablesOutcome(replace bool) string { + if replace { + return "set, all others removed" + } + return "set" +} + +// Time takes a date, meaning its start in UTC, or a full RFC 3339 time. +func Time(flag, value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + for _, layout := range []string{time.RFC3339, time.DateOnly} { + if t, err := time.Parse(layout, value); err == nil { + return &t, nil + } + } + return nil, fmt.Errorf("--%s '%s' is neither a date like 2026-09-01 nor a time like 2026-09-01T12:00:00Z.", flag, value) +} 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..1150a46 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) @@ -393,37 +409,18 @@ type VariableSetOptions struct { Replace bool } -// SetVariables merges KEY=VALUE arguments, always strings, over a file's variables, -// which may be lists or select expressions. With --replace, the result is all there is. +// SetVariables merges KEY=VALUE arguments over a file's variables. With --replace, the +// result is all there is. func SetVariables(ctx context.Context, c *platform.Client, pairs []string, o VariableSetOptions) error { - given := jsyaml.NewMap() - for _, pair := range pairs { - i := strings.Index(pair, "=") - if i <= 0 { - return fmt.Errorf("'%s' is not in the form KEY=VALUE.", pair) - } - given.Set(pair[:i], pair[i+1:]) - } - variables := jsyaml.NewMap() - if o.File != "" { - doc, _, err := resource.Read(o.File, "variables") - if err != nil { - return fmt.Errorf("Variables file '%s' must be a map of variable names to values.", o.File) - } - variables = doc.Value() - } - for _, k := range given.Keys() { - v, _ := given.Get(k) - variables.Set(k, v) - } - if variables.Len() == 0 && !o.Replace { - return errors.New("No variables given. Pass KEY=VALUE arguments or --file.") + variables, err := resource.Variables(pairs, o.File, o.Replace) + if err != nil { + return err } id, err := uuid(o.ID) if err != nil { return err } - body := bytes.NewReader([]byte(jsyaml.CompactJSON(variables))) + body := resource.Body(variables) if o.Replace { _, _, err = platform.Read(c.SetServiceVariablesWithBody(ctx, id, "application/json", body)) } else { @@ -432,10 +429,6 @@ func SetVariables(ctx context.Context, c *platform.Client, pairs []string, o Var if err != nil { return notFoundOr(err, o.ID, "Failed to update the variables of service %s") } - outcome := "set" - if o.Replace { - outcome = "set, all others removed" - } - fmt.Printf("%d variable(s) of service %s %s.\n", variables.Len(), o.ID, outcome) + fmt.Printf("%d variable(s) of service %s %s.\n", variables.Len(), o.ID, resource.VariablesOutcome(o.Replace)) return nil } 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/target/target.go b/internal/target/target.go new file mode 100644 index 0000000..a3bfa33 --- /dev/null +++ b/internal/target/target.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package target implements the `target` commands. +package target + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +func notFoundOr(err error, environment, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Environment %s not found.", environment) + } + return platform.Failed(err, format, environment) +} + +func optional(s string) *string { + if s == "" { + return nil + } + return &s +} + +// printAs writes values as JSON or YAML when a type is given, and returns false otherwise. +func printAs(values []any, datatype string) (bool, error) { + if !resource.Machine(datatype) { + return false, nil + } + if output.JQ != "" { + return true, resource.PrintJSONValue([]byte(jsyaml.CompactJSON(values)), datatype) + } + resolved, err := output.ResolveDatatype(datatype, "") + if err != nil { + return true, err + } + if resolved == output.JSON { + fmt.Println(jsyaml.JSON(values)) + } else { + fmt.Println(jsyaml.Dump(values)) + } + return true, nil +} + +type QueryOptions struct { + Environment, TargetType, Query string + Attributes []string + // At most this many targets; 0 is all of them. + Limit int + Type string +} + +// The most targets the platform returns at once. +const maxPageSize = 1000 + +func Query(ctx context.Context, c *platform.Client, o QueryOptions) error { + if o.Limit < 0 { + return errors.New("--limit cannot be negative.") + } + params := api.GetTargetsParams{Environment: o.Environment, TargetType: optional(o.TargetType), Query: optional(o.Query)} + if len(o.Attributes) > 0 { + params.Attribute = &o.Attributes + } + targets := []any{} + for { + size := int32(maxPageSize) + if o.Limit > 0 { + size = int32(min(o.Limit-len(targets), maxPageSize)) + } + params.Size = &size + body, _, err := platform.Read(c.GetTargets(ctx, ¶ms)) + if err != nil { + return notFoundOr(err, o.Environment, "Failed to get the targets of environment %s") + } + value, err := output.ParseValue(body) + if err != nil { + return err + } + slice, _ := value.(*jsyaml.Map) + items, _ := slice.Get("items") + list, _ := items.([]any) + targets = append(targets, list...) + hasNext, _ := slice.Get("hasNext") + next, _ := slice.Get("nextCursor") + cursor, _ := next.(string) + if hasNext != true || cursor == "" || len(list) == 0 || (o.Limit > 0 && len(targets) >= o.Limit) { + break + } + params.Cursor = &cursor + } + if printed, err := printAs(targets, o.Type); printed { + return err + } + if len(targets) == 0 { + fmt.Println("No targets found.") + return nil + } + columns := []table.Column{ + {Name: "name", Title: "Name", Alignment: table.Left}, + {Name: "type", Title: "Type", Alignment: table.Left}, + } + // The attributes asked for become columns; they are what the query was about. + for _, a := range o.Attributes { + columns = append(columns, table.Column{Name: "@" + a, Title: a, Alignment: table.Left}) + } + t := table.New(columns...) + for _, item := range targets { + target, _ := item.(*jsyaml.Map) + name, _ := target.Get("name") + kind, _ := target.Get("type") + cells := [][2]string{table.Cell("name", name), table.Cell("type", kind)} + for _, a := range o.Attributes { + cells = append(cells, table.Cell("@"+a, strings.Join(attribute(target, a), ", "))) + } + t.AddRow(table.Default, cells...) + } + t.Print() + return nil +} + +// attribute returns a target's values of a key, which it may have more than once. +func attribute(target *jsyaml.Map, key string) []string { + attributes, _ := target.Get("attributes") + list, _ := attributes.([]any) + var values []string + for _, a := range list { + m, _ := a.(*jsyaml.Map) + if k, _ := m.Get("key"); k == key { + v, _ := m.Get("value") + values = append(values, fmt.Sprint(v)) + } + } + return values +} + +type AttributeOptions struct { + Environment, TargetType, Action string + // The attribute whose values to list; empty lists the keys. + Key string + Type string +} + +func (o AttributeOptions) check() error { + if o.TargetType == "" && o.Action == "" { + return errors.New("Either --target-type or --action must be specified.") + } + return nil +} + +func AttributeKeys(ctx context.Context, c *platform.Client, o AttributeOptions) error { + if err := o.check(); err != nil { + return err + } + keys, err := platform.AllPages[string](func(page, size int32) (*http.Response, error) { + return c.GetTargetAttributeKeys(ctx, &api.GetTargetAttributeKeysParams{ + Environment: o.Environment, TargetType: optional(o.TargetType), ActionId: optional(o.Action), Page: &page, Size: &size, + }) + }) + if err != nil { + return notFoundOr(err, o.Environment, "Failed to get the attribute keys of environment %s") + } + return printStrings(keys, "Key", "No attribute keys found.", o.Type) +} + +func AttributeValues(ctx context.Context, c *platform.Client, o AttributeOptions) error { + if err := o.check(); err != nil { + return err + } + values, err := platform.AllPages[string](func(page, size int32) (*http.Response, error) { + return c.GetTargetAttributeValues(ctx, &api.GetTargetAttributeValuesParams{ + Environment: o.Environment, TargetType: optional(o.TargetType), ActionId: optional(o.Action), AttributeKey: o.Key, Page: &page, Size: &size, + }) + }) + if err != nil { + return notFoundOr(err, o.Environment, "Failed to get the attribute values of environment %s") + } + return printStrings(values, "Value", "No values found for attribute "+o.Key+".", o.Type) +} + +func printStrings(values []string, title, none, datatype string) error { + list := make([]any, len(values)) + for i, v := range values { + list[i] = v + } + if printed, err := printAs(list, datatype); printed { + return err + } + if len(values) == 0 { + fmt.Println(none) + return nil + } + t := table.New(table.Column{Name: "value", Title: title, Alignment: table.Left}) + for _, v := range values { + t.AddRow(table.Default, table.Cell("value", v)) + } + t.Print() + return nil +} diff --git a/internal/target/target_test.go b/internal/target/target_test.go new file mode 100644 index 0000000..1b6885b --- /dev/null +++ b/internal/target/target_test.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package target_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/target" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func container(name string, attributes ...[2]string) map[string]any { + list := []any{} + for _, a := range attributes { + list = append(list, map[string]any{"key": a[0], "value": a[1]}) + } + return map[string]any{"name": name, "type": "container", "attributes": list} +} + +func TestQueryFollowsTheCursorUpToTheLimit(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/targets", func(r platformtest.Request) platformtest.Reply { + if r.Query["cursor"] == nil { + return platformtest.Reply{JSON: map[string]any{"items": []any{ + container("a", [2]string{"k8s.namespace", "shop"}, [2]string{"k8s.pod.name", "a-1"}, [2]string{"k8s.pod.name", "a-2"}), + container("b"), + }, "hasNext": true, "nextCursor": "c-2"}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{container("c", [2]string{"k8s.namespace", "shop"})}, "hasNext": true, "nextCursor": "c-3"}} + }) + + out, err := platformtest.Stdout(t, func() error { + return target.Query(ctx, p.Client, target.QueryOptions{ + Environment: "Global", TargetType: "container", Query: `k8s.namespace="shop"`, Attributes: []string{"k8s.namespace", "k8s.pod.name"}, Limit: 3, + }) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ a │ container │ shop │ a-1, a-2 │") + assert.Contains(t, out, "│ c │ container │ shop │ │") + requests := p.Requests("GET /api/targets") + require.Len(t, requests, 2) + assert.Equal(t, []string{"Global"}, requests[0].Query["environment"]) + assert.Equal(t, []string{"container"}, requests[0].Query["targetType"]) + assert.Equal(t, []string{`k8s.namespace="shop"`}, requests[0].Query["query"]) + assert.Equal(t, []string{"k8s.namespace", "k8s.pod.name"}, requests[0].Query["attribute"]) + assert.Equal(t, []string{"3"}, requests[0].Query["size"]) + assert.Equal(t, []string{"1"}, requests[1].Query["size"]) + assert.Equal(t, []string{"c-2"}, requests[1].Query["cursor"]) +} + +func TestQueryPrintsAsJSON(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/targets", platformtest.Reply{JSON: map[string]any{"items": []any{}, "hasNext": false}}) + + out, err := platformtest.Stdout(t, func() error { + return target.Query(ctx, p.Client, target.QueryOptions{Environment: "Global", Type: "json"}) + }) + + require.NoError(t, err) + assert.Equal(t, "[]\n", out) + assert.Equal(t, []string{"1000"}, p.Requests("GET /api/targets")[0].Query["size"]) + assert.NotContains(t, p.Requests("GET /api/targets")[0].Query, "targetType") +} + +func TestQueryReportsAMissingEnvironment(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/targets", platformtest.Reply{Status: http.StatusNotFound}) + + assert.EqualError(t, target.Query(ctx, p.Client, target.QueryOptions{Environment: "Nowhere"}), "Environment Nowhere not found.") +} + +func TestAttributeKeysAndValues(t *testing.T) { + p := platformtest.New(t) + p.Handle("GET /api/targets/attributes/keys", func(r platformtest.Request) platformtest.Reply { + if r.Query["page"][0] == "0" { + return platformtest.Reply{JSON: map[string]any{"items": []any{"k8s.namespace"}, "nextPage": 1}} + } + return platformtest.Reply{JSON: map[string]any{"items": []any{"k8s.pod.name"}}} + }) + p.Reply("GET /api/targets/attributes/values", platformtest.Reply{JSON: map[string]any{"items": []any{"shop", "kube-system"}}}) + + out, err := platformtest.Stdout(t, func() error { + if err := target.AttributeKeys(ctx, p.Client, target.AttributeOptions{Environment: "Global", TargetType: "container"}); err != nil { + return err + } + return target.AttributeValues(ctx, p.Client, target.AttributeOptions{Environment: "Global", Action: "com.example.attack", Key: "k8s.namespace", Type: "yaml"}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ k8s.namespace │\n│ k8s.pod.name │") + assert.Contains(t, out, "- shop\n- kube-system\n") + values := p.Requests("GET /api/targets/attributes/values")[0].Query + assert.Equal(t, []string{"k8s.namespace"}, values["attributeKey"]) + assert.Equal(t, []string{"com.example.attack"}, values["actionId"]) + assert.EqualError(t, target.AttributeKeys(ctx, p.Client, target.AttributeOptions{Environment: "Global"}), "Either --target-type or --action must be specified.") +} diff --git a/internal/team/team.go b/internal/team/team.go new file mode 100644 index 0000000..542c486 --- /dev/null +++ b/internal/team/team.go @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package team implements the `team` commands. +package team + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/jsyaml" + "github.com/steadybit/cli/internal/output" + "github.com/steadybit/cli/internal/platform" + "github.com/steadybit/cli/internal/resource" + "github.com/steadybit/cli/internal/table" +) + +// The version is dropped as `service get` drops it. Members are sent back as the platform +// takes them, by username, email and role; the rest describes the user. +var ( + readOnly = []string{"version"} + memberReadOnly = []string{"name", "pictureUrl", "managedBy"} +) + +func notFoundOr(err error, key, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Team %s not found.", key) + } + return platform.Failed(err, format, key) +} + +type ListOptions struct { + Search string + Type string +} + +func List(ctx context.Context, c *platform.Client, o ListOptions) error { + var summaries struct { + Teams []struct { + Key, Name string + AllowedEnvironments []string `json:"allowedEnvironments"` + Members []any `json:"members"` + } `json:"teams"` + } + params := &api.GetTeamsParams{} + if o.Search != "" { + params.Search = &o.Search + } + resp, err := c.GetTeams(ctx, params) + raw, err := resource.DecodeListed(resp, err, "teams", &summaries) + if err != nil { + return platform.Failed(err, "Failed to get the teams") + } + if resource.Machine(o.Type) { + return resource.List(raw, o.Type, nil) + } + if len(summaries.Teams) == 0 { + fmt.Println("No teams found.") + return nil + } + t := table.New( + table.Column{Name: "key", Title: "Key", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "members", Title: "Members"}, + table.Column{Name: "environments", Title: "Environments"}, + ) + for _, team := range summaries.Teams { + t.AddRow(table.Default, table.Cell("key", team.Key), table.Cell("name", team.Name), table.Cell("members", len(team.Members)), + table.Cell("environments", len(team.AllowedEnvironments))) + } + t.Print() + return nil +} + +type GetOptions struct { + Key, File, Type string +} + +func Get(ctx context.Context, c *platform.Client, o GetOptions) error { + doc, _, err := platform.ReadDocument(c.GetTeam(ctx, o.Key)) + if err != nil { + return notFoundOr(err, o.Key, "Failed to get team %s") + } + resource.Strip(doc, readOnly...) + if members, ok := doc.Value().Get("members"); ok { + list, _ := members.([]any) + for _, m := range list { + if member, ok := m.(*jsyaml.Map); ok { + for _, field := range memberReadOnly { + member.Delete(field) + } + } + } + } + if err := resource.Output(doc, o.File, o.Type); err != nil { + return err + } + if o.File != "" { + fmt.Printf("Team %s written to %s.\n", o.Key, o.File) + } + return nil +} + +type ApplyOptions struct { + Files []string + Recursive bool +} + +// Apply upserts teams by their key, which is what names a team; there is no id to write back. +func Apply(ctx context.Context, c *platform.Client, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "team", func(file string, doc *output.Document) (resource.Applied, error) { + key, _ := doc.Get("key") + if key == "" { + return resource.Applied{}, fmt.Errorf("Team file '%s' does not name the team key.", file) + } + resp, err := c.UpsertTeamWithBody(ctx, &api.UpsertTeamParams{}, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + _, resp, err = platform.Read(resp, err) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save team %s", key) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Team %s %s.\n", key, resource.CreatedOrUpdated(created)) + return resource.Applied{Created: created}, nil + }) +} + +type DeleteOptions struct { + Key string + Experiments bool + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + question := fmt.Sprintf("Delete team %s?", o.Key) + if o.Experiments { + question = fmt.Sprintf("Delete team %s with all its experiments and their runs?", o.Key) + } + if ok, err := resource.Confirmed(o.Yes, question); !ok || err != nil { + return err + } + _, _, err := platform.Read(c.DeleteTeam(ctx, o.Key, &api.DeleteTeamParams{PurgeIncludingExperiments: o.Experiments})) + // The platform refuses without purging, even a team without experiments, and says + // nothing about why. + if !o.Experiments && platform.IsStatus(err, http.StatusBadRequest) { + return fmt.Errorf("Team %s was not deleted. The platform deletes a team only with --purge-experiments, which deletes its experiments and their runs too.", o.Key) + } + if err != nil { + return notFoundOr(err, o.Key, "Failed to delete team %s") + } + fmt.Printf("Team %s deleted.\n", o.Key) + return nil +} + +type member struct { + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` +} + +type members struct { + Members []member `json:"members"` +} + +func ListMembers(ctx context.Context, c *platform.Client, key, explicitType string) error { + var result members + resp, err := c.GetTeamMembers(ctx, key) + raw, err := resource.DecodeListed(resp, err, "members", &result) + if err != nil { + return notFoundOr(err, key, "Failed to get the members of team %s") + } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } + printMembers(key, result.Members) + return nil +} + +func printMembers(key string, list []member) { + if len(list) == 0 { + fmt.Printf("Team %s has no members.\n", key) + return + } + t := table.New( + table.Column{Name: "username", Title: "Username", Alignment: table.Left}, + table.Column{Name: "name", Title: "Name", Alignment: table.Left}, + table.Column{Name: "email", Title: "Email", Alignment: table.Left}, + table.Column{Name: "role", Title: "Role", Alignment: table.Left}, + ) + for _, m := range list { + t.AddRow(table.Default, table.Cell("username", m.Username), table.Cell("name", m.Name), table.Cell("email", m.Email), table.Cell("role", m.Role)) + } + t.Print() +} + +// MemberOptions names users by username or email; the platform takes either. +type MemberOptions struct { + Key string + Usernames []string + Emails []string + Role string + Validate bool + Yes bool +} + +func (o MemberOptions) update() (api.TeamMembersUpdateAO, error) { + role := api.MemberUpdateAORole(strings.ToUpper(o.Role)) + if role != api.MemberUpdateAORoleMEMBER && role != api.MemberUpdateAORoleOWNER { + return api.TeamMembersUpdateAO{}, fmt.Errorf("--role must be MEMBER or OWNER, not '%s'.", o.Role) + } + update := api.TeamMembersUpdateAO{Members: []api.MemberUpdateAO{}} + for _, u := range o.Usernames { + update.Members = append(update.Members, api.MemberUpdateAO{Username: &u, Role: role}) + } + for _, e := range o.Emails { + update.Members = append(update.Members, api.MemberUpdateAO{Email: &e, Role: role}) + } + return update, nil +} + +func (o MemberOptions) count() int { return len(o.Usernames) + len(o.Emails) } + +var errNoMembers = errors.New("No members given. Pass --username or --email.") + +func AddMembers(ctx context.Context, c *platform.Client, o MemberOptions) error { + if o.count() == 0 { + return errNoMembers + } + update, err := o.update() + if err != nil { + return err + } + var result members + resp, err := c.AddTeamMembers(ctx, o.Key, &api.AddTeamMembersParams{ValidateMembers: &o.Validate}, update) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to add members to team %s") + } + fmt.Printf("Team %s now has %d member(s).\n", o.Key, len(result.Members)) + return nil +} + +func RemoveMembers(ctx context.Context, c *platform.Client, o MemberOptions) error { + if o.count() == 0 { + return errNoMembers + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Remove %d member(s) from team %s?", o.count(), o.Key)); !ok || err != nil { + return err + } + request := api.TeamMembersRemoveAO{Usernames: resource.Optional(o.Usernames), Emails: resource.Optional(o.Emails)} + var result members + resp, err := c.RemoveTeamMembers(ctx, o.Key, request) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to remove members from team %s") + } + fmt.Printf("Team %s now has %d member(s).\n", o.Key, len(result.Members)) + return nil +} + +// SetMembers replaces the members: everyone not given is removed from the team. +func SetMembers(ctx context.Context, c *platform.Client, o MemberOptions) error { + if o.count() == 0 { + return errNoMembers + } + update, err := o.update() + if err != nil { + return err + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Make these %d the only members of team %s, removing everyone else?", o.count(), o.Key)); !ok || err != nil { + return err + } + var result members + resp, err := c.SetTeamMembers(ctx, o.Key, &api.SetTeamMembersParams{ValidateMembers: &o.Validate}, update) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to set the members of team %s") + } + fmt.Printf("Team %s now has %d member(s).\n", o.Key, len(result.Members)) + return nil +} + +type environments struct { + Environments []struct { + Name string `json:"name"` + } `json:"environments"` +} + +func (e environments) names() []string { + names := make([]string, len(e.Environments)) + for i, env := range e.Environments { + names[i] = env.Name + } + return names +} + +func ListEnvironments(ctx context.Context, c *platform.Client, key, explicitType string) error { + var result environments + resp, err := c.GetTeamEnvironments(ctx, key) + raw, err := resource.DecodeListed(resp, err, "environments", &result) + if err != nil { + return notFoundOr(err, key, "Failed to get the environments of team %s") + } + if resource.Machine(explicitType) { + return resource.List(raw, explicitType, nil) + } + if len(result.Environments) == 0 { + fmt.Printf("Team %s has no environments.\n", key) + return nil + } + t := table.New(table.Column{Name: "name", Title: "Environment", Alignment: table.Left}) + for _, name := range result.names() { + t.AddRow(table.Default, table.Cell("name", name)) + } + t.Print() + return nil +} + +type EnvironmentOptions struct { + Key string + Environments []string + Validate bool + Yes bool +} + +func (o EnvironmentOptions) list() []api.TeamEnvironmentAO { + list := make([]api.TeamEnvironmentAO, len(o.Environments)) + for i, name := range o.Environments { + list[i] = api.TeamEnvironmentAO{Name: name} + } + return list +} + +var errNoEnvironments = errors.New("No environments given. Pass --environment.") + +func reportEnvironments(key string, result environments) { + if len(result.Environments) == 0 { + fmt.Printf("Team %s now has no environments.\n", key) + return + } + fmt.Printf("Team %s now has %d environment(s): %s.\n", key, len(result.Environments), strings.Join(result.names(), ", ")) +} + +func AddEnvironments(ctx context.Context, c *platform.Client, o EnvironmentOptions) error { + if len(o.Environments) == 0 { + return errNoEnvironments + } + var result environments + resp, err := c.AddTeamEnvironments(ctx, o.Key, &api.AddTeamEnvironmentsParams{ValidateEnvironments: &o.Validate}, api.TeamEnvironmentsUpdateAO{Environments: o.list()}) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to add environments to team %s") + } + reportEnvironments(o.Key, result) + return nil +} + +func RemoveEnvironments(ctx context.Context, c *platform.Client, o EnvironmentOptions) error { + if len(o.Environments) == 0 { + return errNoEnvironments + } + var result environments + resp, err := c.RemoveTeamEnvironments(ctx, o.Key, api.TeamEnvironmentsUpdateAO{Environments: o.list()}) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to remove environments from team %s") + } + reportEnvironments(o.Key, result) + return nil +} + +// SetEnvironments replaces the environments a team may use. +func SetEnvironments(ctx context.Context, c *platform.Client, o EnvironmentOptions) error { + if len(o.Environments) == 0 { + return errNoEnvironments + } + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Make these %d the only environments of team %s?", len(o.Environments), o.Key)); !ok || err != nil { + return err + } + var result environments + resp, err := c.SetTeamEnvironments(ctx, o.Key, &api.SetTeamEnvironmentsParams{ValidateEnvironments: &o.Validate}, api.TeamEnvironmentsAO{Environments: o.list()}) + if _, err := platform.Decode(resp, err, &result); err != nil { + return notFoundOr(err, o.Key, "Failed to set the environments of team %s") + } + reportEnvironments(o.Key, result) + return nil +} diff --git a/internal/team/team_test.go b/internal/team/team_test.go new file mode 100644 index 0000000..85f5829 --- /dev/null +++ b/internal/team/team_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package team_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/team" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +const stored = `{"id":"0190d7b2-0000-7000-8000-000000000001","key":"OPS","name":"Operations","version":7,"allowedActions":[],"allowedEnvironments":["Global"],"managedBy":"MANUAL",` + + `"members":[{"username":"u-1","name":"Jane","pictureUrl":"p","email":"jane@example.com","role":"OWNER","managedBy":"MANUAL"}]}` + +func TestList(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams", platformtest.Reply{Body: `{"teams":[` + stored + `]}`}) + + out, err := platformtest.Stdout(t, func() error { return team.List(ctx, p.Client, team.ListOptions{Search: "ops"}) }) + + require.NoError(t, err) + assert.Contains(t, out, "│ OPS │ Operations │ 1 │ 1 │") + assert.Equal(t, []string{"ops"}, p.Requests("GET /api/teams")[0].Query["search"]) +} + +func TestGetAndApplyRoundTripByKey(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams/OPS", platformtest.Reply{Body: stored}) + p.Reply("POST /api/teams", platformtest.Reply{Body: stored}) + file := filepath.Join(t.TempDir(), "team.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := team.Get(ctx, p.Client, team.GetOptions{Key: "OPS", File: file}); err != nil { + return err + } + return team.Apply(ctx, p.Client, team.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Team OPS updated.") + before, _ := os.ReadFile(file) + assert.NotContains(t, string(before), "version") + assert.NotContains(t, string(before), "pictureUrl") + sent := p.Requests("POST /api/teams")[0].JSON(t).(map[string]any) + assert.Equal(t, []any{map[string]any{"username": "u-1", "email": "jane@example.com", "role": "OWNER"}}, sent["members"]) + assert.Equal(t, "MANUAL", sent["managedBy"]) +} + +func TestApplyWritesNothingBack(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/teams", platformtest.Reply{Status: http.StatusCreated, Body: stored}) + file := filepath.Join(t.TempDir(), "team.yml") + original := "key: OPS\nname: Operations\nallowedActions: []\nallowedEnvironments: []\n" + require.NoError(t, os.WriteFile(file, []byte(original), 0o644)) + + out, err := platformtest.Stdout(t, func() error { return team.Apply(ctx, p.Client, team.ApplyOptions{Files: []string{file}}) }) + + require.NoError(t, err) + assert.Equal(t, "Team OPS created.\n", out) + content, _ := os.ReadFile(file) + assert.Equal(t, original, string(content)) +} + +func TestDeletePurgesOnlyWhenAsked(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/teams/OPS", platformtest.Reply{Body: stored}) + p.Reply("DELETE /api/teams/NOPE", platformtest.Reply{Status: http.StatusNotFound}) + p.Reply("DELETE /api/teams/EMPTY", platformtest.Reply{Status: http.StatusBadRequest}) + + out, err := platformtest.Stdout(t, func() error { + if err := team.Delete(ctx, p.Client, team.DeleteOptions{Key: "OPS", Yes: true}); err != nil { + return err + } + return team.Delete(ctx, p.Client, team.DeleteOptions{Key: "OPS", Experiments: true, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Team OPS deleted.\nTeam OPS deleted.\n", out) + requests := p.Requests("DELETE /api/teams/OPS") + assert.Equal(t, []string{"false"}, requests[0].Query["purgeIncludingExperiments"]) + assert.Equal(t, []string{"true"}, requests[1].Query["purgeIncludingExperiments"]) + assert.EqualError(t, team.Delete(ctx, p.Client, team.DeleteOptions{Key: "NOPE", Yes: true}), "Team NOPE not found.") + assert.EqualError(t, team.Delete(ctx, p.Client, team.DeleteOptions{Key: "EMPTY", Yes: true}), + "Team EMPTY was not deleted. The platform deletes a team only with --purge-experiments, which deletes its experiments and their runs too.") +} + +func TestMembers(t *testing.T) { + p := platformtest.New(t) + result := platformtest.Reply{Body: `{"members":[{"username":"u-1","name":"Jane","email":"jane@example.com","role":"OWNER"}]}`} + p.Reply("GET /api/teams/OPS/members", result) + p.Reply("POST /api/teams/OPS/members/add", result) + p.Reply("POST /api/teams/OPS/members/remove", result) + p.Reply("PUT /api/teams/OPS/members", result) + + out, err := platformtest.Stdout(t, func() error { + if err := team.ListMembers(ctx, p.Client, "OPS", ""); err != nil { + return err + } + if err := team.AddMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Usernames: []string{"u-1"}, Emails: []string{"joe@example.com"}, Role: "owner"}); err != nil { + return err + } + if err := team.RemoveMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Emails: []string{"joe@example.com"}, Yes: true}); err != nil { + return err + } + return team.SetMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Usernames: []string{"u-1"}, Role: "OWNER", Validate: true, Yes: true}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ u-1 │ Jane │ jane@example.com │ OWNER │") + assert.Contains(t, out, "Team OPS now has 1 member(s).") + add := p.Requests("POST /api/teams/OPS/members/add")[0] + assert.Equal(t, map[string]any{"members": []any{map[string]any{"username": "u-1", "role": "OWNER"}, map[string]any{"email": "joe@example.com", "role": "OWNER"}}}, add.JSON(t)) + assert.Equal(t, []string{"false"}, add.Query["validateMembers"]) + assert.Equal(t, map[string]any{"emails": []any{"joe@example.com"}}, p.Requests("POST /api/teams/OPS/members/remove")[0].JSON(t)) + set := p.Requests("PUT /api/teams/OPS/members")[0] + assert.Equal(t, map[string]any{"members": []any{map[string]any{"username": "u-1", "role": "OWNER"}}}, set.JSON(t)) + assert.Equal(t, []string{"true"}, set.Query["validateMembers"]) + + assert.EqualError(t, team.AddMembers(ctx, p.Client, team.MemberOptions{Key: "OPS"}), "No members given. Pass --username or --email.") + assert.EqualError(t, team.AddMembers(ctx, p.Client, team.MemberOptions{Key: "OPS", Usernames: []string{"x"}, Role: "admin"}), "--role must be MEMBER or OWNER, not 'admin'.") +} + +func TestEnvironments(t *testing.T) { + p := platformtest.New(t) + result := platformtest.Reply{Body: `{"environments":[{"name":"Global"},{"name":"Prod"}]}`} + p.Reply("GET /api/teams/OPS/environments", result) + p.Reply("POST /api/teams/OPS/environments/add", result) + p.Reply("POST /api/teams/OPS/environments/remove", result) + p.Reply("PUT /api/teams/OPS/environments", result) + p.Reply("GET /api/teams/NOPE/environments", platformtest.Reply{Status: http.StatusNotFound}) + + out, err := platformtest.Stdout(t, func() error { + if err := team.ListEnvironments(ctx, p.Client, "OPS", ""); err != nil { + return err + } + o := team.EnvironmentOptions{Key: "OPS", Environments: []string{"Prod"}, Yes: true} + if err := team.AddEnvironments(ctx, p.Client, o); err != nil { + return err + } + if err := team.RemoveEnvironments(ctx, p.Client, o); err != nil { + return err + } + return team.SetEnvironments(ctx, p.Client, o) + }) + + require.NoError(t, err) + assert.Contains(t, out, "│ Prod │") + assert.Contains(t, out, "Team OPS now has 2 environment(s): Global, Prod.") + want := map[string]any{"environments": []any{map[string]any{"name": "Prod"}}} + assert.Equal(t, want, p.Requests("POST /api/teams/OPS/environments/add")[0].JSON(t)) + assert.Equal(t, want, p.Requests("POST /api/teams/OPS/environments/remove")[0].JSON(t)) + assert.Equal(t, want, p.Requests("PUT /api/teams/OPS/environments")[0].JSON(t)) + assert.EqualError(t, team.ListEnvironments(ctx, p.Client, "NOPE", ""), "Team NOPE not found.") +} + +func TestListPrintsThePlatformsTeamsAsJSON(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/teams", platformtest.Reply{Body: `{"teams":[{"key":"ADM","name":"Admins","members":[]}]}`}) + + out, err := platformtest.Stdout(t, func() error { return team.List(ctx, p.Client, team.ListOptions{Type: "json"}) }) + + require.NoError(t, err) + assert.Equal(t, "[\n {\n \"key\": \"ADM\",\n \"name\": \"Admins\",\n \"members\": []\n }\n]\n", out) +} diff --git a/internal/template/template.go b/internal/template/template.go index 4e1367a..7e3c0b8 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Steadybit GmbH -// Package template implements `template list` and `template get`. +// Package template implements the `template` commands. package template import ( "context" + "encoding/json" + "errors" "fmt" "net/http" @@ -18,8 +20,21 @@ import ( "github.com/steadybit/cli/internal/table" ) +// Who created and edited a template cannot be sent back. The version is dropped as +// `service get` drops it, so that an edit in the UI does not turn the next apply into a +// conflict. +var readOnly = []string{"created", "createdBy", "edited", "editedBy", "version"} + +func notFoundOr(err error, id, format string) error { + if platform.IsStatus(err, http.StatusNotFound) { + return fmt.Errorf("Experiment template %s not found.", id) + } + return platform.Failed(err, format, id) +} + type ListOptions struct { Tags, TargetTypes, Actions, Search []string + Type string } func optional(values []string) *[]string { @@ -30,18 +45,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 @@ -94,7 +118,7 @@ func Get(ctx context.Context, c *platform.Client, o GetOptions) error { } doc = output.NewDocument(values) } - if err := resource.Output(doc, o.File, o.Type); err != nil { + if err := resource.Output(resource.Strip(doc, readOnly...), o.File, o.Type); err != nil { return err } if o.File != "" { @@ -102,3 +126,78 @@ func Get(ctx context.Context, c *platform.Client, o GetOptions) error { } return nil } + +type ApplyOptions struct { + Files []string + Recursive bool +} + +func Apply(ctx context.Context, c *platform.Client, o ApplyOptions) error { + return resource.ApplyFiles(o.Files, o.Recursive, "template", func(file string, doc *output.Document) (resource.Applied, error) { + title, _ := doc.Get("templateTitle") + if title == "" { + return resource.Applied{}, fmt.Errorf("Template file '%s' does not name a templateTitle.", file) + } + var saved struct{ ID, TemplateTitle string } + resp, err := c.UpsertExperimentTemplateWithBody(ctx, "application/json", resource.Body(resource.Strip(doc, readOnly...).Value())) + resp, err = platform.Decode(resp, err, &saved) + if err != nil { + return resource.Applied{}, platform.Failed(err, "Failed to save experiment template %s", title) + } + created := resp.StatusCode == http.StatusCreated + fmt.Printf("Experiment template %s (%s) %s.\n", saved.TemplateTitle, saved.ID, resource.CreatedOrUpdated(created)) + return resource.Applied{ID: saved.ID, Created: created}, nil + }) +} + +type DeleteOptions struct { + ID string + Yes bool +} + +func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error { + id, ok := resource.UUID(o.ID) + if !ok { + return fmt.Errorf("Experiment template %s not found.", o.ID) + } + // Deleting a template also deletes the experiments service profiles provided from it. + if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Delete experiment template %s, and the service experiments provided from it?", o.ID)); !ok || err != nil { + return err + } + if _, _, err := platform.Read(c.DeleteExperimentTemplate(ctx, id)); err != nil { + return notFoundOr(err, o.ID, "Failed to delete experiment template %s") + } + fmt.Printf("Experiment template %s deleted.\n", o.ID) + return nil +} + +type ImportOptions struct { + Hub string + Templates []string + Overwrite bool +} + +func Import(ctx context.Context, c *platform.Client, o ImportOptions) error { + hub, ok := resource.UUID(o.Hub) + if !ok { + return fmt.Errorf("Hub %s not found.", o.Hub) + } + ids := make([]openapi_types.UUID, len(o.Templates)) + for i, t := range o.Templates { + if ids[i], ok = resource.UUID(t); !ok { + return fmt.Errorf("Experiment template %s not found.", t) + } + } + request := api.ExperimentTemplatesImportAO{HubId: hub, TemplateIds: &ids} + _, _, err := platform.Read(c.ImportFromHub(ctx, &api.ImportFromHubParams{Overwrite: &o.Overwrite}, request)) + switch { + case platform.IsStatus(err, http.StatusConflict): + return errors.New("Some of the templates exist already. Pass --overwrite to replace them.") + case platform.IsStatus(err, http.StatusNotFound): + return fmt.Errorf("Hub %s not found.", o.Hub) + case err != nil: + return platform.Failed(err, "Failed to import experiment templates from hub %s", o.Hub) + } + fmt.Printf("%d experiment template(s) imported from hub %s.\n", len(o.Templates), o.Hub) + return nil +} diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 72c7f37..093ca2a 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -6,6 +6,8 @@ package template_test import ( "context" "net/http" + "os" + "path/filepath" "testing" "github.com/steadybit/cli/internal/platformtest" @@ -49,3 +51,79 @@ func TestGetReportsAMissingTemplate(t *testing.T) { assert.EqualError(t, template.Get(context.Background(), p.Client, template.GetOptions{ID: id}), "Experiment template "+id+" not found.") } + +func TestGetAndApplyRoundTrip(t *testing.T) { + p := platformtest.New(t) + p.Reply("GET /api/experiments/templates/"+id, platformtest.Reply{Body: `{"id":"` + id + `","version":2,"templateTitle":"Shop survives","templateDescription":"d","lanes":[],"futureField":1,"created":"c","createdBy":{},"edited":"e","editedBy":{}}`}) + p.Reply("POST /api/experiments/templates", platformtest.Reply{JSON: map[string]any{"id": id, "templateTitle": "Shop survives"}}) + file := filepath.Join(t.TempDir(), "template.yml") + + out, err := platformtest.Stdout(t, func() error { + if err := template.Get(context.Background(), p.Client, template.GetOptions{ID: id, File: file}); err != nil { + return err + } + return template.Apply(context.Background(), p.Client, template.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Contains(t, out, "Experiment template Shop survives ("+id+") updated.") + content, _ := os.ReadFile(file) + assert.Equal(t, "id: "+id+"\ntemplateTitle: Shop survives\ntemplateDescription: d\nlanes: []\nfutureField: 1\n", string(content)) + // A field the spec does not know yet is sent back, not dropped. + assert.Equal(t, map[string]any{"id": id, "templateTitle": "Shop survives", "templateDescription": "d", "lanes": []any{}, "futureField": float64(1)}, + p.Requests("POST /api/experiments/templates")[0].JSON(t)) +} + +func TestApplyCreatesAndWritesTheIdBack(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/experiments/templates", platformtest.Reply{Status: http.StatusCreated, JSON: map[string]any{"id": id, "templateTitle": "New"}}) + dir := t.TempDir() + file := filepath.Join(dir, "new.yml") + require.NoError(t, os.WriteFile(file, []byte("templateTitle: New\nlanes: []\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untitled.yml"), []byte("lanes: []\n"), 0o644)) + + out, err := platformtest.Stdout(t, func() error { + return template.Apply(context.Background(), p.Client, template.ApplyOptions{Files: []string{file}}) + }) + + require.NoError(t, err) + assert.Equal(t, "Experiment template New ("+id+") created.\n", out) + content, _ := os.ReadFile(file) + assert.Equal(t, "id: "+id+"\ntemplateTitle: New\nlanes: []\n", string(content)) + err = template.Apply(context.Background(), p.Client, template.ApplyOptions{Files: []string{filepath.Join(dir, "untitled.yml")}}) + assert.EqualError(t, err, "Template file '"+filepath.Join(dir, "untitled.yml")+"' does not name a templateTitle.") +} + +func TestDelete(t *testing.T) { + p := platformtest.New(t) + p.Reply("DELETE /api/experiments/templates/"+id, platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + return template.Delete(context.Background(), p.Client, template.DeleteOptions{ID: id, Yes: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "Experiment template "+id+" deleted.\n", out) + assert.EqualError(t, template.Delete(context.Background(), p.Client, template.DeleteOptions{ID: "nope", Yes: true}), "Experiment template nope not found.") +} + +func TestImportFromAHub(t *testing.T) { + const hub = "0194a7d4-0d1f-7b21-9c64-5b6e3c1f2a10" + p := platformtest.New(t) + p.Handle("POST /api/experiments/templates/imports", func(r platformtest.Request) platformtest.Reply { + if r.Query["overwrite"][0] == "false" { + return platformtest.Reply{Status: http.StatusConflict} + } + return platformtest.Reply{} + }) + + out, err := platformtest.Stdout(t, func() error { + return template.Import(context.Background(), p.Client, template.ImportOptions{Hub: hub, Templates: []string{id}, Overwrite: true}) + }) + + require.NoError(t, err) + assert.Equal(t, "1 experiment template(s) imported from hub "+hub+".\n", out) + assert.Equal(t, map[string]any{"hubId": hub, "templateIds": []any{id}}, p.Requests("POST /api/experiments/templates/imports")[0].JSON(t)) + assert.EqualError(t, template.Import(context.Background(), p.Client, template.ImportOptions{Hub: hub, Templates: []string{id}}), + "Some of the templates exist already. Pass --overwrite to replace them.") +} diff --git a/internal/user/user.go b/internal/user/user.go new file mode 100644 index 0000000..aaa9c37 --- /dev/null +++ b/internal/user/user.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +// Package user implements the `user` commands. +package user + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/steadybit/cli/api" + "github.com/steadybit/cli/internal/platform" +) + +type InviteOptions struct { + Emails []string + Role string + Team string +} + +func Invite(ctx context.Context, c *platform.Client, o InviteOptions) error { + if len(o.Emails) == 0 { + return errors.New("No one to invite. Pass --email.") + } + var role *api.InvitationAORole + if o.Role != "" { + r := api.InvitationAORole(strings.ToUpper(o.Role)) + if r != api.InvitationAORoleADMIN && r != api.InvitationAORoleUSER { + return fmt.Errorf("--role must be USER or ADMIN, not '%s'.", o.Role) + } + role = &r + } + request := api.InviteUsersRequestAO{Invitations: make([]api.InvitationAO, len(o.Emails))} + for i, email := range o.Emails { + if _, err := openapi_types.Email(email).MarshalJSON(); err != nil { + return fmt.Errorf("'%s' is not an email address.", email) + } + request.Invitations[i] = api.InvitationAO{Email: openapi_types.Email(email), Role: role} + if o.Team != "" { + request.Invitations[i].TeamKey = &o.Team + } + } + _, _, err := platform.Read(c.InviteUser(ctx, request)) + if platform.IsStatus(err, http.StatusForbidden) { + return errors.New("Inviting users needs an admin access token.") + } + if err != nil { + return platform.Failed(err, "Failed to invite %s", strings.Join(o.Emails, ", ")) + } + fmt.Printf("%d user(s) invited. They receive an email to join.\n", len(o.Emails)) + return nil +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go new file mode 100644 index 0000000..51f9795 --- /dev/null +++ b/internal/user/user_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Steadybit GmbH + +package user_test + +import ( + "context" + "net/http" + "testing" + + "github.com/steadybit/cli/internal/platformtest" + "github.com/steadybit/cli/internal/user" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ctx = context.Background() + +func TestInvite(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/users/invite", platformtest.Reply{}) + + out, err := platformtest.Stdout(t, func() error { + return user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane@example.com", "joe@example.com"}, Role: "user", Team: "ADM"}) + }) + + require.NoError(t, err) + assert.Equal(t, "2 user(s) invited. They receive an email to join.\n", out) + assert.Equal(t, map[string]any{"invitations": []any{ + map[string]any{"email": "jane@example.com", "role": "USER", "teamKey": "ADM"}, + map[string]any{"email": "joe@example.com", "role": "USER", "teamKey": "ADM"}, + }}, p.Requests("POST /api/users/invite")[0].JSON(t)) +} + +func TestInviteLeavesOutWhatIsNotGiven(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/users/invite", platformtest.Reply{}) + + _, err := platformtest.Stdout(t, func() error { + return user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane@example.com"}}) + }) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"invitations": []any{map[string]any{"email": "jane@example.com"}}}, p.Requests("POST /api/users/invite")[0].JSON(t)) +} + +func TestInviteRefusals(t *testing.T) { + p := platformtest.New(t) + p.Reply("POST /api/users/invite", platformtest.Reply{Status: http.StatusForbidden}) + + assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane@example.com"}}), "Inviting users needs an admin access token.") + assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane"}}), "'jane' is not an email address.") + assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{Emails: []string{"jane@example.com"}, Role: "owner"}), "--role must be USER or ADMIN, not 'owner'.") + assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{}), "No one to invite. Pass --email.") + assert.Len(t, p.Requests("POST /api/users/invite"), 1) +}