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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 128 additions & 16 deletions cmd/kosli/listEnvironments.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"

Expand All @@ -14,69 +15,176 @@ 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 {
o := new(environmentLsOptions)
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 {
return o.run(out, args)
},
}

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)
if err != nil {
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,
})
}

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
}

Expand All @@ -94,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 {
Expand All @@ -111,6 +220,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
}
122 changes: 122 additions & 0 deletions cmd/kosli/listEnvironments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Comment thread
mbevc1 marked this conversation as resolved.
// {"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)
Expand Down
12 changes: 11 additions & 1 deletion cmd/kosli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -118,7 +122,13 @@ 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: [" + validEnvTypesList + "]."
envSearchNameFlag = "[optional] Only list environments whose name contains this substring (case-insensitive)."
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)"
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'."
Expand Down
Loading
Loading