Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rotate command #433

Merged
merged 3 commits into from
Jan 23, 2025
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
2 changes: 2 additions & 0 deletions CHANGELOG_PENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions cmd/esc/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, not sure I understand why RotateEnvironment calls open? Is this a typo?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is for the test client for tests. I don't know exactly how it should do a fake "rotate" so I'm assuming a no-op and calling open (since rotating calls open)

}

func (c *testPulumiClient) CheckYAMLEnvironment(
ctx context.Context,
orgName string,
Expand Down
47 changes: 46 additions & 1 deletion cmd/esc/cli/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: could maybe move these queryObj and resp structs out to reuse between functions, not sure how many times these are used

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,
Expand Down
70 changes: 70 additions & 0 deletions cmd/esc/cli/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}`)
Expand Down
1 change: 1 addition & 0 deletions cmd/esc/cli/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion cmd/esc/cli/env_open.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ func newEnvOpenCmd(envcmd *envCommand) *cobra.Command {
if err != nil {
return err
}
_ = args

var path resource.PropertyPath
if len(args) == 1 {
Expand Down
88 changes: 88 additions & 0 deletions cmd/esc/cli/env_rotate.go
Original file line number Diff line number Diff line change
@@ -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 [<org-name>/][<project-name>/]<environment-name>",
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
}
Loading