Repository: pulumi/esc
Pull request #459 (2025-02-19 20:22:38)

Release Rotate CLI
Example output with a complete error, partial error, and successful rotation
![Screenshot 2025-02-19 at 2 23 55 PM](https://github.com/user-attachments/assets/24f71deb-4e1a-424f-a928-847b4c8e494b)



Patch:
diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index ab126c9..bf5a76e 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -11,6 +11,8 @@
   [#443](https://github.com/pulumi/esc/pull/443)
 - Introduce rotateOnly inputs
   [#444](https://github.com/pulumi/esc/pull/444)
+- Release rotate environment CLI command
+  [#459](https://github.com/pulumi/esc/pull/459)
 
 ### Bug Fixes
 
diff --git a/cmd/esc/cli/client/apitype.go b/cmd/esc/cli/client/apitype.go
index ad7423c..bce8a05 100644
--- a/cmd/esc/cli/client/apitype.go
+++ b/cmd/esc/cli/client/apitype.go
@@ -34,6 +34,49 @@ type EnvironmentErrorResponse struct {
 	Diagnostics []EnvironmentDiagnostic `json:"diagnostics,omitempty"`
 }
 
+type RotateEnvironmentResponse struct {
+	Code                int                     `json:"code,omitempty"`
+	Message             string                  `json:"message,omitempty"`
+	Diagnostics         []EnvironmentDiagnostic `json:"diagnostics,omitempty"`
+	SecretRotationEvent SecretRotationEvent     `json:"secretRotationEvent,omitempty"`
+}
+
+type SecretRotationEvent struct {
+	ID                   string              `json:"id"`
+	EnvironmentID        string              `json:"environmentId"`
+	CreatedAt            time.Time           `json:"created"`
+	PreRotationRevision  int                 `json:"preRotationRevision"`
+	PostRotationRevision *int                `json:"postRotationRevision,omitempty"`
+	UserID               string              `json:"userID"`
+	CompletedAt          *time.Time          `json:"completed,omitempty"`
+	Status               RotationEventStatus `json:"status"`
+	ScheduledActionID    *string             `json:"scheduledActionID,omitempty"`
+	ErrorMessage         *string             `json:"errorMessage,omitempty"`
+	Rotations            []SecretRotation    `json:"rotations"`
+}
+
+type SecretRotation struct {
+	ID              string         `json:"id"`
+	EnvironmentPath string         `json:"environmentPath"`
+	Status          RotationStatus `json:"status"`
+	ErrorMessage    *string        `json:"errorMessage,omitempty"`
+}
+
+type RotationEventStatus string
+
+const (
+	RotationEventSucceeded  RotationEventStatus = "succeeded"
+	RotationEventFailed     RotationEventStatus = "failed"
+	RotationEventInProgress RotationEventStatus = "in_progress"
+)
+
+type RotationStatus string
+
+const (
+	RotationSucceeded RotationStatus = "succeeded"
+	RotationFailed    RotationStatus = "failed"
+)
+
 func (err EnvironmentErrorResponse) Error() string {
 	errString := fmt.Sprintf("[%d] %s", err.Code, err.Message)
 	if len(err.Diagnostics) > 0 {
diff --git a/cmd/esc/cli/client/client.go b/cmd/esc/cli/client/client.go
index ccaf115..81c80d8 100644
--- a/cmd/esc/cli/client/client.go
+++ b/cmd/esc/cli/client/client.go
@@ -163,7 +163,7 @@ type Client interface {
 		projectName string,
 		envName string,
 		rotationPaths []string,
-	) ([]EnvironmentDiagnostic, error)
+	) (*RotateEnvironmentResponse, []EnvironmentDiagnostic, error)
 
 	// CheckYAMLEnvironment checks the given environment YAML for errors within the context of org orgName.
 	//
@@ -632,7 +632,7 @@ func (pc *client) RotateEnvironment(
 	projectName string,
 	envName string,
 	rotationPaths []string,
-) ([]EnvironmentDiagnostic, error) {
+) (*RotateEnvironmentResponse, []EnvironmentDiagnostic, error) {
 	path := fmt.Sprintf("/api/esc/environments/%v/%v/%v/rotate", orgName, projectName, envName)
 
 	reqObj := struct {
@@ -641,9 +641,7 @@ func (pc *client) RotateEnvironment(
 		Paths: rotationPaths,
 	}
 
-	var resp struct {
-		ID string `json:"id"`
-	}
+	var resp RotateEnvironmentResponse
 	var errResp EnvironmentErrorResponse
 	err := pc.restCallWithOptions(ctx, http.MethodPost, path, nil, reqObj, &resp, httpCallOptions{
 		ErrorResponse: &errResp,
@@ -651,11 +649,11 @@ func (pc *client) RotateEnvironment(
 	if err != nil {
 		var diags *EnvironmentErrorResponse
 		if errors.As(err, &diags) && diags.Code == http.StatusBadRequest && len(diags.Diagnostics) != 0 {
-			return diags.Diagnostics, nil
+			return nil, diags.Diagnostics, nil
 		}
-		return nil, err
+		return nil, nil, err
 	}
-	return nil, nil
+	return &resp, nil, nil
 }
 
 func (pc *client) CheckYAMLEnvironment(
diff --git a/cmd/esc/cli/env_rotate.go b/cmd/esc/cli/env_rotate.go
index c2e6e5f..d78890d 100644
--- a/cmd/esc/cli/env_rotate.go
+++ b/cmd/esc/cli/env_rotate.go
@@ -5,7 +5,10 @@ package cli
 import (
 	"context"
 	"fmt"
+	"strings"
 
+	"github.com/pulumi/esc/cmd/esc/cli/client"
+	"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
 	"github.com/spf13/cobra"
 )
@@ -18,7 +21,6 @@ func newEnvRotateCmd(envcmd *envCommand) *cobra.Command {
 			"\n" +
 			"Optionally accepts any number of Property Paths as additional arguments. If given any paths, will only rotate secrets at those paths.\n",
 		SilenceUsage: true,
-		Hidden:       true,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := context.Background()
 
@@ -44,15 +46,49 @@ func newEnvRotateCmd(envcmd *envCommand) *cobra.Command {
 				rotationPaths = append(rotationPaths, arg)
 			}
 
-			diags, err := envcmd.esc.client.RotateEnvironment(ctx, ref.orgName, ref.projectName, ref.envName, rotationPaths)
+			resp, diags, err := envcmd.esc.client.RotateEnvironment(ctx, ref.orgName, ref.projectName, ref.envName, rotationPaths)
 			if err != nil {
 				return err
 			}
 			if len(diags) != 0 {
 				return envcmd.writePropertyEnvironmentDiagnostics(envcmd.esc.stderr, diags)
 			}
+			if resp == nil {
+				return nil
+			}
+
+			event := resp.SecretRotationEvent
+
+			// Print result of rotation
+			var b strings.Builder
+			if event.Status == client.RotationEventSucceeded {
+				fmt.Fprintf(&b, "Environment '%s' rotated.\n", args[0])
+			} else if event.Status == client.RotationEventFailed {
+				if event.ErrorMessage != nil {
+					fmt.Fprintf(&b, "%vError rotating: %s.%v\n", colors.SpecError, *event.ErrorMessage, colors.Reset)
+				} else {
+					fmt.Fprintf(&b, "%vEnvironment '%s' rotated with errors.%v\n", colors.SpecWarning, args[0], colors.Reset)
+				}
+			}
+			if event.PostRotationRevision != nil {
+				fmt.Fprintf(&b, "New revision '%d' was created.\n", *event.PostRotationRevision)
+			}
+
+			var failedRotations []client.SecretRotation
+			for _, rotation := range event.Rotations {
+				if rotation.Status == client.RotationFailed {
+					failedRotations = append(failedRotations, rotation)
+				}
+			}
+			if len(failedRotations) > 0 {
+				fmt.Fprintf(&b, "\n%vFailed secrets:%v\n", colors.SpecError, colors.Reset)
+				for _, rotation := range failedRotations {
+					fmt.Fprintf(&b, "Path: %s, error: %s\n", rotation.EnvironmentPath, *rotation.ErrorMessage)
+				}
+			}
+
+			fmt.Fprint(envcmd.esc.stdout, envcmd.esc.colors.Colorize(b.String()))
 
-			fmt.Fprintf(envcmd.esc.stdout, "Environment '%s' rotated.\n", args[0])
 			return nil
 		},
 	}


Test patch:
diff --git a/cmd/esc/cli/cli_test.go b/cmd/esc/cli/cli_test.go
index 730d82c..154f0b1 100644
--- a/cmd/esc/cli/cli_test.go
+++ b/cmd/esc/cli/cli_test.go
@@ -683,8 +683,8 @@ func (c *testPulumiClient) RotateEnvironment(
 	projectName string,
 	envName string,
 	rotationPaths []string,
-) ([]client.EnvironmentDiagnostic, error) {
-	return []client.EnvironmentDiagnostic{}, nil
+) (*client.RotateEnvironmentResponse, []client.EnvironmentDiagnostic, error) {
+	return &client.RotateEnvironmentResponse{}, []client.EnvironmentDiagnostic{}, nil
 }
 
 func (c *testPulumiClient) CheckYAMLEnvironment(
diff --git a/cmd/esc/cli/client/client_test.go b/cmd/esc/cli/client/client_test.go
index 5cf7915..7e60c03 100644
--- a/cmd/esc/cli/client/client_test.go
+++ b/cmd/esc/cli/client/client_test.go
@@ -518,7 +518,7 @@ func TestRotateEnvironment(t *testing.T) {
 			require.NoError(t, err)
 		})
 
-		diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", rotationPaths)
+		_, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", rotationPaths)
 		require.NoError(t, err)
 		assert.Empty(t, diags)
 	})
@@ -554,7 +554,7 @@ func TestRotateEnvironment(t *testing.T) {
 			require.NoError(t, err)
 		})
 
-		diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", rotationPaths)
+		_, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", rotationPaths)
 		require.NoError(t, err)
 		assert.Equal(t, expected, diags)
 	})
@@ -570,7 +570,7 @@ func TestRotateEnvironment(t *testing.T) {
 			require.NoError(t, err)
 		})
 
-		_, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", rotationPaths)
+		_, _, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", rotationPaths)
 		assert.ErrorContains(t, err, "not found")
 	})
 }