Repository: pulumi/esc
Pull request #440 (2025-01-28 21:29:39)

Add paths to rotate
Tested with passing paths
```
go run ./cmd/esc rotate delme/abc/yo ab cd
```

With invalid path
```
❯ go run ./cmd/esc env rotate delme/abc/yo abc ....
Error: '....' is an invalid property path: expected property path to start with a name or index
exit status 1

```


Patch:
diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index 7b035b8..e29abdd 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -5,6 +5,8 @@
 - Introduce support for rotating static credentials via `fn::rotate` providers [432](https://github.com/pulumi/esc/pull/432)
 - Add the `rotate` CLI command
   [#433](https://github.com/pulumi/esc/pull/433)
+- Add ability to pass specific paths to rotate with the `rotate` CLI command
+  [#440](https://github.com/pulumi/esc/pull/440)
 
 ### Bug Fixes
 
diff --git a/cmd/esc/cli/client/client.go b/cmd/esc/cli/client/client.go
index 21d662c..131af48 100644
--- a/cmd/esc/cli/client/client.go
+++ b/cmd/esc/cli/client/client.go
@@ -156,6 +156,7 @@ type Client interface {
 	) (string, []EnvironmentDiagnostic, error)
 
 	// RotateEnvironment will rotate credentials in an environment.
+	// If rotationPaths is non-empty, will only rotate credentials at those paths.
 	// It also evaluates the environment projectName/envName in org orgName and returns the ID of the opened
 	// environment. The opened environment will be available for the indicated duration, after which it
 	// will expire.
@@ -167,6 +168,7 @@ type Client interface {
 		projectName string,
 		envName string,
 		duration time.Duration,
+		rotationPaths []string,
 	) (string, []EnvironmentDiagnostic, error)
 
 	// CheckYAMLEnvironment checks the given environment YAML for errors within the context of org orgName.
@@ -636,9 +638,16 @@ func (pc *client) RotateEnvironment(
 	projectName string,
 	envName string,
 	duration time.Duration,
+	rotationPaths []string,
 ) (string, []EnvironmentDiagnostic, error) {
 	path := fmt.Sprintf("/api/esc/environments/%v/%v/%v/rotate", orgName, projectName, envName)
 
+	reqObj := struct {
+		Paths []string `url:"paths"`
+	}{
+		Paths: rotationPaths,
+	}
+
 	queryObj := struct {
 		Duration string `url:"duration"`
 	}{
@@ -648,7 +657,7 @@ func (pc *client) RotateEnvironment(
 		ID string `json:"id"`
 	}
 	var errResp EnvironmentErrorResponse
-	err := pc.restCallWithOptions(ctx, http.MethodPost, path, queryObj, nil, &resp, httpCallOptions{
+	err := pc.restCallWithOptions(ctx, http.MethodPost, path, queryObj, reqObj, &resp, httpCallOptions{
 		ErrorResponse: &errResp,
 	})
 	if err != nil {
diff --git a/cmd/esc/cli/env_rotate.go b/cmd/esc/cli/env_rotate.go
index f386c22..5ea4398 100644
--- a/cmd/esc/cli/env_rotate.go
+++ b/cmd/esc/cli/env_rotate.go
@@ -18,9 +18,11 @@ func newEnvRotateCmd(envcmd *envCommand) *cobra.Command {
 	var format string
 
 	cmd := &cobra.Command{
-		Use:   "rotate [<org-name>/][<project-name>/]<environment-name>",
+		Use:   "rotate [<org-name>/][<project-name>/]<environment-name> [path(s) to rotate]",
 		Short: "Rotate secrets and open the environment",
 		Long: "Rotate secrets and open the environment\n" +
+			"\n" +
+			"Optionally accepts any number of Property Paths as additional arguments. If given any paths, will only rotate secrets at those paths.\n" +
 			"\n" +
 			"This command opens the environment with the given name. The result is written to\n" +
 			"stdout as JSON.\n",
@@ -42,6 +44,15 @@ func newEnvRotateCmd(envcmd *envCommand) *cobra.Command {
 				return fmt.Errorf("the rotate command does not accept environments at specific versions")
 			}
 
+			rotationPaths := []string{}
+			for _, arg := range args[1:] {
+				_, err := resource.ParsePropertyPath(arg)
+				if err != nil {
+					return fmt.Errorf("'%s' is an invalid property path: %w", arg, err)
+				}
+				rotationPaths = append(rotationPaths, arg)
+			}
+
 			switch format {
 			case "detailed", "json", "yaml", "string", "dotenv", "shell":
 				// OK
@@ -49,7 +60,7 @@ func newEnvRotateCmd(envcmd *envCommand) *cobra.Command {
 				return fmt.Errorf("unknown output format %q", format)
 			}
 
-			env, diags, err := envcmd.rotateEnvironment(ctx, ref, duration)
+			env, diags, err := envcmd.rotateEnvironment(ctx, ref, duration, rotationPaths)
 			if err != nil {
 				return err
 			}
@@ -75,8 +86,9 @@ func (env *envCommand) rotateEnvironment(
 	ctx context.Context,
 	ref environmentRef,
 	duration time.Duration,
+	rotationPaths []string,
 ) (*esc.Environment, []client.EnvironmentDiagnostic, error) {
-	envID, diags, err := env.esc.client.RotateEnvironment(ctx, ref.orgName, ref.projectName, ref.envName, duration)
+	envID, diags, err := env.esc.client.RotateEnvironment(ctx, ref.orgName, ref.projectName, ref.envName, duration, rotationPaths)
 	if err != nil {
 		return nil, nil, err
 	}


Test patch:
diff --git a/cmd/esc/cli/cli_test.go b/cmd/esc/cli/cli_test.go
index 2ca47c1..9ca4e6c 100644
--- a/cmd/esc/cli/cli_test.go
+++ b/cmd/esc/cli/cli_test.go
@@ -683,6 +683,7 @@ func (c *testPulumiClient) RotateEnvironment(
 	projectName string,
 	envName string,
 	duration time.Duration,
+	rotationPaths []string,
 ) (string, []client.EnvironmentDiagnostic, error) {
 	return c.OpenEnvironment(ctx, orgName, projectName, envName, "", duration)
 }
diff --git a/cmd/esc/cli/client/client_test.go b/cmd/esc/cli/client/client_test.go
index a833b58..d0fdb0f 100644
--- a/cmd/esc/cli/client/client_test.go
+++ b/cmd/esc/cli/client/client_test.go
@@ -505,18 +505,24 @@ func TestOpenEnvironment(t *testing.T) {
 }
 
 func TestRotateEnvironment(t *testing.T) {
+	rotationPaths := []string{"a.b", "c"}
 	t.Run("OK", func(t *testing.T) {
 		const expectedID = "open-id"
 		duration := 2 * time.Hour
+		expectedBody := "{\"Paths\":[\"a.b\",\"c\"]}"
 
 		client := newTestClient(t, http.MethodPost, "/api/esc/environments/test-org/test-project/test-env/rotate", func(w http.ResponseWriter, r *http.Request) {
 			assert.Equal(t, duration.String(), r.URL.Query().Get("duration"))
 
-			err := json.NewEncoder(w).Encode(map[string]any{"id": expectedID})
+			body, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			assert.Equal(t, expectedBody, string(body))
+
+			err = json.NewEncoder(w).Encode(map[string]any{"id": expectedID})
 			require.NoError(t, err)
 		})
 
-		id, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", duration)
+		id, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", duration, rotationPaths)
 		require.NoError(t, err)
 		assert.Equal(t, expectedID, id)
 		assert.Empty(t, diags)
@@ -553,7 +559,7 @@ func TestRotateEnvironment(t *testing.T) {
 			require.NoError(t, err)
 		})
 
-		_, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", 2*time.Hour)
+		_, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", 2*time.Hour, rotationPaths)
 		require.NoError(t, err)
 		assert.Equal(t, expected, diags)
 	})
@@ -569,7 +575,7 @@ func TestRotateEnvironment(t *testing.T) {
 			require.NoError(t, err)
 		})
 
-		_, _, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", 2*time.Hour)
+		_, _, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", 2*time.Hour, rotationPaths)
 		assert.ErrorContains(t, err, "not found")
 	})
 }