Skip to content

Commit

Permalink
Add rotate command (#433)
Browse files Browse the repository at this point in the history
* cleanup

* Add rotate

* Update changelog
  • Loading branch information
seanyeh authored Jan 23, 2025
1 parent 6198f06 commit d038e93
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 2 deletions.
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)
}

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 {
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
}

0 comments on commit d038e93

Please sign in to comment.