From ff1290fbd4c402ca04d7df028154fb3a4538de6b Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Wed, 10 Jun 2026 12:00:17 +0100 Subject: [PATCH 1/3] feat(environments): add new filtering and pagination flags Fixes #934 * Pagination params are only sent to the API when the user explicitly sets --page or --page-limit, preserving the existing full-list behaviour when no flags are passed. * The table printer handles both the legacy array response and the paginated response shape, appending a pagination footer for the latter. * Filters map to the corresponding query params on `GET /api/v2/environments/{org}`: name, type, space_id and tag are repeatable with OR semantics. As with pagination, nothing is sent when the flags are not used (kosli-dev/cli#934). * Adds a TagEnv test helper mirroring TagFlow. * Map to the sort and sort_direction query params; sent only when set, so the API defaults (name, asc) apply otherwise. Valid values are enforced by the API, not the CLI. * The `unknown-space-id` case is blocked on the server returning a `5xx` instead of an empty list, reported upstream in kosli-dev/server#5858. Re-enable the commented-out case once that is fixed. --- cmd/kosli/listEnvironments.go | 135 ++++++++++++++++++++++++++--- cmd/kosli/listEnvironments_test.go | 122 ++++++++++++++++++++++++++ cmd/kosli/root.go | 6 ++ cmd/kosli/testHelpers.go | 12 +++ 4 files changed, 263 insertions(+), 12 deletions(-) diff --git a/cmd/kosli/listEnvironments.go b/cmd/kosli/listEnvironments.go index 90370aad8..41954b2fa 100644 --- a/cmd/kosli/listEnvironments.go +++ b/cmd/kosli/listEnvironments.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -14,10 +15,68 @@ import ( "github.com/spf13/cobra" ) -const listEnvironmentsDesc = `List environments for an org.` +const listEnvironmentsShortDesc = `List environments for an org.` + +const listEnvironmentsLongDesc = listEnvironmentsShortDesc + ` +By default, all environments are returned in one response. +When --page or --page-limit is set, the results are paginated and the response includes pagination metadata. +The list can be filtered by name, type, space and tags, and sorted with --sort and --sort-direction.` + +const listEnvironmentsExample = ` +# list all environments for an org: +kosli list environments \ + --api-token yourAPIToken \ + --org yourOrgName + +# show the second page of environments, 25 per page: +kosli list environments \ + --page 2 \ + --page-limit 25 \ + --api-token yourAPIToken \ + --org yourOrgName + +# list environments whose name contains a substring (in JSON): +kosli list environments \ + --name prod \ + --output json \ + --api-token yourAPIToken \ + --org yourOrgName + +# list K8S and ECS environments tagged with team=platform: +kosli list environments \ + --type K8S \ + --type ECS \ + --tag team:platform \ + --api-token yourAPIToken \ + --org yourOrgName + +# list environments sorted by when they last changed, newest first: +kosli list environments \ + --sort last_changed_at \ + --sort-direction desc \ + --api-token yourAPIToken \ + --org yourOrgName +` type environmentLsOptions struct { - output string + listOptions + // withPagination is true when the user explicitly set --page or --page-limit. + // Without it, no pagination params are sent and the API returns all environments. + withPagination bool + name string + envTypes []string + spaceIDs []string + tags []string + sort string + sortDirection string +} + +type paginatedEnvsResponse struct { + Page int64 `json:"page"` + PerPage int64 `json:"per_page"` + TotalPages int64 `json:"total_pages"` + TotalCount int64 `json:"total_count"` + Environments []map[string]interface{} `json:"environments"` } func newListEnvironmentsCmd(out io.Writer) *cobra.Command { @@ -25,14 +84,19 @@ func newListEnvironmentsCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ Use: "environments", Aliases: []string{"env", "envs"}, - Short: listEnvironmentsDesc, - Long: listEnvironmentsDesc, + Short: listEnvironmentsShortDesc, + Long: listEnvironmentsLongDesc, + Example: listEnvironmentsExample, Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } + o.withPagination = cmd.Flags().Changed("page") || cmd.Flags().Changed("page-limit") + if o.withPagination { + return o.validate(cmd) + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -40,20 +104,54 @@ func newListEnvironmentsCmd(out io.Writer) *cobra.Command { }, } - cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + cmd.Flags().StringVar(&o.name, "name", "", envSearchNameFlag) + cmd.Flags().StringSliceVar(&o.envTypes, "type", []string{}, envTypeFilterFlag) + cmd.Flags().StringSliceVar(&o.spaceIDs, "space-id", []string{}, envSpaceIDFilterFlag) + cmd.Flags().StringSliceVar(&o.tags, "tag", []string{}, envTagFilterFlag) + cmd.Flags().StringVar(&o.sort, "sort", "", envSortFlag) + cmd.Flags().StringVar(&o.sortDirection, "sort-direction", "", envSortDirectionFlag) + addListFlags(cmd, &o.listOptions) return cmd } func (o *environmentLsOptions) run(out io.Writer, args []string) error { - url, err := url.JoinPath(global.Host, "api/v2/environments", global.Org) + base, err := url.JoinPath(global.Host, "api/v2/environments", global.Org) if err != nil { return err } + params := url.Values{} + if o.withPagination { + params.Set("page", strconv.Itoa(o.pageNumber)) + params.Set("per_page", strconv.Itoa(o.pageLimit)) + } + if o.name != "" { + params.Set("name", o.name) + } + for _, envType := range o.envTypes { + params.Add("type", envType) + } + for _, spaceID := range o.spaceIDs { + params.Add("space_id", spaceID) + } + for _, tag := range o.tags { + params.Add("tag", tag) + } + if o.sort != "" { + params.Set("sort", o.sort) + } + if o.sortDirection != "" { + params.Set("sort_direction", o.sortDirection) + } + reqURL := base + if encoded := params.Encode(); encoded != "" { + reqURL = base + "?" + encoded + } + reqParams := &requests.RequestParams{ Method: http.MethodGet, - URL: url, + URL: reqURL, Token: global.ApiToken, } response, err := kosliClient.Do(reqParams) @@ -61,7 +159,7 @@ func (o *environmentLsOptions) run(out io.Writer, args []string) error { return err } - return output.FormattedPrint(response.Body, o.output, out, 0, + return output.FormattedPrint(response.Body, o.output, out, o.pageNumber, map[string]output.FormatOutputFunc{ "table": printEnvListAsTable, "json": output.PrintJson, @@ -69,14 +167,24 @@ func (o *environmentLsOptions) run(out io.Writer, args []string) error { } func printEnvListAsTable(raw string, out io.Writer, page int) error { + // the API returns a plain array when no pagination params are sent, + // and a wrapped object with pagination metadata when they are var envs []map[string]interface{} - err := json.Unmarshal([]byte(raw), &envs) - if err != nil { - return err + var paginated *paginatedEnvsResponse + if err := json.Unmarshal([]byte(raw), &envs); err != nil { + paginated = &paginatedEnvsResponse{} + if err := json.Unmarshal([]byte(raw), paginated); err != nil { + return err + } + envs = paginated.Environments } if len(envs) == 0 { - logger.Info("No environments were found.") + msg := "No environments were found" + if page > 1 { + msg = fmt.Sprintf("%s at page number %d", msg, page) + } + logger.Info(msg + ".") return nil } @@ -111,6 +219,9 @@ func printEnvListAsTable(raw string, out io.Writer, page int) error { row := fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s", env["name"], env["type"], last_reported_str, last_modified_str, tagsOutput, policies) rows = append(rows, row) } + if paginated != nil { + rows = append(rows, fmt.Sprintf("\nShowing page %d of %d, total %d items", paginated.Page, paginated.TotalPages, paginated.TotalCount)) + } tabFormattedPrint(out, header, rows) return nil } diff --git a/cmd/kosli/listEnvironments_test.go b/cmd/kosli/listEnvironments_test.go index 4641ccbfa..5fef872d5 100644 --- a/cmd/kosli/listEnvironments_test.go +++ b/cmd/kosli/listEnvironments_test.go @@ -24,6 +24,13 @@ func (suite *ListEnvironmentsCommandTestSuite) SetupTest() { } suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + // dedicated envs with a unique name prefix so pagination/filter tests are + // deterministic even when other test suites create envs in the same org + CreateEnv(global.Org, "list-envs-934-a", "server", suite.T()) + CreateEnv(global.Org, "list-envs-934-b", "docker", suite.T()) + CreateEnv(global.Org, "list-envs-934-c", "K8S", suite.T()) + TagEnv("list-envs-934-a", "team", "platform", suite.T()) + global.Org = "acme-org" global.ApiToken = "v3OWZiYWu9G2IMQStYg9BcPQUQ88lJNNnTJTNq8jfvmkR1C5wVpHSs7F00JcB5i6OGeUzrKt3CwRq7ndcN4TTfMeo8ASVJ5NdHpZT7DkfRfiFvm8s7GbsIHh2PtiQJYs2UoN13T8DblV5C4oKb6-yWH73h67OhotPlKfVKazR-c" suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) @@ -56,6 +63,121 @@ func (suite *ListEnvironmentsCommandTestSuite) TestListEnvironmentsCmd() { cmd: fmt.Sprintf(`list environments xxx %s`, suite.defaultKosliArguments), golden: "Error: unknown command \"xxx\" for \"kosli list environments\"\n", }, + { + wantError: true, + name: "--page 0 causes an error", + cmd: fmt.Sprintf(`list environments --page 0 %s`, suite.defaultKosliArguments), + golden: "Error: page number must be a positive integer\nUsage: kosli list environments [flags]\n", + }, + { + wantError: true, + name: "--page-limit 0 causes an error", + cmd: fmt.Sprintf(`list environments --page-limit 0 %s`, suite.defaultKosliArguments), + golden: "Error: page limit must be a positive integer\nUsage: kosli list environments [flags]\n", + }, + { + wantError: true, + name: "negative --page causes an error", + cmd: fmt.Sprintf(`list environments --page -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page' has value '-1' which is illegal\n", + }, + { + name: "paginated table output shows a pagination footer", + cmd: fmt.Sprintf(`list environments --page 1 --page-limit 2 %s`, suite.defaultKosliArguments), + goldenRegex: `(?s).*Showing page 1 of \d+, total \d+ items\n$`, + }, + { + name: "paginated json output has the paginated response shape", + cmd: fmt.Sprintf(`list environments --page 1 --page-limit 2 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"environments", "length:2"}, + {"page", float64(1)}, + {"per_page", float64(2)}, + {"total_count", "not-nil"}, + {"total_pages", "not-nil"}, + }, + }, + { + name: "paginating beyond the last page reports no environments at that page", + cmd: fmt.Sprintf(`list environments --page 99 --page-limit 50 %s`, suite.acmeOrgKosliArguments), + golden: "No environments were found at page number 99.\n", + }, + { + name: "--name matches environments whose name contains the substring", + cmd: fmt.Sprintf(`list environments --name list-envs-934 --page 1 --page-limit 50 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"environments", "length:3"}, + }, + }, + { + name: "--name with no matching substring returns no environments", + cmd: fmt.Sprintf(`list environments --name no-such-env-substring-xyz %s`, suite.defaultKosliArguments), + golden: "No environments were found.\n", + }, + { + name: "--type filters environments by type", + cmd: fmt.Sprintf(`list environments --name list-envs-934 --type docker --page 1 --page-limit 50 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"environments", "length:1"}, + {"environments.[0].name", "list-envs-934-b"}, + }, + }, + { + name: "--type can be repeated to match multiple types", + cmd: fmt.Sprintf(`list environments --name list-envs-934 --type docker --type server --page 1 --page-limit 50 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"environments", "length:2"}, + }, + }, + { + name: "--tag filters environments by tag key:value", + cmd: fmt.Sprintf(`list environments --name list-envs-934 --tag team:platform --page 1 --page-limit 50 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"environments", "length:1"}, + {"environments.[0].name", "list-envs-934-a"}, + }, + }, + { + name: "--tag filters environments by tag key only", + cmd: fmt.Sprintf(`list environments --name list-envs-934 --tag team --page 1 --page-limit 50 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"environments", "length:1"}, + {"environments.[0].name", "list-envs-934-a"}, + }, + }, + // TODO: re-enable once the server returns an empty list instead of a + // 5xx for unknown space IDs: https://github.com/kosli-dev/server/issues/5858 + // { + // name: "--space-id with an unknown space returns no environments", + // cmd: fmt.Sprintf(`list environments --space-id no-such-space-id --page 1 --page-limit 50 --output json %s`, suite.defaultKosliArguments), + // goldenJson: []jsonCheck{ + // {"environments", "[]"}, + // }, + // }, + { + name: "--sort name --sort-direction desc reverses the name order", + cmd: fmt.Sprintf(`list environments --name list-envs-934 --sort name --sort-direction desc --page 1 --page-limit 50 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"environments", "length:3"}, + {"environments.[0].name", "list-envs-934-c"}, + {"environments.[2].name", "list-envs-934-a"}, + }, + }, + { + name: "--sort-direction asc keeps the name order", + cmd: fmt.Sprintf(`list environments --name list-envs-934 --sort-direction asc --page 1 --page-limit 50 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"environments", "length:3"}, + {"environments.[0].name", "list-envs-934-a"}, + {"environments.[2].name", "list-envs-934-c"}, + }, + }, + { + wantError: true, + name: "an invalid --sort value surfaces the API error", + cmd: fmt.Sprintf(`list environments --sort no-such-field %s`, suite.defaultKosliArguments), + goldenRegex: `^Error: .*`, + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index bebb8c6c0..fe53e3f8a 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -119,6 +119,12 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, pageNumberFlag = "[defaulted] The page number of a response." pageLimitFlag = "[defaulted] The number of elements per page." newEnvTypeFlag = "The type of environment. Valid types are: [K8S, ECS, server, S3, lambda, docker, azure-apps, logical]." + envSearchNameFlag = "[optional] Only list environments whose name contains this substring (case-insensitive)." + envTypeFilterFlag = "[optional] Only list environments of this type. Valid types are: [K8S, ECS, S3, lambda, server, docker, azure-apps, cloud-run, logical]. Can be repeated to match more than one type." + envSpaceIDFilterFlag = "[optional] Only list environments in the space with this ID. Can be repeated to match more than one space." + envTagFilterFlag = "[optional] Only list environments that have this tag, given as 'key' or 'key:value'. Can be repeated to match more than one tag." + envSortFlag = "[optional] The field to sort environments by. Valid values are: [name, last_modified_at, last_changed_at]. (defaults to name)" + envSortDirectionFlag = "[optional] The direction to sort environments in. Valid values are: [asc, desc]. (defaults to asc)" envAllowListFlag = "The environment name for which the artifact is allowlisted." reasonFlag = "The reason why this artifact is allowlisted." oldestCommitFlag = "[conditional] The source commit sha for the oldest change in the deployment. Can be any commit-ish. Only required if you don't specify '--environment'." diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index c2178423a..52c59ea1d 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -672,3 +672,15 @@ func TagFlow(flowName, tagKey, tagValue string, t *testing.T) { err := o.run([]string{"flow", flowName}) require.NoError(t, err, "flow should be tagged without error") } + +// TagEnv tags an environment with a key-value pair +func TagEnv(envName, tagKey, tagValue string, t *testing.T) { + t.Helper() + o := &tagOptions{ + payload: TagResourcePayload{ + SetTags: map[string]string{tagKey: tagValue}, + }, + } + err := o.run([]string{"env", envName}) + require.NoError(t, err, "env should be tagged without error") +} From b1a0a95f361a8e3695dd913da1b27ee60724c4b4 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Wed, 10 Jun 2026 12:16:13 +0100 Subject: [PATCH 2/3] chore: align flag description and add nil checking --- cmd/kosli/listEnvironments.go | 9 +++++---- cmd/kosli/root.go | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/kosli/listEnvironments.go b/cmd/kosli/listEnvironments.go index 41954b2fa..59cf88775 100644 --- a/cmd/kosli/listEnvironments.go +++ b/cmd/kosli/listEnvironments.go @@ -202,12 +202,13 @@ func printEnvListAsTable(raw string, out io.Writer, page int) error { last_modified_str = time.Unix(int64(last_modified_at.(float64)), 0).Format(time.RFC3339) } - tags := env["tags"].(map[string]interface{}) tagsOutput := "" - for key, value := range tags { - tagsOutput += fmt.Sprintf("[%s=%s], ", key, value) + if tags, ok := env["tags"].(map[string]interface{}); ok { + for key, value := range tags { + tagsOutput += fmt.Sprintf("[%s=%s], ", key, value) + } + tagsOutput = strings.TrimSuffix(tagsOutput, ", ") } - tagsOutput = strings.TrimSuffix(tagsOutput, ", ") var policies []interface{} if env["policies"] != nil { diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index fe53e3f8a..0ca22a3d1 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -118,7 +118,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, approvalEnvironmentNameFlag = "[defaulted] The environment the artifact is approved for. (defaults to all environments)" pageNumberFlag = "[defaulted] The page number of a response." pageLimitFlag = "[defaulted] The number of elements per page." - newEnvTypeFlag = "The type of environment. Valid types are: [K8S, ECS, server, S3, lambda, docker, azure-apps, logical]." + newEnvTypeFlag = "The type of environment. Valid types are: [K8S, ECS, S3, lambda, server, docker, azure-apps, cloud-run, logical]." envSearchNameFlag = "[optional] Only list environments whose name contains this substring (case-insensitive)." envTypeFilterFlag = "[optional] Only list environments of this type. Valid types are: [K8S, ECS, S3, lambda, server, docker, azure-apps, cloud-run, logical]. Can be repeated to match more than one type." envSpaceIDFilterFlag = "[optional] Only list environments in the space with this ID. Can be repeated to match more than one space." From 8ecc1e8c65ede5c8e304baa6c9ca0b1f1c798786 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Wed, 10 Jun 2026 12:31:19 +0100 Subject: [PATCH 3/3] chore: shae the list of supported ENVs for flag descriptions --- cmd/kosli/root.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 0ca22a3d1..a79df8ab2 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -85,6 +85,10 @@ The service principal needs to have the following permissions: Each line should specify a relative path or path glob to be ignored. You can include comments in this file, using ^#^. The ^.kosli_ignore^ will be treated as part of the artifact like any other file, unless it is explicitly ignored itself.` + // single source of truth for the env type lists shown in flag help texts; + // the server is the authority on which types are actually accepted + validEnvTypesList = "K8S, ECS, S3, lambda, server, docker, azure-apps, cloud-run, logical" + // flags apiTokenFlag = "The Kosli API token." artifactName = "[optional] Artifact display name, if different from file, image or directory name." @@ -118,9 +122,9 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, approvalEnvironmentNameFlag = "[defaulted] The environment the artifact is approved for. (defaults to all environments)" pageNumberFlag = "[defaulted] The page number of a response." pageLimitFlag = "[defaulted] The number of elements per page." - newEnvTypeFlag = "The type of environment. Valid types are: [K8S, ECS, S3, lambda, server, docker, azure-apps, cloud-run, logical]." + newEnvTypeFlag = "The type of environment. Valid types are: [" + validEnvTypesList + "]." envSearchNameFlag = "[optional] Only list environments whose name contains this substring (case-insensitive)." - envTypeFilterFlag = "[optional] Only list environments of this type. Valid types are: [K8S, ECS, S3, lambda, server, docker, azure-apps, cloud-run, logical]. Can be repeated to match more than one type." + envTypeFilterFlag = "[optional] Only list environments of this type. Valid types are: [" + validEnvTypesList + "]. Can be repeated to match more than one type." envSpaceIDFilterFlag = "[optional] Only list environments in the space with this ID. Can be repeated to match more than one space." envTagFilterFlag = "[optional] Only list environments that have this tag, given as 'key' or 'key:value'. Can be repeated to match more than one tag." envSortFlag = "[optional] The field to sort environments by. Valid values are: [name, last_modified_at, last_changed_at]. (defaults to name)"