Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@
stderr is not a terminal, for builds that are not releases, and with
`STEADYBIT_NO_UPDATE_CHECK` set. The check asks GitHub where its latest release is and
waits for the answer at most a second, once a day.
- `experiment badge -k ADM-1` prints the Markdown that embeds the experiment's status
badge in a README, `--format html` the HTML and `--format url` the image URL;
`--tag` prints the badge of a tag instead, which invites to create the experiment
while there is none. The URLs carry the tenant key, never the access token. The
tenant key is read from the license, which needs an admin access token; `--tenant`
gives it otherwise, and with an admin access token must be the token's own tenant.
The badge is fetched once without the token, so a wrong tenant key fails the command
rather than showing as a broken image. `-t json|yaml` prints every format at once and
does not combine with `--format`.
- `target stats` counts the targets of each type in the tenant, optionally only those
matching a target query (`-q`), as a table or with `-t json|yaml` as the platform
sends it.
- `license show` prints the tenant's license, when it expires, and how much of each
limit is used; `-t json|yaml` prints the platform's summary. `license report`
downloads the license usage report, a zip archive, under the platform's name without
overwriting a file, or to `-o`. Both need an admin access token.

## v6.0.1

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ steadybit execution artifact list -i 1234
steadybit execution artifact download -i 1234 -d ./artifacts
```

Show the state of an experiment's latest run in a README with a status badge. The badge
URL carries the tenant key, never the access token:

```bash
steadybit experiment badge -k ADM-1 # Markdown; --format html or url
```

### Experiment schedules

```bash
Expand Down Expand Up @@ -234,6 +241,7 @@ steadybit access-token create --name ci --type TEAM --team ADM --expires-at 2026
steadybit user invite --email jane@example.com --team ADM
steadybit killswitch status
steadybit audit-log --from 2026-09-01 -t json
steadybit license show
steadybit report experiments-executed --group-by STATE --rollup MONTHLY
```

Expand All @@ -245,6 +253,7 @@ Commands that cannot be undone, such as `killswitch activate`, `access-token del
```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 target stats -q 'k8s.namespace="shop"'
steadybit action list --kind ATTACK
```

Expand Down
172 changes: 172 additions & 0 deletions internal/badge/badge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Steadybit GmbH

// Package badge implements the `experiment badge` command: a status badge to embed in a
// README, which is served without an access token.
package badge

import (
"context"
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
"net/url"
"strings"

"github.com/steadybit/cli/v6/internal/experiment"
"github.com/steadybit/cli/v6/internal/output"
"github.com/steadybit/cli/v6/internal/platform"
"github.com/steadybit/cli/v6/internal/resource"
)

type Options struct {
Key, Tag, CreateCaption string
Tenant string
Scale int
// "markdown", "html" or "url".
Format string
Type string
}

type badge struct {
Image string `json:"image"`
Link string `json:"link"`
Markdown string `json:"markdown"`
HTML string `json:"html"`
}

// Print writes the snippet that embeds the badge. The badge URLs carry the tenant key
// and never the access token: a README is read by anyone, and the platform serves
// badges to anyone who knows the tenant key.
func Print(ctx context.Context, c *platform.Client, o Options) error {
if (o.Key == "") == (o.Tag == "") {
return errors.New("Either --key or --tag must be specified.")
}
if o.CreateCaption != "" && o.Tag == "" {
return errors.New("--create-caption only applies to a badge for --tag.")
}
if o.Scale < 0 {
return errors.New("--scale cannot be negative.")
}
// Checked before any request: the badge takes up to three.
if _, err := output.ResolveDatatype(o.Type, ""); err != nil {
return err
}
format := o.Format
if format != "" && resource.Machine(o.Type) {
return errors.New("--format cannot be combined with -t or --jq, which print every format.")
}
if format == "" {
format = "markdown"
}
if format != "markdown" && format != "html" && format != "url" {
return fmt.Errorf("Unsupported badge format '%s'. Use \"markdown\", \"html\" or \"url\".", format)
}
tenant, err := tenantKey(ctx, c, o.Tenant)
if err != nil {
return err
}

var imagePath, linkPath, alt string
image := url.Values{"tenantKey": {tenant}}
if o.Scale > 0 {
image.Set("scale", fmt.Sprint(o.Scale))
}
if o.Key != "" {
// The badge of a key that does not exist is an image saying "not found", with 200.
doc, err := experiment.Fetch(ctx, c, o.Key)
if err != nil {
return err
}
team, _ := doc.Get("team")
imagePath = "/api/experiments/" + url.PathEscape(o.Key) + "/badge.svg?" + query(image)
linkPath = "/experiments/edit/" + url.PathEscape(o.Key) + "?" + query(url.Values{"tenant": {tenant}, "team": {team}})
alt = o.Key
} else {
image.Set("tag", o.Tag)
if o.CreateCaption != "" {
image.Set("createCaption", o.CreateCaption)
}
imagePath = "/api/badges/linked-badge.svg?" + query(image)
linkPath = "/api/badges/link?" + query(url.Values{"tenantKey": {tenant}, "tag": {o.Tag}})
alt = o.Tag
}
if err := check(ctx, c, imagePath, tenant); err != nil {
return err
}

b := badge{Image: c.BaseURL + imagePath, Link: c.BaseURL + linkPath}
b.Markdown = fmt.Sprintf("[![%s](%s)](%s)", markdownText(alt), b.Image, b.Link)
b.HTML = fmt.Sprintf(`<a href="%s"><img alt="%s" src="%s"></a>`, html.EscapeString(b.Link), html.EscapeString(alt), html.EscapeString(b.Image))
if resource.Machine(o.Type) {
raw, _ := json.Marshal(b)
return resource.PrintJSONValue(raw, o.Type)
}
switch format {
case "html":
fmt.Println(b.HTML)
case "url":
fmt.Println(b.Image)
default:
fmt.Println(b.Markdown)
}
return nil
}

