diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index e9e5b0ad..7b035b80 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -3,6 +3,8 @@ - Fix diagnostic messages when updating environment with invalid definition [#422](https://github.com/pulumi/esc/pull/422) - 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) ### Bug Fixes diff --git a/cmd/esc/cli/cli_test.go b/cmd/esc/cli/cli_test.go index 53ec1a49..2ca47c17 100644 --- a/cmd/esc/cli/cli_test.go +++ b/cmd/esc/cli/cli_test.go @@ -677,6 +677,16 @@ func (c *testPulumiClient) OpenEnvironment( return c.openEnvironment(ctx, orgName, envName, env.yaml) } +func (c *testPulumiClient) RotateEnvironment( + ctx context.Context, + orgName string, + projectName string, + envName string, + duration time.Duration, +) (string, []client.EnvironmentDiagnostic, error) { + return c.OpenEnvironment(ctx, orgName, projectName, envName, "", duration) +} + func (c *testPulumiClient) CheckYAMLEnvironment( ctx context.Context, orgName string, diff --git a/cmd/esc/cli/client/client.go b/cmd/esc/cli/client/client.go index 31a87cbf..21d662cd 100644 --- a/cmd/esc/cli/client/client.go +++ b/cmd/esc/cli/client/client.go @@ -141,7 +141,7 @@ type Client interface { // DeleteEnvironment deletes the environment envName in org orgName. DeleteEnvironment(ctx context.Context, orgName, projectName, envName string) error - // OpenEnvironment evaluates the environment envName in org orgName and returns the ID of the opened + // OpenEnvironment 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. // @@ -155,6 +155,20 @@ type Client interface { duration time.Duration, ) (string, []EnvironmentDiagnostic, error) + // RotateEnvironment will rotate credentials in an environment. + // 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. + // + // If the environment contains errors, the open will fail with diagnostics. + RotateEnvironment( + ctx context.Context, + orgName string, + projectName string, + envName string, + duration time.Duration, + ) (string, []EnvironmentDiagnostic, error) + // CheckYAMLEnvironment checks the given environment YAML for errors within the context of org orgName. // // This call returns the checked environment's AST, values, schema, and any diagnostics issued by the @@ -616,6 +630,37 @@ func (pc *client) OpenEnvironment( return resp.ID, nil, nil } +func (pc *client) RotateEnvironment( + ctx context.Context, + orgName string, + projectName string, + envName string, + duration time.Duration, +) (string, []EnvironmentDiagnostic, error) { + path := fmt.Sprintf("/api/esc/environments/%v/%v/%v/rotate", orgName, projectName, envName) + + queryObj := struct { + Duration string `url:"duration"` + }{ + Duration: duration.String(), + } + var resp struct { + ID string `json:"id"` + } + var errResp EnvironmentErrorResponse + err := pc.restCallWithOptions(ctx, http.MethodPost, path, queryObj, nil, &resp, httpCallOptions{ + ErrorResponse: &errResp, + }) + 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, err + } + return resp.ID, nil, nil +} + func (pc *client) CheckYAMLEnvironment( ctx context.Context, orgName string, diff --git a/cmd/esc/cli/client/client_test.go b/cmd/esc/cli/client/client_test.go index a5919d26..a833b585 100644 --- a/cmd/esc/cli/client/client_test.go +++ b/cmd/esc/cli/client/client_test.go @@ -504,6 +504,76 @@ func TestOpenEnvironment(t *testing.T) { }) } +func TestRotateEnvironment(t *testing.T) { + t.Run("OK", func(t *testing.T) { + const expectedID = "open-id" + duration := 2 * time.Hour + + 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}) + require.NoError(t, err) + }) + + id, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", duration) + require.NoError(t, err) + assert.Equal(t, expectedID, id) + assert.Empty(t, diags) + }) + + t.Run("Diags", func(t *testing.T) { + expected := []EnvironmentDiagnostic{ + { + Range: &esc.Range{ + Environment: "test-env", + Begin: esc.Pos{Line: 42, Column: 1}, + End: esc.Pos{Line: 42, Column: 42}, + }, + Summary: "diag 1", + }, + { + Range: &esc.Range{ + Environment: "import-env", + Begin: esc.Pos{Line: 1, Column: 2}, + End: esc.Pos{Line: 3, Column: 4}, + }, + Summary: "diag 2", + }, + } + + client := newTestClient(t, http.MethodPost, "/api/esc/environments/test-org/test-project/test-env/rotate", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + + err := json.NewEncoder(w).Encode(EnvironmentErrorResponse{ + Code: 400, + Message: "bad request", + Diagnostics: expected, + }) + require.NoError(t, err) + }) + + _, diags, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", 2*time.Hour) + require.NoError(t, err) + assert.Equal(t, expected, diags) + }) + + t.Run("Not found", func(t *testing.T) { + client := newTestClient(t, http.MethodPost, "/api/esc/environments/test-org/test-project/test-env/rotate", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + + err := json.NewEncoder(w).Encode(apitype.ErrorResponse{ + Code: 404, + Message: "not found", + }) + require.NoError(t, err) + }) + + _, _, err := client.RotateEnvironment(context.Background(), "test-org", "test-project", "test-env", 2*time.Hour) + assert.ErrorContains(t, err, "not found") + }) +} + func TestCheckYAMLEnvironment(t *testing.T) { t.Run("OK", func(t *testing.T) { yaml := []byte(`{"values":{"foo":"bar"}}`) diff --git a/cmd/esc/cli/env.go b/cmd/esc/cli/env.go index 63a5f2dd..c6c7a289 100644 --- a/cmd/esc/cli/env.go +++ b/cmd/esc/cli/env.go @@ -74,6 +74,7 @@ func newEnvCmd(esc *escCommand) *cobra.Command { cmd.AddCommand(newEnvTagCmd((env))) cmd.AddCommand(newEnvRmCmd(env)) cmd.AddCommand(newEnvOpenCmd(env)) + cmd.AddCommand(newEnvRotateCmd(env)) cmd.AddCommand(newEnvRunCmd(env)) return cmd diff --git a/cmd/esc/cli/env_open.go b/cmd/esc/cli/env_open.go index 95112ac8..8c06eeff 100644 --- a/cmd/esc/cli/env_open.go +++ b/cmd/esc/cli/env_open.go @@ -40,7 +40,6 @@ func newEnvOpenCmd(envcmd *envCommand) *cobra.Command { if err != nil { return err } - _ = args var path resource.PropertyPath if len(args) == 1 { diff --git a/cmd/esc/cli/env_rotate.go b/cmd/esc/cli/env_rotate.go new file mode 100644 index 00000000..f386c22c --- /dev/null +++ b/cmd/esc/cli/env_rotate.go @@ -0,0 +1,88 @@ +// Copyright 2025, Pulumi Corporation. + +package cli + +import ( + "context" + "fmt" + "time" + + "github.com/pulumi/esc" + "github.com/pulumi/esc/cmd/esc/cli/client" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/spf13/cobra" +) + +func newEnvRotateCmd(envcmd *envCommand) *cobra.Command { + var duration time.Duration + var format string + + cmd := &cobra.Command{ + Use: "rotate [/][/]", + Short: "Rotate secrets and open the environment", + Long: "Rotate secrets and open the environment\n" + + "\n" + + "This command opens the environment with the given name. The result is written to\n" + + "stdout as JSON.\n", + SilenceUsage: true, + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + if err := envcmd.esc.getCachedClient(ctx); err != nil { + return err + } + + ref, _, err := envcmd.getExistingEnvRef(ctx, args) + if err != nil { + return err + } + + if ref.version != "" { + return fmt.Errorf("the rotate command does not accept environments at specific versions") + } + + switch format { + case "detailed", "json", "yaml", "string", "dotenv", "shell": + // OK + default: + return fmt.Errorf("unknown output format %q", format) + } + + env, diags, err := envcmd.rotateEnvironment(ctx, ref, duration) + if err != nil { + return err + } + if len(diags) != 0 { + return envcmd.writePropertyEnvironmentDiagnostics(envcmd.esc.stderr, diags) + } + + return envcmd.renderValue(envcmd.esc.stdout, env, resource.PropertyPath{}, format, false, true) + }, + } + + cmd.Flags().DurationVarP( + &duration, "lifetime", "l", 2*time.Hour, + "the lifetime of the opened environment in the form HhMm (e.g. 2h, 1h30m, 15m)") + cmd.Flags().StringVarP( + &format, "format", "f", "json", + "the output format to use. May be 'dotenv', 'json', 'yaml', 'detailed', or 'shell'") + + return cmd +} + +func (env *envCommand) rotateEnvironment( + ctx context.Context, + ref environmentRef, + duration time.Duration, +) (*esc.Environment, []client.EnvironmentDiagnostic, error) { + envID, diags, err := env.esc.client.RotateEnvironment(ctx, ref.orgName, ref.projectName, ref.envName, duration) + if err != nil { + return nil, nil, err + } + if len(diags) != 0 { + return nil, diags, err + } + open, err := env.esc.client.GetOpenEnvironmentWithProject(ctx, ref.orgName, ref.projectName, ref.envName, envID) + return open, nil, err +}