diff --git a/CHANGELOG.md b/CHANGELOG.md index caa30f3..16d2970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 34bc418..833c38e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ``` @@ -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 ``` diff --git a/internal/badge/badge.go b/internal/badge/badge.go new file mode 100644 index 0000000..20c5bf0 --- /dev/null +++ b/internal/badge/badge.go @@ -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(`%s`, 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 +} diff --git a/internal/badge/badge_test.go b/internal/badge/badge_test.go new file mode 100644 index 0000000..55c9875 --- /dev/null +++ b/internal/badge/badge_test.go @@ -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: "", 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, `ADM-1`+"\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. +} diff --git a/internal/cli/badge.go b/internal/cli/badge.go new file mode 100644 index 0000000..d07d468 --- /dev/null +++ b/internal/cli/badge.go @@ -0,0 +1,50 @@ +// 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. 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", + "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", "", `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/cli/experiment.go b/internal/cli/experiment.go index 2ca91f7..4ed02de 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/cli/license.go b/internal/cli/license.go new file mode 100644 index 0000000..56d92a4 --- /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, 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/cli/root.go b/internal/cli/root.go index 571f4fe..5eea296 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -75,7 +75,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/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/license/license.go b/internal/license/license.go new file mode 100644 index 0000000..1a3e39e --- /dev/null +++ b/internal/license/license.go @@ -0,0 +1,234 @@ +// 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 { + 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 + } + 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.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 }) + // 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 := limitOf(f) + 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 +} + +// 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:]) +} + +// 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: + days := int(left.Hours()/24) + 1 + if days == 1 { + return ", expires in 1 day" + } + return fmt.Sprintf(", expires in %d days", days) + } + 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. 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) + 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, flags := o.Output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC + if file == "" { + 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 + } + } + 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 +// 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 { + return "license-report.zip" + } + name := strings.TrimLeft(output.PathSegment(strings.ReplaceAll(params["filename"], `\`, "/")), ".") + if name == "" || name == "_" { + return "license-report.zip" + } + return name +} diff --git a/internal/license/license_test.go b/internal/license/license_test.go new file mode 100644 index 0000000..54a14ef --- /dev/null +++ b/internal/license/license_test.go @@ -0,0 +1,230 @@ +// 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") +} + +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) + }) + } +} diff --git a/internal/platform/client.go b/internal/platform/client.go index 8c910a9..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 } @@ -99,6 +99,29 @@ 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 := c.newRequest(ctx, http.MethodGet, path) + if err != nil { + return nil, err + } + 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) { @@ -124,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)) diff --git a/internal/target/stats.go b/internal/target/stats.go new file mode 100644 index 0000000..e1652f5 --- /dev/null +++ b/internal/target/stats.go @@ -0,0 +1,67 @@ +// 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 { + if _, err := output.ResolveDatatype(o.Type, ""); err != nil { + return err + } + 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..87aeff3 --- /dev/null +++ b/internal/target/stats_test.go @@ -0,0 +1,88 @@ +// 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") +} + +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"`) +}