// query encodes spaces as %20: a badge caption is shown as written, and not every
// Markdown renderer or server reads + as a space.
func query(v url.Values) string { return strings.ReplaceAll(v.Encode(), "+", "%20") }

func markdownText(s string) string {
return strings.NewReplacer(`\`, `\\`, "[", `\[`, "]", `\]`).Replace(s)
}

// tenantKey is the one given, or the one the license names. The access token does not
// say which tenant it belongs to, and the license is the only other place that does.
// A given key is still compared with the license when it can be read: the badge of
// another tenant's experiment is an image saying "not found", with 200, which the check
// of the badge cannot tell from a real one.
func tenantKey(ctx context.Context, c *platform.Client, given string) (string, error) {
var summary struct {
TenantKey string `json:"tenantKey"`
}
resp, err := c.GetLicenseSummary(ctx)
_, err = platform.Decode(resp, err, &summary)
if given != "" {
// Without an admin token the license cannot be read, and the given key is taken as it is.
if err == nil && summary.TenantKey != "" && summary.TenantKey != given {
return "", fmt.Errorf("The access token belongs to tenant %s, not %s: the badge would show \"not found\". Leave out --tenant, or use an access token of tenant %s.",
summary.TenantKey, given, given)
}
return given, nil
}
if platform.IsStatus(err, http.StatusForbidden) {
return "", errors.New("Finding the tenant key needs an admin access token. Pass it with --tenant: it is the tenant= of a platform URL.")
}
if err != nil {
return "", platform.Failed(err, "Failed to find the tenant key")
}
if summary.TenantKey == "" {
return "", errors.New("The platform did not name the tenant. Pass its key with --tenant: it is the tenant= of a platform URL.")
}
return summary.TenantKey, nil
}

// check fetches the badge as a README would show it, without the token, so that a
// wrong tenant key fails here and not as a broken image.
func check(ctx context.Context, c *platform.Client, path, tenant string) error {
_, resp, err := platform.Read(c.GetAnonymously(ctx, path))
var apiErr *platform.APIError
if errors.As(err, &apiErr) && apiErr.Status == http.StatusBadRequest && strings.HasSuffix(apiErr.ProblemType(), "/missing-tenant-exception") {
return fmt.Errorf("Tenant %s not found.", tenant)
}
if err != nil {
return platform.Failed(err, "Failed to get the badge")
}
if kind := resp.Header.Get("Content-Type"); !strings.HasPrefix(kind, "image/svg+xml") {
return fmt.Errorf("The platform sent %s instead of a badge image.", kind)
}
return nil
}
137 changes: 137 additions & 0 deletions internal/badge/badge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Steadybit GmbH

package badge_test

import (
"context"
"net/http"
"strings"
"testing"

"github.com/steadybit/cli/v6/internal/badge"
"github.com/steadybit/cli/v6/internal/output"
"github.com/steadybit/cli/v6/internal/platformtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var ctx = context.Background()

var svg = platformtest.Reply{Body: "<svg/>", Headers: map[string]string{"Content-Type": "image/svg+xml;charset=UTF-8"}}

func platformWithExperiment(t *testing.T) *platformtest.Platform {
p := platformtest.New(t)
p.Reply("GET /api/license", platformtest.Reply{JSON: map[string]any{"tenantKey": "demo"}})
p.Reply("GET /api/experiments/ADM-1", platformtest.Reply{JSON: map[string]any{"key": "ADM-1", "name": "Shop", "team": "ADM"}})
p.Reply("GET /api/experiments/ADM-1/badge.svg", svg)
return p
}

func TestPrintsTheBadgeOfAnExperimentAsMarkdown(t *testing.T) {
p := platformWithExperiment(t)

out, err := platformtest.Stdout(t, func() error { return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1"}) })

require.NoError(t, err)
assert.Equal(t, "[![ADM-1]("+p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=demo)]("+p.URL+"/experiments/edit/ADM-1?team=ADM&tenant=demo)\n", out)
checked := p.Requests("GET /api/experiments/ADM-1/badge.svg")[0]
assert.Equal(t, []string{"demo"}, checked.Query["tenantKey"])
// Fetched as a README does: with the token, the platform ignores a wrong tenant key.
assert.Empty(t, checked.Header.Get("Authorization"))
assert.True(t, strings.HasPrefix(checked.Header.Get("User-Agent"), "steadybit@"))
assert.NotEmpty(t, p.Requests("GET /api/experiments/ADM-1")[0].Header.Get("Authorization"))
}

func TestPrintsTheBadgeAsHTMLOrURL(t *testing.T) {
p := platformWithExperiment(t)

html, err := platformtest.Stdout(t, func() error {
return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Tenant: "demo", Scale: 2, Format: "html"})
})
require.NoError(t, err)
url, err := platformtest.Stdout(t, func() error { return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Format: "url"}) })
require.NoError(t, err)

assert.Equal(t, `<a href="`+p.URL+`/experiments/edit/ADM-1?team=ADM&amp;tenant=demo"><img alt="ADM-1" src="`+p.URL+`/api/experiments/ADM-1/badge.svg?scale=2&amp;tenantKey=demo"></a>`+"\n", html)
assert.Equal(t, p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=demo\n", url)
}

func TestRefusesATenantOtherThanTheTokens(t *testing.T) {
p := platformWithExperiment(t)

err := badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Tenant: "shop"})

// The badge of another tenant is a 200 image saying "not found": only the license tells.
assert.EqualError(t, err, `The access token belongs to tenant demo, not shop: the badge would show "not found". Leave out --tenant, or use an access token of tenant shop.`)
assert.Empty(t, p.Requests("GET /api/experiments/ADM-1/badge.svg"))
}

func TestTakesAGivenTenantWhenTheLicenseCannotBeRead(t *testing.T) {
p := platformWithExperiment(t)
p.Reply("GET /api/license", platformtest.Reply{Status: http.StatusForbidden})

out, err := platformtest.Stdout(t, func() error {
return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Tenant: "shop", Format: "url"})
})

require.NoError(t, err)
assert.Equal(t, p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=shop\n", out)
}

func TestPrintsTheBadgeOfATag(t *testing.T) {
p := platformtest.New(t)
p.Reply("GET /api/license", platformtest.Reply{JSON: map[string]any{"tenantKey": "demo"}})
p.Reply("GET /api/badges/linked-badge.svg", svg)

out, err := platformtest.Stdout(t, func() error {
return badge.Print(ctx, p.Client, badge.Options{Tag: "INCIDENT-100", CreateCaption: "Create one (now)", Tenant: "demo"})
})

require.NoError(t, err)
assert.Equal(t, "[![INCIDENT-100]("+p.URL+"/api/badges/linked-badge.svg?createCaption=Create%20one%20%28now%29&tag=INCIDENT-100&tenantKey=demo)]("+
p.URL+"/api/badges/link?tag=INCIDENT-100&tenantKey=demo)\n", out)
assert.Equal(t, []string{"Create one (now)"}, p.Requests("GET /api/badges/linked-badge.svg")[0].Query["createCaption"])
}

func TestPrintsTheBadgeAsJSON(t *testing.T) {
p := platformWithExperiment(t)
output.JQ = ".image"
t.Cleanup(func() { output.JQ = "" })

out, err := platformtest.Stdout(t, func() error { return badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1"}) })

require.NoError(t, err)
assert.Equal(t, p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=demo\n", out)
}

func TestReportsAWrongTenantOrExperiment(t *testing.T) {
p := platformtest.New(t)
p.Reply("GET /api/license", platformtest.Reply{Status: http.StatusForbidden})
p.Reply("GET /api/experiments/ADM-1", platformtest.Reply{JSON: map[string]any{"key": "ADM-1", "team": "ADM"}})
p.Reply("GET /api/experiments/ADM-2", platformtest.Reply{Status: http.StatusNotFound})
p.Reply("GET /api/experiments/ADM-1/badge.svg", platformtest.Reply{Status: http.StatusBadRequest,
Body: `{"type":"https://steadybit.com/problems/missing-tenant-exception","title":"A tenant must be set","status":400}`})

assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Tenant: "nosuch"}), "Tenant nosuch not found.")
assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-2", Tenant: "demo"}), "Experiment ADM-2 not found.")
}

func TestFindingTheTenantNeedsAnAdminToken(t *testing.T) {
p := platformtest.New(t)
p.Reply("GET /api/license", platformtest.Reply{Status: http.StatusForbidden})

assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1"}),
"Finding the tenant key needs an admin access token. Pass it with --tenant: it is the tenant= of a platform URL.")
}

func TestRefusals(t *testing.T) {
p := platformtest.New(t)

assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{}), "Either --key or --tag must be specified.")
assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", CreateCaption: "x"}), "--create-caption only applies to a badge for --tag.")
assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Format: "svg"}), `Unsupported badge format 'svg'. Use "markdown", "html" or "url".`)
assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Type: "xml"}), `unsupported output format 'xml'. Use "json" or "yaml"`)
assert.EqualError(t, badge.Print(ctx, p.Client, badge.Options{Key: "ADM-1", Type: "json", Format: "html"}), "--format cannot be combined with -t or --jq, which print every format.")
// Refused before any request: the platform has no route to answer.
}
Loading
Loading