From 9da88b9def740f84ee3232ab8e542366567702e0 Mon Sep 17 00:00:00 2001
From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.>
Date: Tue, 29 Sep 2026 11:47:52 +0200
Subject: [PATCH 1/6] feat: target stats counts the targets of each type
How many targets of each type the platform knows was only visible in the
UI's landscape. `target stats` prints the counts of the tenant, sorted by
type, or with -q only of the targets matching a target query, which is the
quick way to see what a query would hit before putting it into an
experiment or an environment. -t json|yaml and --jq get the platform's
object as it is.
The endpoint counts over the whole tenant and takes no environment, so
unlike `target query` there is no -e.
---
CHANGELOG.md | 6 +++
README.md | 1 +
internal/cli/target.go | 16 ++++++-
internal/target/stats.go | 64 ++++++++++++++++++++++++++++
internal/target/stats_test.go | 79 +++++++++++++++++++++++++++++++++++
5 files changed, 165 insertions(+), 1 deletion(-)
create mode 100644 internal/target/stats.go
create mode 100644 internal/target/stats_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2723f4..0f28aae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## v6.1.0
+
+- `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.
+
## v6.0.1
- `experiment apply` and `experiment run` no longer send a `version` from the file. The
diff --git a/README.md b/README.md
index 91871d1..813abd4 100644
--- a/README.md
+++ b/README.md
@@ -240,6 +240,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
```
diff --git a/internal/cli/target.go b/internal/cli/target.go
index 33270eb..097e353 100644
--- a/internal/cli/target.go
+++ b/internal/cli/target.go
@@ -75,7 +75,21 @@ func newTarget() *cobra.Command {
_ = values.MarkFlagRequired("key")
attribute.AddCommand(keys, values)
- cmd.AddCommand(query, attribute)
+ var s target.StatsOptions
+ stats := &cobra.Command{
+ Use: "stats",
+ Short: "Count the targets of each type in the tenant, optionally only those matching a query.",
+ Args: cobra.NoArgs,
+ Example: examples(
+ "steadybit target stats",
+ `steadybit target stats -q 'k8s.namespace="shop"' -t json`,
+ ),
+ RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return target.Stats(ctx, c, s) }),
+ }
+ stats.Flags().StringVarP(&s.Query, "query", "q", "", "Only count targets matching this target query.")
+ stats.Flags().StringVarP(&s.Type, "type", "t", "", `Print the counts by target type as "json" or "yaml" instead of a table.`)
+
+ cmd.AddCommand(query, attribute, stats)
return cmd
}
diff --git a/internal/target/stats.go b/internal/target/stats.go
new file mode 100644
index 0000000..2ec722a
--- /dev/null
+++ b/internal/target/stats.go
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package target
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sort"
+
+ "github.com/steadybit/cli/v6/api"
+ "github.com/steadybit/cli/v6/internal/jsyaml"
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platform"
+ "github.com/steadybit/cli/v6/internal/resource"
+ "github.com/steadybit/cli/v6/internal/table"
+)
+
+type StatsOptions struct {
+ Query string
+ Type string
+}
+
+// Stats prints how many targets of each type the platform knows. The platform counts
+// over the whole tenant: it takes a query but no environment.
+func Stats(ctx context.Context, c *platform.Client, o StatsOptions) error {
+ var resp *http.Response
+ var err error
+ if o.Query == "" {
+ resp, err = c.GetTargetsStats(ctx)
+ } else {
+ resp, err = c.GetTargetsStats1(ctx, api.TargetStatsRequest{Query: &o.Query})
+ }
+ body, _, err := platform.Read(resp, err)
+ if err != nil {
+ return platform.Failed(err, "Failed to get the target statistics")
+ }
+ if resource.Machine(o.Type) {
+ return resource.PrintJSONValue(body, o.Type)
+ }
+ value, err := output.ParseValue(body)
+ if err != nil {
+ return err
+ }
+ counts, _ := value.(*jsyaml.Map)
+ if counts == nil || counts.Len() == 0 {
+ fmt.Println("No targets found.")
+ return nil
+ }
+ // The platform sends the types in no particular order.
+ types := counts.Keys()
+ sort.Strings(types)
+ t := table.New(
+ table.Column{Name: "type", Title: "Target type", Alignment: table.Left},
+ table.Column{Name: "count", Title: "Targets", Alignment: table.Right},
+ )
+ for _, kind := range types {
+ count, _ := counts.Get(kind)
+ t.AddRow(table.Default, table.Cell("type", kind), table.Cell("count", count))
+ }
+ t.Print()
+ return nil
+}
diff --git a/internal/target/stats_test.go b/internal/target/stats_test.go
new file mode 100644
index 0000000..89337d3
--- /dev/null
+++ b/internal/target/stats_test.go
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package target_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platformtest"
+ "github.com/steadybit/cli/v6/internal/target"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const stats = `{"com.steadybit.extension_kubernetes.kubernetes-pod":11108,"com.steadybit.extension_aws.zone":12,"com.steadybit.extension_host.host":25}`
+
+func TestStatsCountsEveryTypeInOrder(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/target-stats", platformtest.Reply{Body: stats})
+
+ out, err := platformtest.Stdout(t, func() error { return target.Stats(ctx, p.Client, target.StatsOptions{}) })
+
+ require.NoError(t, err)
+ assert.Contains(t, out, "│ com.steadybit.extension_aws.zone │ 12 │\n"+
+ "│ com.steadybit.extension_host.host │ 25 │\n"+
+ "│ com.steadybit.extension_kubernetes.kubernetes-pod │ 11108 │")
+}
+
+func TestStatsOfAQuery(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("POST /api/target-stats", platformtest.Reply{Body: `{"com.steadybit.extension_host.host":3}`})
+
+ out, err := platformtest.Stdout(t, func() error {
+ return target.Stats(ctx, p.Client, target.StatsOptions{Query: `k8s.namespace="shop"`, Type: "yaml"})
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "com.steadybit.extension_host.host: 3\n", out)
+ assert.Equal(t, map[string]any{"query": `k8s.namespace="shop"`}, p.Requests("POST /api/target-stats")[0].JSON(t))
+}
+
+func TestStatsPrintsThePlatformsValue(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/target-stats", platformtest.Reply{Body: stats})
+
+ json, err := platformtest.Stdout(t, func() error { return target.Stats(ctx, p.Client, target.StatsOptions{Type: "json"}) })
+ require.NoError(t, err)
+ output.JQ = `.["com.steadybit.extension_host.host"]`
+ t.Cleanup(func() { output.JQ = "" })
+ jq, err := platformtest.Stdout(t, func() error { return target.Stats(ctx, p.Client, target.StatsOptions{}) })
+ require.NoError(t, err)
+
+ assert.Equal(t, "{\n \"com.steadybit.extension_kubernetes.kubernetes-pod\": 11108,\n \"com.steadybit.extension_aws.zone\": 12,\n \"com.steadybit.extension_host.host\": 25\n}\n", json)
+ assert.Equal(t, "25\n", jq)
+}
+
+func TestStatsSaysWhenNothingMatches(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("POST /api/target-stats", platformtest.Reply{Body: `{}`})
+
+ out, err := platformtest.Stdout(t, func() error { return target.Stats(ctx, p.Client, target.StatsOptions{Query: `k8s.namespace="none"`}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "No targets found.\n", out)
+}
+
+func TestStatsReportsAnInvalidQuery(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("POST /api/target-stats", platformtest.Reply{Status: http.StatusUnprocessableEntity,
+ Body: `{"title":"Constraint Violation","status":422,"violations":[{"field":"query","message":"Failed to parse query"}]}`})
+
+ err := target.Stats(ctx, p.Client, target.StatsOptions{Query: "k8s.namespace="})
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Failed to get the target statistics: ")
+ assert.Contains(t, err.Error(), "Failed to parse query")
+}
From aa06fc6a3783258587bcb9012a41e02597bb45ed Mon Sep 17 00:00:00 2001
From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.>
Date: Tue, 29 Sep 2026 11:49:45 +0200
Subject: [PATCH 2/6] feat: license show and license report
An admin had to open the UI to see when the license expires and how close
the tenant is to a limit such as services or environments. `license show`
prints the license, a warning when it expired or expires within 30 days,
the limited features with their usage, over-limit ones in red, and the
features that are simply included. -t json|yaml and --jq get the
platform's summary as it is.
The usage report is a zip archive holding one archive per license period
(the license as JSON, the usage as CSV), not a value -t could print, so it
is its own command, `license report`, which writes it like `execution
artifact download` writes artifacts: under the name the platform gives it,
reduced to one path segment, or to -o. Both need an admin access token,
and say so on 403 as `audit-log` does.
---
CHANGELOG.md | 3 +
README.md | 1 +
internal/cli/license.go | 39 +++++++
internal/cli/root.go | 2 +-
internal/license/license.go | 178 +++++++++++++++++++++++++++++++
internal/license/license_test.go | 137 ++++++++++++++++++++++++
6 files changed, 359 insertions(+), 1 deletion(-)
create mode 100644 internal/cli/license.go
create mode 100644 internal/license/license.go
create mode 100644 internal/license/license_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f28aae..3f97c95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,9 @@
- `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. Both need an admin access token.
## v6.0.1
diff --git a/README.md b/README.md
index 813abd4..afd45a4 100644
--- a/README.md
+++ b/README.md
@@ -229,6 +229,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
```
diff --git a/internal/cli/license.go b/internal/cli/license.go
new file mode 100644
index 0000000..e8ab879
--- /dev/null
+++ b/internal/cli/license.go
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package cli
+
+import (
+ "context"
+
+ "github.com/spf13/cobra"
+ "github.com/steadybit/cli/v6/internal/license"
+ "github.com/steadybit/cli/v6/internal/platform"
+)
+
+func newLicense() *cobra.Command {
+ cmd := &cobra.Command{Use: "license", Short: "Show the license of the tenant and what of it is used. Needs an admin access token."}
+
+ var s license.ShowOptions
+ show := &cobra.Command{
+ Use: "show",
+ Short: "Show the license, when it expires, and how much of each limit is used.",
+ Args: cobra.NoArgs,
+ Example: examples("steadybit license show", "steadybit license show --jq '.expires'"),
+ RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return license.Show(ctx, c, s) }),
+ }
+ show.Flags().StringVarP(&s.Type, "type", "t", "", `Print the license summary as "json" or "yaml" instead of text.`)
+
+ var r license.ReportOptions
+ report := &cobra.Command{
+ Use: "report",
+ Short: "Download the license usage report, a zip archive with the usage of each license period.",
+ Args: cobra.NoArgs,
+ Example: examples("steadybit license report", "steadybit license report -o usage.zip"),
+ RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return license.Report(ctx, c, r) }),
+ }
+ report.Flags().StringVarP(&r.Output, "output", "o", "", "Write the report to this file. (default: the name the platform gives it, in the current directory)")
+
+ cmd.AddCommand(show, report)
+ return cmd
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index d03f7bf..bd43518 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -73,7 +73,7 @@ func newRoot() *cobra.Command {
root.PersistentFlags().StringVar(&output.JQ, "jq", "", "Filter the JSON a command prints with a jq expression; strings are printed raw.")
root.Flags().BoolP("version", "V", false, "output the version number")
root.SetVersionTemplate("{{.Version}}\n")
- root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newHub(), newIntegration(), newKillswitch(), newProperty(), newReport(), newSchedule(), newService(), newServiceProfile(), newTarget(), newTeam(), newTemplate(), newUser(),
+ root.AddCommand(newAccessToken(), newAction(), newAdvice(), newAuditLog(), newConfig(), newEnvironment(), newExecution(), newExperiment(), newHub(), newIntegration(), newKillswitch(), newLicense(), 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()
diff --git a/internal/license/license.go b/internal/license/license.go
new file mode 100644
index 0000000..d4f8689
--- /dev/null
+++ b/internal/license/license.go
@@ -0,0 +1,178 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+// Package license implements the `license` commands.
+package license
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "mime"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/steadybit/cli/v6/internal/output"
+ "github.com/steadybit/cli/v6/internal/platform"
+ "github.com/steadybit/cli/v6/internal/resource"
+ "github.com/steadybit/cli/v6/internal/table"
+)
+
+var errNotAdmin = errors.New("The license needs an admin access token.")
+
+type ShowOptions struct {
+ Type string
+}
+
+type summary struct {
+ License *struct {
+ LicenseType string `json:"licenseType"`
+ OrderNumber string `json:"orderNumber"`
+ ValidFrom string `json:"validFrom"`
+ ValidTo string `json:"validTo"`
+ } `json:"license"`
+ Expires *time.Time `json:"expires"`
+ TenantKey string `json:"tenantKey"`
+ Features []feature `json:"features"`
+}
+
+type feature struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Usage *int64 `json:"usage"`
+ SoftLimit *int64 `json:"softLimit"`
+ HardLimit *int64 `json:"hardLimit"`
+}
+
+// Show prints the license of the tenant and how much of each limit is used.
+func Show(ctx context.Context, c *platform.Client, o ShowOptions) error {
+ body, _, err := platform.Read(c.GetLicenseSummary(ctx))
+ if platform.IsStatus(err, http.StatusForbidden) {
+ return errNotAdmin
+ }
+ if err != nil {
+ return platform.Failed(err, "Failed to get the license")
+ }
+ if resource.Machine(o.Type) {
+ return resource.PrintJSONValue(body, o.Type)
+ }
+ var s summary
+ if err := json.Unmarshal(body, &s); err != nil {
+ return fmt.Errorf("Failed to read the license: %w", err)
+ }
+ if s.License == nil || s.License.LicenseType == "" || s.License.LicenseType == "NONE" {
+ fmt.Println("The tenant has no license.")
+ return nil
+ }
+ fmt.Printf("%s license %s of tenant %s, valid from %s to %s%s.\n", title(s.License.LicenseType), s.License.OrderNumber, s.TenantKey,
+ s.License.ValidFrom, s.License.ValidTo, expiry(s.Expires, time.Now()))
+
+ // The platform sends the features in no particular order.
+ sort.Slice(s.Features, func(i, j int) bool { return s.Features[i].Name < s.Features[j].Name })
+ // Features without a limit are only on or off; they make a list, not table rows.
+ var included []string
+ t := table.New(
+ table.Column{Name: "feature", Title: "Limit", Alignment: table.Left},
+ table.Column{Name: "used", Title: "Used", Alignment: table.Right},
+ table.Column{Name: "limit", Title: "Licensed", Alignment: table.Right},
+ )
+ limited := false
+ for _, f := range s.Features {
+ if f.Type == "SIMPLE" {
+ included = append(included, f.Name)
+ continue
+ }
+ limited = true
+ limit, color := "unlimited", table.Default
+ switch {
+ case f.HardLimit != nil:
+ limit = fmt.Sprint(*f.HardLimit)
+ if f.Usage != nil && *f.Usage > *f.HardLimit {
+ color = table.Red
+ }
+ case f.SoftLimit != nil:
+ limit = fmt.Sprintf("%d (soft)", *f.SoftLimit)
+ if f.Usage != nil && *f.Usage > *f.SoftLimit {
+ color = table.Red
+ }
+ }
+ used := ""
+ if f.Usage != nil {
+ used = fmt.Sprint(*f.Usage)
+ }
+ t.AddRow(color, table.Cell("feature", f.Name), table.Cell("used", used), table.Cell("limit", limit))
+ }
+ if limited {
+ t.Print()
+ }
+ if len(included) > 0 {
+ fmt.Printf("Included: %s\n", strings.Join(included, ", "))
+ }
+ return nil
+}
+
+func title(licenseType string) string {
+ return strings.ToUpper(licenseType[:1]) + strings.ToLower(licenseType[1:])
+}
+
+// expiry warns of a license that has run out or is about to: a pipeline's runs stop with it.
+func expiry(expires *time.Time, now time.Time) string {
+ if expires == nil {
+ return ""
+ }
+ left := expires.Sub(now)
+ switch {
+ case left <= 0:
+ return ", expired"
+ case left < 30*24*time.Hour:
+ return fmt.Sprintf(", expires in %d days", int(left.Hours()/24)+1)
+ }
+ return ""
+}
+
+type ReportOptions struct {
+ Output string
+}
+
+// Report downloads the license usage report, a zip archive of the tenant's usage over
+// each license period, as the platform names it unless an output file is given.
+func Report(ctx context.Context, c *platform.Client, o ReportOptions) error {
+ // The report covers every license period; building it takes longer than an API response.
+ ctx = platform.WithTimeout(ctx, 5*time.Minute)
+ content, resp, err := platform.Read(c.GetReport(ctx))
+ if platform.IsStatus(err, http.StatusForbidden) {
+ return errNotAdmin
+ }
+ if err != nil {
+ return platform.Failed(err, "Failed to download the license report")
+ }
+ file := o.Output
+ if file == "" {
+ file = fileName(resp.Header.Get("Content-Disposition"))
+ }
+ if dir := filepath.Dir(file); dir != "." {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ }
+ if err := os.WriteFile(file, content, 0o644); err != nil {
+ return err
+ }
+ fmt.Printf("License report written to %s.\n", file)
+ return nil
+}
+
+// fileName takes the name the platform gives the report, but only as one path segment:
+// the header must not decide where on disk the file goes.
+func fileName(disposition string) string {
+ _, params, err := mime.ParseMediaType(disposition)
+ if err != nil || params["filename"] == "" {
+ return "license-report.zip"
+ }
+ return output.PathSegment(strings.ReplaceAll(params["filename"], `\`, "/"))
+}
diff --git a/internal/license/license_test.go b/internal/license/license_test.go
new file mode 100644
index 0000000..6c05360
--- /dev/null
+++ b/internal/license/license_test.go
@@ -0,0 +1,137 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package license_test
+
+import (
+ "context"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/steadybit/cli/v6/internal/license"
+ "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()
+
+const summary = `{"license":{"id":1,"licenseType":"ENTERPRISE","orderNumber":"1234-2","validFrom":"2026-01-22","validTo":"2099-01-01"},
+ "expires":"2099-01-01T00:00:00Z","tenantKey":"demo","features":[
+ {"name":"TEMPLATES","type":"SIMPLE","usage":0},
+ {"name":"SERVICES","type":"HARD_LIMIT","usage":60,"hardLimit":50},
+ {"name":"AUDIT_LOG","type":"SIMPLE","usage":0},
+ {"name":"ENVIRONMENT_SIZE","type":"SOFT_LIMIT","usage":45,"softLimit":100},
+ {"name":"USER_SIZE","type":"HARD_LIMIT","usage":22}]}`
+
+func TestShowsTheLicenseAndItsLimits(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: summary})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, `Enterprise license 1234-2 of tenant demo, valid from 2026-01-22 to 2099-01-01.
+┌──────────────────┬──────┬────────────┐
+│ Limit │ Used │ Licensed │
+├──────────────────┼──────┼────────────┤
+│ ENVIRONMENT_SIZE │ 45 │ 100 (soft) │
+│ SERVICES │ 60 │ 50 │
+│ USER_SIZE │ 22 │ unlimited │
+└──────────────────┴──────┴────────────┘
+Included: AUDIT_LOG, TEMPLATES
+`, out)
+}
+
+func TestSaysWhenTheLicenseHasExpired(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"TRIAL","orderNumber":"7","validFrom":"2020-01-01","validTo":"2020-02-01"},"expires":"2020-02-01T00:00:00Z","tenantKey":"demo","features":[]}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Trial license 7 of tenant demo, valid from 2020-01-01 to 2020-02-01, expired.\n", out)
+}
+
+func TestWarnsOfALicenseAboutToExpire(t *testing.T) {
+ p := platformtest.New(t)
+ expires := time.Now().Add(10 * 24 * time.Hour).UTC().Format(time.RFC3339)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"PROFESSIONAL","orderNumber":"8","validFrom":"2020-01-01","validTo":"` + expires[:10] + `"},"expires":"` + expires + `","tenantKey":"demo"}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Professional license 8 of tenant demo, valid from 2020-01-01 to "+expires[:10]+", expires in 10 days.\n", out)
+}
+
+func TestSaysWhenThereIsNoLicense(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":null,"tenantKey":"demo"}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "The tenant has no license.\n", out)
+}
+
+func TestPrintsTheLicenseAsThePlatformSendsIt(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"tenantKey":"demo","expires":"2099-01-01T00:00:00Z"}`})
+
+ yaml, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{Type: "yaml"}) })
+ require.NoError(t, err)
+ output.JQ = ".expires"
+ t.Cleanup(func() { output.JQ = "" })
+ jq, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+ require.NoError(t, err)
+
+ assert.Equal(t, "tenantKey: demo\nexpires: '2099-01-01T00:00:00Z'\n", yaml)
+ assert.Equal(t, "2099-01-01T00:00:00Z\n", jq)
+}
+
+func TestDownloadsTheReportUnderItsName(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license/report", platformtest.Reply{Body: "PK-zip", Headers: map[string]string{
+ "Content-Disposition": `attachment; filename="../license-usage-reports-demo.zip"`,
+ }})
+ t.Chdir(t.TempDir())
+
+ out, err := platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{}) })
+ require.NoError(t, err)
+ named, _ := os.ReadFile("license-usage-reports-demo.zip")
+ file := filepath.Join("reports", "usage.zip")
+ _, err = platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{Output: file}) })
+ require.NoError(t, err)
+ given, _ := os.ReadFile(file)
+
+ assert.Equal(t, "License report written to license-usage-reports-demo.zip.\n", out)
+ assert.Equal(t, "PK-zip", string(named))
+ assert.Equal(t, "PK-zip", string(given))
+}
+
+func TestDownloadsTheReportWithoutAName(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license/report", platformtest.Reply{Body: "PK-zip"})
+ t.Chdir(t.TempDir())
+
+ out, err := platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "License report written to license-report.zip.\n", out)
+ assert.FileExists(t, "license-report.zip")
+}
+
+func TestNeedsAnAdminToken(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Status: http.StatusForbidden})
+ p.Reply("GET /api/license/report", platformtest.Reply{Status: http.StatusForbidden})
+ t.Chdir(t.TempDir())
+
+ assert.EqualError(t, license.Show(ctx, p.Client, license.ShowOptions{}), "The license needs an admin access token.")
+ assert.EqualError(t, license.Report(ctx, p.Client, license.ReportOptions{}), "The license needs an admin access token.")
+ assert.NoFileExists(t, "license-report.zip")
+}
From 0d29701f788a93b807e4db1cf9425e3a6c23d5e3 Mon Sep 17 00:00:00 2001
From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.>
Date: Tue, 29 Sep 2026 11:53:01 +0200
Subject: [PATCH 3/6] feat: experiment badge prints a README status badge
A status badge in a README shows whether an experiment's latest run
passed, but building its URL meant finding the badge dialog in the UI or
reading the docs for the tenantKey parameter. `experiment badge -k KEY`
prints the snippet: Markdown by default, --format html or url, --scale for
the image size; --tag prints the badge of a tag, which invites to create
the experiment while none has the tag. -t json|yaml and --jq get the
image URL, link and both snippets.
The badge endpoints are public and take only the tenant key, so nothing
secret ends up in a README; the help says what the badge shows to anyone
who knows that key. The access token does not name its tenant, and the
license summary is the only response that does, so the key is read from
it with an admin token, and --tenant gives it otherwise.
The platform answers a badge request made with a token for the token's
tenant, whatever tenantKey says, and a missing experiment's badge is an
image saying "not found" with 200. So the experiment is looked up first,
and the badge is fetched once without the token, as a README would load
it: a wrong tenant key fails the command instead of rendering as a broken
image.
---
CHANGELOG.md | 7 ++
README.md | 7 ++
internal/badge/badge.go | 156 +++++++++++++++++++++++++++++++++++
internal/badge/badge_test.go | 110 ++++++++++++++++++++++++
internal/cli/badge.go | 46 +++++++++++
internal/cli/experiment.go | 2 +-
internal/platform/client.go | 12 +++
7 files changed, 339 insertions(+), 1 deletion(-)
create mode 100644 internal/badge/badge.go
create mode 100644 internal/badge/badge_test.go
create mode 100644 internal/cli/badge.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f97c95..165a5d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
## v6.1.0
+- `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. The badge is fetched once without the token, so a wrong tenant
+ key fails the command rather than showing as a broken image.
- `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.
diff --git a/README.md b/README.md
index afd45a4..4a0a805 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,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
diff --git a/internal/badge/badge.go b/internal/badge/badge.go
new file mode 100644
index 0000000..b0e4ad2
--- /dev/null
+++ b/internal/badge/badge.go
@@ -0,0 +1,156 @@
+// 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/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.")
+ }
+ format := o.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)", markdownText(alt), b.Image, b.Link)
+ b.HTML = fmt.Sprintf(``, 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.
+func tenantKey(ctx context.Context, c *platform.Client, given string) (string, error) {
+ if given != "" {
+ return given, nil
+ }
+ var summary struct {
+ TenantKey string `json:"tenantKey"`
+ }
+ resp, err := c.GetLicenseSummary(ctx)
+ _, err = platform.Decode(resp, err, &summary)
+ 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
+}
diff --git a/internal/badge/badge_test.go b/internal/badge/badge_test.go
new file mode 100644
index 0000000..e0f48ac
--- /dev/null
+++ b/internal/badge/badge_test.go
@@ -0,0 +1,110 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package badge_test
+
+import (
+ "context"
+ "net/http"
+ "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: "", 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, "[]("+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.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: "shop", 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, ``+"\n", html)
+ assert.Equal(t, p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=demo\n", url)
+ // A given tenant key is taken as it is; only the other run reads the license.
+ assert.Len(t, p.Requests("GET /api/license"), 1)
+}
+
+func TestPrintsTheBadgeOfATag(t *testing.T) {
+ p := platformtest.New(t)
+ 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, "[]("+
+ 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/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".`)
+}
diff --git a/internal/cli/badge.go b/internal/cli/badge.go
new file mode 100644
index 0000000..6dbb8c1
--- /dev/null
+++ b/internal/cli/badge.go
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package cli
+
+import (
+ "context"
+
+ "github.com/spf13/cobra"
+ "github.com/steadybit/cli/v6/internal/badge"
+ "github.com/steadybit/cli/v6/internal/platform"
+)
+
+func newExperimentBadge() *cobra.Command {
+ var o badge.Options
+ cmd := &cobra.Command{
+ Use: "badge",
+ Short: "Print a status badge of an experiment to paste into a README: its latest run's state, linking to the platform.",
+ Long: `Print a status badge of an experiment to paste into a README: its latest run's state, linking to the platform.
+
+With --tag, such as an incident id, the badge shows the latest run of the experiment
+having the tag, or, while there is none, invites to create one with the tag.
+
+The badge URL carries no access token: anyone who knows the tenant key can load it,
+and it shows the experiment key and the state of its latest run. The link opens the
+platform, which asks to log in. Finding the tenant key needs an admin access token;
+with any other, pass it with --tenant.`,
+ Args: cobra.NoArgs,
+ Example: examples(
+ "steadybit experiment badge -k ADM-1",
+ "steadybit experiment badge -k ADM-1 --format html --scale 2",
+ `steadybit experiment badge --tag INCIDENT-100 --create-caption "Create experiment for incident 100" --tenant demo`,
+ ),
+ RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return badge.Print(ctx, c, o) }),
+ }
+ f := cmd.Flags()
+ f.StringVarP(&o.Key, "key", "k", "", "The experiment key.")
+ f.StringVar(&o.Tag, "tag", "", "Instead of --key: the tag of the experiments the badge is for.")
+ f.StringVar(&o.CreateCaption, "create-caption", "", "With --tag: the caption shown while no experiment has the tag. (default: the platform's, \"Create experiment\")")
+ f.StringVar(&o.Tenant, "tenant", "", "The tenant key, the tenant= of a platform URL. (default: read from the license)")
+ f.IntVar(&o.Scale, "scale", 0, "Scale the badge image by this factor. (default: the platform's, 1)")
+ f.StringVar(&o.Format, "format", "markdown", `Print the badge as "markdown", "html", or only the image "url".`)
+ f.StringVarP(&o.Type, "type", "t", "", `Print the image URL, link and snippets as "json" or "yaml" instead.`)
+ cmd.MarkFlagsMutuallyExclusive("key", "tag")
+ return cmd
+}
diff --git a/internal/cli/experiment.go b/internal/cli/experiment.go
index 13ed70f..4542eeb 100644
--- a/internal/cli/experiment.go
+++ b/internal/cli/experiment.go
@@ -18,7 +18,7 @@ import (
func newExperiment() *cobra.Command {
cmd := &cobra.Command{Use: "experiment", Short: "Check and run experiments."}
- cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDelete(), newExperimentDump(), newExperimentInit(),
+ cmd.AddCommand(newExperimentRun(), newExperimentGet(), newExperimentApply(), newExperimentDelete(), newExperimentDump(), newExperimentInit(), newExperimentBadge(),
newDiff(gitops.Experiment, "experiment", "experiment.yml"))
return cmd
}
diff --git a/internal/platform/client.go b/internal/platform/client.go
index 8c910a9..edb566c 100644
--- a/internal/platform/client.go
+++ b/internal/platform/client.go
@@ -99,6 +99,18 @@ func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) {
return c.http.Do(req)
}
+// GetAnonymously fetches a path without the access token, as a README showing a badge
+// does: with the token, the platform takes the tenant from it and ignores a wrong one
+// in the URL.
+func (c *Client) GetAnonymously(ctx context.Context, path string) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "steadybit@"+CurrentVersion())
+ return c.http.Do(req)
+}
+
var Verbose bool
func New() (*Client, error) {
From f2375106539191a99b2f8237ee2294ec1573009d Mon Sep 17 00:00:00 2001
From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.>
Date: Tue, 29 Sep 2026 14:17:54 +0200
Subject: [PATCH 4/6] fix: GetAnonymously and Get build their request in one
place
Both built the request and set the User-Agent on their own, so a change to one could
miss the other. newRequest builds it; Get then adds the access token.
---
internal/platform/client.go | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/internal/platform/client.go b/internal/platform/client.go
index edb566c..a51dfb1 100644
--- a/internal/platform/client.go
+++ b/internal/platform/client.go
@@ -89,7 +89,7 @@ type Client struct {
// Get fetches a path the spec has no operation for, such as the Location of a run.
func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
+ req, err := c.newRequest(ctx, http.MethodGet, path)
if err != nil {
return nil, err
}
@@ -103,14 +103,25 @@ func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) {
// does: with the token, the platform takes the tenant from it and ignores a wrong one
// in the URL.
func (c *Client) GetAnonymously(ctx context.Context, path string) (*http.Response, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
+ req, err := c.newRequest(ctx, http.MethodGet, path)
if err != nil {
return nil, err
}
- req.Header.Set("User-Agent", "steadybit@"+CurrentVersion())
return c.http.Do(req)
}
+// newRequest is a request to a path of the platform, before any authorization.
+func (c *Client) newRequest(ctx context.Context, method, path string) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", userAgent())
+ return req, nil
+}
+
+func userAgent() string { return "steadybit@" + CurrentVersion() }
+
var Verbose bool
func New() (*Client, error) {
@@ -136,7 +147,7 @@ func New() (*Client, error) {
authorize := func(_ context.Context, req *http.Request) error {
req.Header.Set("Authorization", "accessToken "+cfg.APIAccessToken)
req.Header.Set("Accept", "application/json, */*")
- req.Header.Set("User-Agent", "steadybit@"+CurrentVersion())
+ req.Header.Set("User-Agent", userAgent())
return nil
}
client, err := api.NewClientWithResponses(cfg.BaseURL, api.WithHTTPClient(httpClient), api.WithRequestEditorFn(authorize))
From 094dacc0dc696d5113acfdfe81578dd49ead4a72 Mon Sep 17 00:00:00 2001
From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.>
Date: Tue, 29 Sep 2026 14:18:20 +0200
Subject: [PATCH 5/6] fix: experiment badge refuses a tenant other than the
token's, and checks -t first
The badge of another tenant's experiment is an image saying "not found", sent with 200,
so the anonymous check passed and the command printed a broken badge. When the license
names the token's tenant, a --tenant that differs is refused; without an admin token
the license cannot be read and the given key is taken as before.
A wrong -t was only reported by the printing, after up to three requests; it is now
checked first, in target stats too. -t prints every format at once, so --format is
refused with it instead of being ignored, and --format no longer defaults to markdown
in the flag itself, which would make it look given.
---
CHANGELOG.md | 6 ++++--
internal/badge/badge.go | 22 +++++++++++++++++++---
internal/badge/badge_test.go | 35 +++++++++++++++++++++++++++++++----
internal/cli/badge.go | 10 +++++++---
internal/target/stats.go | 3 +++
internal/target/stats_test.go | 9 +++++++++
6 files changed, 73 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 165a5d7..9aa71c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,8 +7,10 @@
`--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. The badge is fetched once without the token, so a wrong tenant
- key fails the command rather than showing as a broken image.
+ 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.
diff --git a/internal/badge/badge.go b/internal/badge/badge.go
index b0e4ad2..20c5bf0 100644
--- a/internal/badge/badge.go
+++ b/internal/badge/badge.go
@@ -16,6 +16,7 @@ import (
"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"
)
@@ -49,7 +50,14 @@ func Print(ctx context.Context, c *platform.Client, o Options) error {
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"
}
@@ -117,15 +125,23 @@ func markdownText(s string) string {
// 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) {
- if given != "" {
- return given, nil
- }
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.")
}
diff --git a/internal/badge/badge_test.go b/internal/badge/badge_test.go
index e0f48ac..55c9875 100644
--- a/internal/badge/badge_test.go
+++ b/internal/badge/badge_test.go
@@ -6,6 +6,7 @@ package badge_test
import (
"context"
"net/http"
+ "strings"
"testing"
"github.com/steadybit/cli/v6/internal/badge"
@@ -38,6 +39,7 @@ func TestPrintsTheBadgeOfAnExperimentAsMarkdown(t *testing.T) {
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"))
}
@@ -45,20 +47,41 @@ 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: "shop", Scale: 2, Format: "html"})
+ 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, ``+"\n", html)
+ assert.Equal(t, ``+"\n", html)
assert.Equal(t, p.URL+"/api/experiments/ADM-1/badge.svg?tenantKey=demo\n", url)
- // A given tenant key is taken as it is; only the other run reads the license.
- assert.Len(t, p.Requests("GET /api/license"), 1)
+}
+
+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 {
@@ -84,6 +107,7 @@ func TestPrintsTheBadgeAsJSON(t *testing.T) {
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,
@@ -107,4 +131,7 @@ func TestRefusals(t *testing.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.
}
diff --git a/internal/cli/badge.go b/internal/cli/badge.go
index 6dbb8c1..d07d468 100644
--- a/internal/cli/badge.go
+++ b/internal/cli/badge.go
@@ -24,7 +24,11 @@ having the tag, or, while there is none, invites to create one with the tag.
The badge URL carries no access token: anyone who knows the tenant key can load it,
and it shows the experiment key and the state of its latest run. The link opens the
platform, which asks to log in. Finding the tenant key needs an admin access token;
-with any other, pass it with --tenant.`,
+with any other, pass it with --tenant. With an admin access token, --tenant must be
+the token's own tenant.
+
+-t prints the image URL, the link and every snippet at once, so it does not combine
+with --format.`,
Args: cobra.NoArgs,
Example: examples(
"steadybit experiment badge -k ADM-1",
@@ -39,8 +43,8 @@ with any other, pass it with --tenant.`,
f.StringVar(&o.CreateCaption, "create-caption", "", "With --tag: the caption shown while no experiment has the tag. (default: the platform's, \"Create experiment\")")
f.StringVar(&o.Tenant, "tenant", "", "The tenant key, the tenant= of a platform URL. (default: read from the license)")
f.IntVar(&o.Scale, "scale", 0, "Scale the badge image by this factor. (default: the platform's, 1)")
- f.StringVar(&o.Format, "format", "markdown", `Print the badge as "markdown", "html", or only the image "url".`)
- f.StringVarP(&o.Type, "type", "t", "", `Print the image URL, link and snippets as "json" or "yaml" instead.`)
+ f.StringVar(&o.Format, "format", "", `Print the badge as "markdown", "html", or only the image "url". Not with -t or --jq. (default: markdown)`)
+ f.StringVarP(&o.Type, "type", "t", "", `Print the image URL, link and snippets as "json" or "yaml" instead. Not with --format.`)
cmd.MarkFlagsMutuallyExclusive("key", "tag")
return cmd
}
diff --git a/internal/target/stats.go b/internal/target/stats.go
index 2ec722a..e1652f5 100644
--- a/internal/target/stats.go
+++ b/internal/target/stats.go
@@ -25,6 +25,9 @@ type StatsOptions struct {
// Stats prints how many targets of each type the platform knows. The platform counts
// over the whole tenant: it takes a query but no environment.
func Stats(ctx context.Context, c *platform.Client, o StatsOptions) error {
+ if _, err := output.ResolveDatatype(o.Type, ""); err != nil {
+ return err
+ }
var resp *http.Response
var err error
if o.Query == "" {
diff --git a/internal/target/stats_test.go b/internal/target/stats_test.go
index 89337d3..87aeff3 100644
--- a/internal/target/stats_test.go
+++ b/internal/target/stats_test.go
@@ -77,3 +77,12 @@ func TestStatsReportsAnInvalidQuery(t *testing.T) {
assert.Contains(t, err.Error(), "Failed to get the target statistics: ")
assert.Contains(t, err.Error(), "Failed to parse query")
}
+
+func TestStatsRejectsAWrongTypeBeforeAnyRequest(t *testing.T) {
+ p := platformtest.New(t)
+
+ // No route: a request would fail the test.
+ err := target.Stats(ctx, p.Client, target.StatsOptions{Type: "xml"})
+
+ assert.EqualError(t, err, `unsupported output format 'xml'. Use "json" or "yaml"`)
+}
From 40773733c8ccc6c3595c016c1e5da087eaf73dad Mon Sep 17 00:00:00 2001
From: "antoine.choimet" <12182686+achoimet@users.noreply.github.com.>
Date: Tue, 29 Sep 2026 14:18:35 +0200
Subject: [PATCH 6/6] fix: license report never overwrites a file it named, and
license show reads right
license report wrote the name from Content-Disposition into the current directory and
replaced any file of that name, dotfiles included: the name is the platform's choice,
not the user's. It is now created only if it does not exist, a leading dot is dropped,
and a name reducing to nothing becomes license-report.zip. A file named with -o is
still overwritten, as asked.
license show left a double space for an empty order number and "of tenant ," for a
null tenant key; the sentence is built from the parts present. It said "1 days". The
limit shown was the first field present, so a soft-limit feature also sending a hard
limit showed the hard one; the feature's type now decides. Usage at a hard limit is
highlighted too, since nothing more can be added. A wrong -t is reported before the
request.
---
CHANGELOG.md | 3 +-
internal/cli/license.go | 2 +-
internal/license/license.go | 104 ++++++++++++++++++++++++-------
internal/license/license_test.go | 93 +++++++++++++++++++++++++++
internal/license/limit_test.go | 38 +++++++++++
5 files changed, 214 insertions(+), 26 deletions(-)
create mode 100644 internal/license/limit_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9aa71c4..f7c89d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,8 @@
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. Both need an admin access token.
+ 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
diff --git a/internal/cli/license.go b/internal/cli/license.go
index e8ab879..56d92a4 100644
--- a/internal/cli/license.go
+++ b/internal/cli/license.go
@@ -32,7 +32,7 @@ func newLicense() *cobra.Command {
Example: examples("steadybit license report", "steadybit license report -o usage.zip"),
RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return license.Report(ctx, c, r) }),
}
- report.Flags().StringVarP(&r.Output, "output", "o", "", "Write the report to this file. (default: the name the platform gives it, in the current directory)")
+ report.Flags().StringVarP(&r.Output, "output", "o", "", "Write the report to this file, overwriting it. (default: the name the platform gives it, in the current directory, which is never overwritten)")
cmd.AddCommand(show, report)
return cmd
diff --git a/internal/license/license.go b/internal/license/license.go
index d4f8689..1a3e39e 100644
--- a/internal/license/license.go
+++ b/internal/license/license.go
@@ -51,6 +51,9 @@ type feature struct {
// Show prints the license of the tenant and how much of each limit is used.
func Show(ctx context.Context, c *platform.Client, o ShowOptions) error {
+ if _, err := output.ResolveDatatype(o.Type, ""); err != nil {
+ return err
+ }
body, _, err := platform.Read(c.GetLicenseSummary(ctx))
if platform.IsStatus(err, http.StatusForbidden) {
return errNotAdmin
@@ -69,8 +72,7 @@ func Show(ctx context.Context, c *platform.Client, o ShowOptions) error {
fmt.Println("The tenant has no license.")
return nil
}
- fmt.Printf("%s license %s of tenant %s, valid from %s to %s%s.\n", title(s.License.LicenseType), s.License.OrderNumber, s.TenantKey,
- s.License.ValidFrom, s.License.ValidTo, expiry(s.Expires, time.Now()))
+ fmt.Println(sentence(s, time.Now()))
// The platform sends the features in no particular order.
sort.Slice(s.Features, func(i, j int) bool { return s.Features[i].Name < s.Features[j].Name })
@@ -88,19 +90,7 @@ func Show(ctx context.Context, c *platform.Client, o ShowOptions) error {
continue
}
limited = true
- limit, color := "unlimited", table.Default
- switch {
- case f.HardLimit != nil:
- limit = fmt.Sprint(*f.HardLimit)
- if f.Usage != nil && *f.Usage > *f.HardLimit {
- color = table.Red
- }
- case f.SoftLimit != nil:
- limit = fmt.Sprintf("%d (soft)", *f.SoftLimit)
- if f.Usage != nil && *f.Usage > *f.SoftLimit {
- color = table.Red
- }
- }
+ limit, color := limitOf(f)
used := ""
if f.Usage != nil {
used = fmt.Sprint(*f.Usage)
@@ -116,6 +106,51 @@ func Show(ctx context.Context, c *platform.Client, o ShowOptions) error {
return nil
}
+// sentence describes the license from the parts the platform sent: an order number or
+// a tenant key can be missing, and must not leave a gap or a dangling "of tenant".
+func sentence(s summary, now time.Time) string {
+ text := title(s.License.LicenseType) + " license"
+ if s.License.OrderNumber != "" {
+ text += " " + s.License.OrderNumber
+ }
+ if s.TenantKey != "" {
+ text += " of tenant " + s.TenantKey
+ }
+ switch from, to := s.License.ValidFrom, s.License.ValidTo; {
+ case from != "" && to != "":
+ text += ", valid from " + from + " to " + to
+ case from != "":
+ text += ", valid from " + from
+ case to != "":
+ text += ", valid to " + to
+ }
+ return text + expiry(s.Expires, now) + "."
+}
+
+// limitOf is the licensed amount of a feature and whether its usage is highlighted.
+// The feature's type says which limit applies; the platform can send the other field
+// too. A hard limit is highlighted once reached, since nothing more can be added; a
+// soft one only once exceeded.
+func limitOf(f feature) (string, table.Color) {
+ highlight := func(over bool) table.Color {
+ if over {
+ return table.Red
+ }
+ return table.Default
+ }
+ switch f.Type {
+ case "SOFT_LIMIT":
+ if f.SoftLimit != nil {
+ return fmt.Sprintf("%d (soft)", *f.SoftLimit), highlight(f.Usage != nil && *f.Usage > *f.SoftLimit)
+ }
+ case "HARD_LIMIT":
+ if f.HardLimit != nil {
+ return fmt.Sprint(*f.HardLimit), highlight(f.Usage != nil && *f.Usage >= *f.HardLimit)
+ }
+ }
+ return "unlimited", table.Default
+}
+
func title(licenseType string) string {
return strings.ToUpper(licenseType[:1]) + strings.ToLower(licenseType[1:])
}
@@ -130,7 +165,11 @@ func expiry(expires *time.Time, now time.Time) string {
case left <= 0:
return ", expired"
case left < 30*24*time.Hour:
- return fmt.Sprintf(", expires in %d days", int(left.Hours()/24)+1)
+ days := int(left.Hours()/24) + 1
+ if days == 1 {
+ return ", expires in 1 day"
+ }
+ return fmt.Sprintf(", expires in %d days", days)
}
return ""
}
@@ -140,7 +179,9 @@ type ReportOptions struct {
}
// Report downloads the license usage report, a zip archive of the tenant's usage over
-// each license period, as the platform names it unless an output file is given.
+// each license period, as the platform names it unless an output file is given. Only a
+// file given with -o is overwritten: the platform's name is not the user's choice, and
+// could be that of any file in the current directory.
func Report(ctx context.Context, c *platform.Client, o ReportOptions) error {
// The report covers every license period; building it takes longer than an API response.
ctx = platform.WithTimeout(ctx, 5*time.Minute)
@@ -151,28 +192,43 @@ func Report(ctx context.Context, c *platform.Client, o ReportOptions) error {
if err != nil {
return platform.Failed(err, "Failed to download the license report")
}
- file := o.Output
+ file, flags := o.Output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC
if file == "" {
- file = fileName(resp.Header.Get("Content-Disposition"))
+ file, flags = fileName(resp.Header.Get("Content-Disposition")), os.O_WRONLY|os.O_CREATE|os.O_EXCL
}
if dir := filepath.Dir(file); dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
- if err := os.WriteFile(file, content, 0o644); err != nil {
+ f, err := os.OpenFile(file, flags, 0o644)
+ if errors.Is(err, os.ErrExist) {
+ return fmt.Errorf("%s already exists. Pass -o %s to overwrite it, or -o another file.", file, file)
+ }
+ if err != nil {
+ return err
+ }
+ _, err = f.Write(content)
+ if closeErr := f.Close(); err == nil {
+ err = closeErr
+ }
+ if err != nil {
return err
}
fmt.Printf("License report written to %s.\n", file)
return nil
}
-// fileName takes the name the platform gives the report, but only as one path segment:
-// the header must not decide where on disk the file goes.
+// fileName takes the name the platform gives the report, but only as one path segment
+// and not as a hidden file: the header must not decide where on disk the file goes.
func fileName(disposition string) string {
_, params, err := mime.ParseMediaType(disposition)
- if err != nil || params["filename"] == "" {
+ if err != nil {
+ return "license-report.zip"
+ }
+ name := strings.TrimLeft(output.PathSegment(strings.ReplaceAll(params["filename"], `\`, "/")), ".")
+ if name == "" || name == "_" {
return "license-report.zip"
}
- return output.PathSegment(strings.ReplaceAll(params["filename"], `\`, "/"))
+ return name
}
diff --git a/internal/license/license_test.go b/internal/license/license_test.go
index 6c05360..54a14ef 100644
--- a/internal/license/license_test.go
+++ b/internal/license/license_test.go
@@ -135,3 +135,96 @@ func TestNeedsAnAdminToken(t *testing.T) {
assert.EqualError(t, license.Report(ctx, p.Client, license.ReportOptions{}), "The license needs an admin access token.")
assert.NoFileExists(t, "license-report.zip")
}
+
+func TestLeavesOutTheMissingPartsOfTheLicense(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"TRIAL","orderNumber":"","validFrom":"2026-01-01","validTo":"2099-01-01"},"tenantKey":null,"features":[]}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Trial license, valid from 2026-01-01 to 2099-01-01.\n", out)
+}
+
+func TestSaysOneDay(t *testing.T) {
+ p := platformtest.New(t)
+ expires := time.Now().Add(12 * time.Hour).UTC().Format(time.RFC3339)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"TRIAL","orderNumber":"8","validFrom":"2020-01-01","validTo":"` + expires[:10] + `"},"expires":"` + expires + `","tenantKey":"demo"}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "Trial license 8 of tenant demo, valid from 2020-01-01 to "+expires[:10]+", expires in 1 day.\n", out)
+}
+
+func TestTakesTheLimitTheTypeOfTheFeatureNames(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license", platformtest.Reply{Body: `{"license":{"licenseType":"ENTERPRISE","orderNumber":"1","validFrom":"2026-01-01","validTo":"2099-01-01"},"tenantKey":"demo","features":[
+ {"name":"HARD","type":"HARD_LIMIT","usage":5,"softLimit":3,"hardLimit":10},
+ {"name":"SOFT","type":"SOFT_LIMIT","usage":5,"softLimit":3,"hardLimit":10},
+ {"name":"UNLIMITED","type":"HARD_LIMIT","usage":5,"softLimit":3}]}`})
+
+ out, err := platformtest.Stdout(t, func() error { return license.Show(ctx, p.Client, license.ShowOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, `Enterprise license 1 of tenant demo, valid from 2026-01-01 to 2099-01-01.
+┌───────────┬──────┬───────────┐
+│ Limit │ Used │ Licensed │
+├───────────┼──────┼───────────┤
+│ HARD │ 5 │ 10 │
+│ SOFT │ 5 │ 3 (soft) │
+│ UNLIMITED │ 5 │ unlimited │
+└───────────┴──────┴───────────┘
+`, out)
+}
+
+func TestRejectsAWrongTypeBeforeAnyRequest(t *testing.T) {
+ p := platformtest.New(t)
+
+ assert.EqualError(t, license.Show(ctx, p.Client, license.ShowOptions{Type: "xml"}), `unsupported output format 'xml'. Use "json" or "yaml"`)
+ assert.Empty(t, p.Requests("GET /api/license"))
+}
+
+func TestNeverOverwritesAFileThePlatformNamed(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license/report", platformtest.Reply{Body: "PK-zip", Headers: map[string]string{
+ "Content-Disposition": `attachment; filename="usage.zip"`,
+ }})
+ t.Chdir(t.TempDir())
+ require.NoError(t, os.WriteFile("usage.zip", []byte("mine"), 0o644))
+
+ err := license.Report(ctx, p.Client, license.ReportOptions{})
+ assert.EqualError(t, err, "usage.zip already exists. Pass -o usage.zip to overwrite it, or -o another file.")
+ kept, _ := os.ReadFile("usage.zip")
+ assert.Equal(t, "mine", string(kept))
+
+ // Named with -o, the file is the user's choice.
+ _, err = platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{Output: "usage.zip"}) })
+ require.NoError(t, err)
+ written, _ := os.ReadFile("usage.zip")
+ assert.Equal(t, "PK-zip", string(written))
+}
+
+func TestNeverWritesAHiddenOrEmptyName(t *testing.T) {
+ for disposition, want := range map[string]string{
+ `attachment; filename=".bashrc"`: "bashrc",
+ `attachment; filename="../.x.zip"`: "x.zip",
+ `attachment; filename=".."`: "license-report.zip",
+ `attachment; filename="."`: "license-report.zip",
+ `attachment; filename="/"`: "license-report.zip",
+ `attachment; filename=""`: "license-report.zip",
+ `attachment; filename="reports/..."`: "license-report.zip",
+ } {
+ t.Run(disposition, func(t *testing.T) {
+ p := platformtest.New(t)
+ p.Reply("GET /api/license/report", platformtest.Reply{Body: "PK-zip", Headers: map[string]string{"Content-Disposition": disposition}})
+ t.Chdir(t.TempDir())
+
+ out, err := platformtest.Stdout(t, func() error { return license.Report(ctx, p.Client, license.ReportOptions{}) })
+
+ require.NoError(t, err)
+ assert.Equal(t, "License report written to "+want+".\n", out)
+ assert.FileExists(t, want)
+ })
+ }
+}
diff --git a/internal/license/limit_test.go b/internal/license/limit_test.go
new file mode 100644
index 0000000..e60896f
--- /dev/null
+++ b/internal/license/limit_test.go
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 2026 Steadybit GmbH
+
+package license
+
+import (
+ "testing"
+
+ "github.com/steadybit/cli/v6/internal/table"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHighlightsAHardLimitOnceReachedAndASoftOneOnceExceeded(t *testing.T) {
+ n := func(v int64) *int64 { return &v }
+ for _, c := range []struct {
+ name string
+ f feature
+ limit string
+ color table.Color
+ }{
+ {"below a hard limit", feature{Type: "HARD_LIMIT", Usage: n(9), HardLimit: n(10)}, "10", table.Default},
+ {"at a hard limit", feature{Type: "HARD_LIMIT", Usage: n(10), HardLimit: n(10)}, "10", table.Red},
+ {"over a hard limit", feature{Type: "HARD_LIMIT", Usage: n(11), HardLimit: n(10)}, "10", table.Red},
+ {"at a soft limit", feature{Type: "SOFT_LIMIT", Usage: n(10), SoftLimit: n(10)}, "10 (soft)", table.Default},
+ {"over a soft limit", feature{Type: "SOFT_LIMIT", Usage: n(11), SoftLimit: n(10)}, "10 (soft)", table.Red},
+ // The type decides, not which field is sent.
+ {"a soft limit also sending a hard one", feature{Type: "SOFT_LIMIT", Usage: n(5), SoftLimit: n(3), HardLimit: n(10)}, "3 (soft)", table.Red},
+ {"a hard limit also sending a soft one", feature{Type: "HARD_LIMIT", Usage: n(5), SoftLimit: n(3), HardLimit: n(10)}, "10", table.Default},
+ {"a hard limit without an amount", feature{Type: "HARD_LIMIT", Usage: n(5), SoftLimit: n(3)}, "unlimited", table.Default},
+ {"no usage", feature{Type: "HARD_LIMIT", HardLimit: n(0)}, "0", table.Default},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ limit, color := limitOf(c.f)
+ assert.Equal(t, c.limit, limit)
+ assert.Equal(t, c.color, color)
+ })
+ }
+}