Repository: pulumi/esc
Pull request #484 (2025-03-11 20:20:02)

Adding proper errors to list environments
### Summary
- Fixes https://github.com/pulumi/esc/issues/472
- Introduces ListOrganizationEnvironments API into the client
- Currently used API ListEnvironments only takes in orgName as a filter, so if no such org exists, it returns an empty array and a 2XX
- Therefore make the list command select between ListOrganizationEnvironments and ListEnvironments  based on whether org name is provided
- ListOrganizationEnvironments returns proper 404 when org is not found, which results in CLI command returning error code 1 properly

### Testing
- Adjusted client tests
- Manually tested using `esc env ls; echo $?`
  - `esc env real-org; echo $?` returns 0, whether or not it has environments
  - `esc env non-existing-org; echo $?` returns 1, and prints an error message `Error: listing environments: [404] Not Found: Organization 'non-existing-org' not found`


Patch:
diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index d3c3373..229175b 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -2,6 +2,8 @@
 
 - Add `--string` flag to `env set` to treat the given value as a string.
   [#467](https://github.com/pulumi/esc/pull/467)
+- Add proper return code to list environments when organization doesn't exist
+  [#484](https://github.com/pulumi/esc/pull/484)
 
 ### Bug Fixes
 
diff --git a/cmd/esc/cli/client/client.go b/cmd/esc/cli/client/client.go
index 81c80d8..ad7db9e 100644
--- a/cmd/esc/cli/client/client.go
+++ b/cmd/esc/cli/client/client.go
@@ -68,12 +68,22 @@ type Client interface {
 	// GetRevisionNumber returns the revision number for version.
 	GetRevisionNumber(ctx context.Context, orgName, projectName, envName, version string) (int, error)
 
-	// ListEnvironments lists all environments in the given org that are accessible to the calling user.
+	// ListEnvironments lists all environments that are accessible to the calling user.
 	//
 	// Each call to ListEnvironments returns a page of results and a continuation token. If there are no
 	// more results, the continuation token will be empty. Otherwise, the continuattion token should be
 	// passed to the next call to ListEnvironments to fetch the next page of results.
 	ListEnvironments(
+		ctx context.Context,
+		continuationToken string,
+	) (environments []OrgEnvironment, nextToken string, err error)
+
+	// ListOrganizationEnvironments lists all environments in the given org that are accessible to the calling user.
+	//
+	// Each call to ListOrganizationEnvironments returns a page of results and a continuation token. If there are no
+	// more results, the continuation token will be empty. Otherwise, the continuattion token should be
+	// passed to the next call to ListOrganizationEnvironments to fetch the next page of results.
+	ListOrganizationEnvironments(
 		ctx context.Context,
 		orgName string,
 		continuationToken string,
@@ -445,15 +455,12 @@ func (pc *client) GetRevisionNumber(ctx context.Context, orgName, projectName, e
 
 func (pc *client) ListEnvironments(
 	ctx context.Context,
-	orgName string,
 	continuationToken string,
 ) ([]OrgEnvironment, string, error) {
 	queryObj := struct {
 		ContinuationToken string `url:"continuationToken,omitempty"`
-		Organization      string `url:"organization,omitempty"`
 	}{
 		ContinuationToken: continuationToken,
-		Organization:      orgName,
 	}
 
 	var resp ListEnvironmentsResponse
@@ -464,6 +471,25 @@ func (pc *client) ListEnvironments(
 	return resp.Environments, resp.NextToken, nil
 }
 
+func (pc *client) ListOrganizationEnvironments(
+	ctx context.Context,
+	orgName string,
+	continuationToken string,
+) ([]OrgEnvironment, string, error) {
+	queryObj := struct {
+		ContinuationToken string `url:"continuationToken,omitempty"`
+	}{
+		ContinuationToken: continuationToken,
+	}
+
+	var resp ListEnvironmentsResponse
+	err := pc.restCall(ctx, http.MethodGet, "/api/esc/environments/"+orgName, queryObj, nil, &resp)
+	if err != nil {
+		return nil, "", err
+	}
+	return resp.Environments, resp.NextToken, nil
+}
+
 // Deprecated: Use CreateEnvironmentWithProject instead
 func (pc *client) CreateEnvironment(ctx context.Context, orgName, envName string) error {
 	return pc.CreateEnvironmentWithProject(ctx, orgName, DefaultProject, envName)
diff --git a/cmd/esc/cli/env_ls.go b/cmd/esc/cli/env_ls.go
index 8044102..b79620f 100644
--- a/cmd/esc/cli/env_ls.go
+++ b/cmd/esc/cli/env_ls.go
@@ -69,7 +69,18 @@ func (env *envCommand) listEnvironments(ctx context.Context, orgFilter, projectF
 	user := env.esc.account.Username
 	continuationToken, allEnvs := "", []client.OrgEnvironment(nil)
 	for {
-		envs, nextToken, err := env.esc.client.ListEnvironments(ctx, orgFilter, continuationToken)
+		var envs []client.OrgEnvironment
+		var nextToken string
+		var err error
+
+		// If orgFilter is specified, use ListOrganizationEnvironments endpoint, so that we receive proper errors
+		// like 404 when environment doesn't exist, instead of an empty array
+		if orgFilter != "" {
+			envs, nextToken, err = env.esc.client.ListOrganizationEnvironments(ctx, orgFilter, continuationToken)
+		} else {
+			envs, nextToken, err = env.esc.client.ListEnvironments(ctx, continuationToken)
+		}
+
 		if err != nil {
 			return []client.OrgEnvironment(nil), fmt.Errorf("listing environments: %w", err)
 		}


Test patch:
diff --git a/cmd/esc/cli/cli_test.go b/cmd/esc/cli/cli_test.go
index 3fe809b..39f6bc8 100644
--- a/cmd/esc/cli/cli_test.go
+++ b/cmd/esc/cli/cli_test.go
@@ -473,6 +473,13 @@ func (c *testPulumiClient) GetRevisionNumber(ctx context.Context, orgName, proje
 }
 
 func (c *testPulumiClient) ListEnvironments(
+	ctx context.Context,
+	continuationToken string,
+) ([]client.OrgEnvironment, string, error) {
+	return c.ListOrganizationEnvironments(ctx, "", continuationToken)
+}
+
+func (c *testPulumiClient) ListOrganizationEnvironments(
 	ctx context.Context,
 	orgName string,
 	continuationToken string,
diff --git a/cmd/esc/cli/client/client_test.go b/cmd/esc/cli/client/client_test.go
index 7e60c03..969e9a2 100644
--- a/cmd/esc/cli/client/client_test.go
+++ b/cmd/esc/cli/client/client_test.go
@@ -114,7 +114,7 @@ func TestListEnvironments(t *testing.T) {
 			require.NoError(t, err)
 		})
 
-		actual, token, err := client.ListEnvironments(context.Background(), "", "")
+		actual, token, err := client.ListEnvironments(context.Background(), "")
 		require.NoError(t, err)
 		assert.Equal(t, expected, actual)
 		assert.Equal(t, expectedToken, token)
@@ -129,9 +129,8 @@ func TestListEnvironments(t *testing.T) {
 
 		expectedToken := "next-token"
 
-		client := newTestClient(t, http.MethodGet, "/api/esc/environments", func(w http.ResponseWriter, r *http.Request) {
+		client := newTestClient(t, http.MethodGet, "/api/esc/environments/org-1", func(w http.ResponseWriter, r *http.Request) {
 			assert.Equal(t, "", r.URL.Query().Get("continuationToken"))
-			assert.Equal(t, org, r.URL.Query().Get("organization"))
 
 			err := json.NewEncoder(w).Encode(ListEnvironmentsResponse{
 				Environments: expected,
@@ -140,7 +139,7 @@ func TestListEnvironments(t *testing.T) {
 			require.NoError(t, err)
 		})
 
-		actual, token, err := client.ListEnvironments(context.Background(), org, "")
+		actual, token, err := client.ListOrganizationEnvironments(context.Background(), org, "")
 		require.NoError(t, err)
 		assert.Equal(t, expected, actual)
 		assert.Equal(t, expectedToken, token)
@@ -157,7 +156,7 @@ func TestListEnvironments(t *testing.T) {
 			require.NoError(t, err)
 		})
 
-		actual, token, err := client.ListEnvironments(context.Background(), "", token)
+		actual, token, err := client.ListEnvironments(context.Background(), token)
 		require.NoError(t, err)
 		assert.Nil(t, actual)
 		assert.Equal(t, "", token)