Skip to content

Commit b783e3e

Browse files
authored
Add esc env settings command with get and set subcommands (#595)
* Add `esc env settings` command with `get` and `set` subcommands Fixes pulumi/pulumi-service#33364 Adds `esc env settings get` and `esc env settings set` commands to manage environment settings. Currently supports the `deletion-protected` setting, which prevents environments from being deleted when set to `true`. The default remains `false` for new environments. ### Usage ```bash # Get all settings $ esc env settings get myorg/myproject/prod deletion-protected true # Get a specific setting $ esc env settings get myorg/myproject/prod deletion-protected true # Set deletion protection $ esc env settings set myorg/myproject/prod deletion-protected true true ``` The `get` command returns all settings in space-separated format when no setting name is provided, or just the value when a specific setting is requested. The `set` command works with single key-value pairs. ### Implementation The implementation uses Go generics to keep setting implementations fully typed—each setting implements `Setting[T]` with its concrete type (e.g., `bool`). Settings are wrapped with `settingBox[T]` at registration time to convert them to `UntypedSetting` for homogeneous storage in the registry map. ### Test Script The following script tests the deletion protection functionality end-to-end: ```bash #!/usr/bin/env bash set -ex # Test script for environment settings (deletion protection) # # Required environment variables: # PULUMI_ACCESS_TOKEN - Access token for the review stack # PULUMI_API - Backend API URL (e.g., https://api-fnune-review.review-stacks.pulumi-dev.io) # # Usage: # export PULUMI_ACCESS_TOKEN="your-token-here" # export PULUMI_API="https://api-fnune-review.review-stacks.pulumi-dev.io" # ./esc login $PULUMI_API # ./test.sh # # You can test with any of these environments from the review stack: # pulumi_local/2025-08-18-test-nocode1/dev # pulumi_local/csharp-documented-test-no-code/dev # pulumi_local/fearless-copper-fossa-nocode4/dev # pulumi_local/innovative-rhenium-pangolin/dev # pulumi_local/inspiring-ruby-quokka-nocode3/dev if [ -z "$PULUMI_ACCESS_TOKEN" ]; then echo "Error: PULUMI_ACCESS_TOKEN is not set" exit 1 fi if [ -z "$PULUMI_API" ]; then echo "Error: PULUMI_API is not set" exit 1 fi ENV_NAME="${1:-pulumi_local/2025-08-18-test-nocode1/dev}" echo "Building esc CLI..." go build -o ./esc ./cmd/esc echo "" echo "Testing environment settings commands with: $ENV_NAME" echo "Backend: $PULUMI_API" echo "" echo "=== Test 1: Get all settings ===" ./esc env settings get "$ENV_NAME" echo "" echo "=== Test 2: Get deletion-protected setting ===" ./esc env settings get "$ENV_NAME" deletion-protected echo "" echo "=== Test 3: Enable deletion protection ===" ./esc env settings set "$ENV_NAME" deletion-protected true echo "" echo "=== Test 4: Verify deletion protection is enabled ===" ./esc env settings get "$ENV_NAME" deletion-protected echo "" echo "=== Test 5: Attempt to delete protected environment (should fail) ===" if ./esc env rm "$ENV_NAME" --yes 2>&1 | tee /dev/stderr | grep -q "deletion protection is enabled"; then echo "✓ Deletion correctly blocked" else echo "✗ Deletion should have been blocked" exit 1 fi echo "" echo "=== Test 6: Disable deletion protection ===" ./esc env settings set "$ENV_NAME" deletion-protected false echo "" echo "=== Test 7: Verify deletion protection is disabled ===" ./esc env settings get "$ENV_NAME" deletion-protected echo "" echo "=== Test 8: Test invalid value (should fail) ===" if ./esc env settings set "$ENV_NAME" deletion-protected invalid 2>&1 | tee /dev/stderr | grep -q "expected true or false"; then echo "✓ Invalid value correctly rejected" else echo "✗ Invalid value should have been rejected" exit 1 fi echo "" echo "=== Test 9: Test unknown setting (should fail) ===" if ./esc env settings get "$ENV_NAME" unknown-setting 2>&1 | tee /dev/stderr | grep -q "unknown setting name"; then echo "✓ Unknown setting correctly rejected" else echo "✗ Unknown setting should have been rejected" exit 1 fi echo "" echo "All tests passed! ✓" ``` * Add minimal enforcement of interface compatibility with env set/get * Stop masking 409s unrelated to del protection, add comment about parsing
1 parent 6391eb4 commit b783e3e

20 files changed

+679
-5
lines changed

CHANGELOG_PENDING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
### Improvements
22

33
- Added support for Open Approvals [#592](https://github.com/pulumi/esc/pull/592)
4+
- Added deletion protection for environments:
5+
- Use `esc env settings set [<org-name>/][<project-name>/]<environment-name> deletion-protected true` to enable deletion protection
6+
- Use `esc env settings get [<org-name>/][<project-name>/]<environment-name> [deletion-protected]` to check the current status
7+
- When enabled, environments cannot be deleted until protection is disabled
8+
- Deletion protection is disabled by default for new environments
49

510
### Bug Fixes
611

cmd/esc/cli/cli_test.go

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,10 @@ type testEnvironmentRevision struct {
272272
}
273273

274274
type testEnvironment struct {
275-
revisions []*testEnvironmentRevision
276-
revisionTags map[string]int
277-
tags map[string]string
275+
revisions []*testEnvironmentRevision
276+
revisionTags map[string]int
277+
tags map[string]string
278+
deletionProtected bool
278279
}
279280

280281
func (env *testEnvironment) latest() *testEnvironmentRevision {
@@ -737,9 +738,13 @@ func (c *testPulumiClient) SubmitChangeRequest(
737738

738739
func (c *testPulumiClient) DeleteEnvironment(ctx context.Context, orgName, projectName, envName string) error {
739740
name := path.Join(orgName, projectName, envName)
740-
if _, ok := c.environments[name]; !ok {
741+
env, ok := c.environments[name]
742+
if !ok {
741743
return errors.New("not found")
742744
}
745+
if env.deletionProtected {
746+
return &apitype.ErrorResponse{Code: http.StatusConflict, Message: "environment is deletion protected"}
747+
}
743748
delete(c.environments, name)
744749
return nil
745750
}
@@ -1196,6 +1201,40 @@ func (c *testPulumiClient) CreateEnvironmentOpenRequest(
11961201
}, nil
11971202
}
11981203

1204+
func (c *testPulumiClient) GetEnvironmentSettings(
1205+
ctx context.Context,
1206+
orgName string,
1207+
projectName string,
1208+
envName string,
1209+
) (*client.EnvironmentSettings, error) {
1210+
name := path.Join(orgName, projectName, envName)
1211+
env, ok := c.environments[name]
1212+
if !ok {
1213+
return nil, errors.New("not found")
1214+
}
1215+
return &client.EnvironmentSettings{
1216+
DeletionProtected: env.deletionProtected,
1217+
}, nil
1218+
}
1219+
1220+
func (c *testPulumiClient) PatchEnvironmentSettings(
1221+
ctx context.Context,
1222+
orgName string,
1223+
projectName string,
1224+
envName string,
1225+
req client.PatchEnvironmentSettingsRequest,
1226+
) error {
1227+
name := path.Join(orgName, projectName, envName)
1228+
env, ok := c.environments[name]
1229+
if !ok {
1230+
return errors.New("not found")
1231+
}
1232+
if req.DeletionProtected != nil {
1233+
env.deletionProtected = *req.DeletionProtected
1234+
}
1235+
return nil
1236+
}
1237+
11991238
type testExec struct {
12001239
fs testFS
12011240
environ map[string]string

cmd/esc/cli/client/apitype.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,11 @@ type GetDefaultOrganizationResponse struct {
241241
// Can be an empty string, if the user is a member of no organizations
242242
Organization string `json:"gitHubLogin"`
243243
}
244+
245+
type EnvironmentSettings struct {
246+
DeletionProtected bool `json:"deletionProtected"`
247+
}
248+
249+
type PatchEnvironmentSettingsRequest struct {
250+
DeletionProtected *bool `json:"deletionProtected,omitempty"`
251+
}

cmd/esc/cli/client/client.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,23 @@ type Client interface {
387387
grantExpirationSeconds int,
388388
accessDurationSeconds int,
389389
) (*CreateEnvironmentOpenRequestResponse, error)
390+
391+
// GetEnvironmentSettings returns settings for the given environment.
392+
GetEnvironmentSettings(
393+
ctx context.Context,
394+
orgName string,
395+
projectName string,
396+
envName string,
397+
) (*EnvironmentSettings, error)
398+
399+
// PatchEnvironmentSettings updates settings for the given environment.
400+
PatchEnvironmentSettings(
401+
ctx context.Context,
402+
orgName string,
403+
projectName string,
404+
envName string,
405+
req PatchEnvironmentSettingsRequest,
406+
) error
390407
}
391408

392409
type client struct {
@@ -1303,6 +1320,32 @@ func (pc *client) CreateEnvironmentOpenRequest(
13031320
return &resp, nil
13041321
}
13051322

1323+
func (pc *client) GetEnvironmentSettings(
1324+
ctx context.Context,
1325+
orgName string,
1326+
projectName string,
1327+
envName string,
1328+
) (*EnvironmentSettings, error) {
1329+
path := fmt.Sprintf("/api/esc/environments/%v/%v/%v/settings", orgName, projectName, envName)
1330+
var resp EnvironmentSettings
1331+
err := pc.restCall(ctx, http.MethodGet, path, nil, nil, &resp)
1332+
if err != nil {
1333+
return nil, err
1334+
}
1335+
return &resp, nil
1336+
}
1337+
1338+
func (pc *client) PatchEnvironmentSettings(
1339+
ctx context.Context,
1340+
orgName string,
1341+
projectName string,
1342+
envName string,
1343+
req PatchEnvironmentSettingsRequest,
1344+
) error {
1345+
path := fmt.Sprintf("/api/esc/environments/%v/%v/%v/settings", orgName, projectName, envName)
1346+
return pc.restCall(ctx, http.MethodPatch, path, nil, req, nil)
1347+
}
1348+
13061349
type httpCallOptions struct {
13071350
// RetryPolicy defines the policy for retrying requests by httpClient.Do.
13081351
//

cmd/esc/cli/env.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func newEnvCmd(esc *escCommand) *cobra.Command {
7171
cmd.AddCommand(newEnvVersionCmd(env))
7272
cmd.AddCommand(newEnvLsCmd(env))
7373
cmd.AddCommand(newEnvTagCmd((env)))
74+
cmd.AddCommand(newEnvSettingsCmd(env))
7475
cmd.AddCommand(newEnvRmCmd(env))
7576
cmd.AddCommand(newEnvOpenCmd(env))
7677
cmd.AddCommand(newEnvOpenRequestCmd(env))

cmd/esc/cli/env_rm.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import (
66
"context"
77
"errors"
88
"fmt"
9+
"net/http"
910
"os"
11+
"strings"
1012

1113
"github.com/spf13/cobra"
1214
"gopkg.in/yaml.v3"
1315

1416
"github.com/pulumi/esc/syntax/encoding"
17+
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
1518
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
1619
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
1720
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
@@ -64,6 +67,10 @@ func newEnvRmCmd(env *envCommand) *cobra.Command {
6467

6568
err = env.esc.client.DeleteEnvironment(ctx, ref.orgName, ref.projectName, ref.envName)
6669
if err != nil {
70+
var errResp *apitype.ErrorResponse
71+
if errors.As(err, &errResp) && errResp.Code == http.StatusConflict && strings.Contains(errResp.Message, "protect") {
72+
return fmt.Errorf("cannot delete environment: deletion protection is enabled. Disable deletion protection with 'esc env settings set %s deletion-protected false' before deleting", envSlug)
73+
}
6774
return err
6875
}
6976

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright 2025, Pulumi Corporation.
2+
3+
package cli
4+
5+
import (
6+
"fmt"
7+
8+
"github.com/pulumi/esc/cmd/esc/cli/client"
9+
)
10+
11+
const settingDeletionProtected settingName = "deletion-protected"
12+
13+
type DeletionProtectedSetting struct{}
14+
15+
func (s *DeletionProtectedSetting) KebabName() string {
16+
return "deletion-protected"
17+
}
18+
19+
func (s *DeletionProtectedSetting) HelpText() string {
20+
return "Enable or disable deletion protection"
21+
}
22+
23+
// ValidateValue accepts only "true" and "false" strings, unlike the general env {get,set} commands
24+
// which parse YAML and accept broader boolean values like "yes", "no", "on", "off", etc.
25+
// This restriction maintains compatibility while limiting the accepted subset to a well-defined
26+
// interface that can be reliably parsed and validated.
27+
func (s *DeletionProtectedSetting) ValidateValue(raw string) (bool, error) {
28+
if raw != "true" && raw != "false" {
29+
return false, fmt.Errorf("invalid value for %s: %s (expected true or false)", s.KebabName(), raw)
30+
}
31+
return raw == "true", nil
32+
}
33+
34+
func (s *DeletionProtectedSetting) GetValue(settings *client.EnvironmentSettings) bool {
35+
return settings.DeletionProtected
36+
}
37+
38+
func (s *DeletionProtectedSetting) SetValue(req *client.PatchEnvironmentSettingsRequest, value bool) {
39+
req.DeletionProtected = &value
40+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright 2025, Pulumi Corporation.
2+
3+
package cli
4+
5+
import (
6+
"testing"
7+
8+
"github.com/pulumi/esc/cmd/esc/cli/client"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestDeletionProtectedSetting(t *testing.T) {
13+
s := &DeletionProtectedSetting{}
14+
15+
t.Run("ValidateValue", func(t *testing.T) {
16+
t.Run("valid true", func(t *testing.T) {
17+
value, err := s.ValidateValue("true")
18+
assert.NoError(t, err)
19+
assert.Equal(t, true, value)
20+
})
21+
22+
t.Run("valid false", func(t *testing.T) {
23+
value, err := s.ValidateValue("false")
24+
assert.NoError(t, err)
25+
assert.Equal(t, false, value)
26+
})
27+
28+
t.Run("invalid value", func(t *testing.T) {
29+
_, err := s.ValidateValue("yes")
30+
assert.Error(t, err)
31+
assert.Contains(t, err.Error(), "invalid value for deletion-protected")
32+
})
33+
34+
t.Run("case sensitive", func(t *testing.T) {
35+
_, err := s.ValidateValue("True")
36+
assert.Error(t, err)
37+
})
38+
})
39+
40+
t.Run("GetSetValue", func(t *testing.T) {
41+
t.Run("true", func(t *testing.T) {
42+
req := &client.PatchEnvironmentSettingsRequest{}
43+
s.SetValue(req, true)
44+
assert.NotNil(t, req.DeletionProtected)
45+
assert.True(t, *req.DeletionProtected)
46+
47+
settings := &client.EnvironmentSettings{DeletionProtected: true}
48+
assert.Equal(t, true, s.GetValue(settings))
49+
})
50+
51+
t.Run("false", func(t *testing.T) {
52+
req := &client.PatchEnvironmentSettingsRequest{}
53+
s.SetValue(req, false)
54+
assert.NotNil(t, req.DeletionProtected)
55+
assert.False(t, *req.DeletionProtected)
56+
57+
settings := &client.EnvironmentSettings{DeletionProtected: false}
58+
assert.Equal(t, false, s.GetValue(settings))
59+
})
60+
})
61+
}

cmd/esc/cli/env_settings.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright 2025, Pulumi Corporation.
2+
3+
package cli
4+
5+
import (
6+
"github.com/spf13/cobra"
7+
)
8+
9+
func newEnvSettingsCmd(env *envCommand) *cobra.Command {
10+
registry := NewEnvSettingsRegistry()
11+
12+
cmd := &cobra.Command{
13+
Use: "settings",
14+
Short: "Manage environment settings",
15+
Long: "Manage environment settings\n" +
16+
"\n" +
17+
"This command manages environment settings such as deletion protection.\n" +
18+
"\n" +
19+
"Subcommands exist for reading and updating settings.",
20+
Args: cobra.NoArgs,
21+
}
22+
23+
cmd.AddCommand(newEnvSettingsGetCmd(env, registry))
24+
cmd.AddCommand(newEnvSettingsSetCmd(env, registry))
25+
26+
return cmd
27+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright 2025, Pulumi Corporation.
2+
3+
package cli
4+
5+
import (
6+
"strings"
7+
"testing"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/spf13/pflag"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestSettingsFlagsAreSubsetOfEnvFlags(t *testing.T) {
16+
tests := []struct {
17+
parent []string
18+
subset []string
19+
}{
20+
{parent: []string{"env", "set"}, subset: []string{"env", "settings", "set"}},
21+
{parent: []string{"env", "get"}, subset: []string{"env", "settings", "get"}},
22+
}
23+
24+
esc := New(&Options{})
25+
26+
for _, tt := range tests {
27+
t.Run(tt.subset[len(tt.subset)-1], func(t *testing.T) {
28+
parentCmd := findCommand(esc, tt.parent)
29+
subsetCmd := findCommand(esc, tt.subset)
30+
31+
require.NotNil(t, parentCmd)
32+
require.NotNil(t, subsetCmd)
33+
34+
parentFlags := getFlagNames(parentCmd)
35+
subsetFlags := getFlagNames(subsetCmd)
36+
37+
for _, flag := range subsetFlags {
38+
assert.Contains(t, parentFlags, flag,
39+
"%s has flag --%s which doesn't exist in %s. "+
40+
"If this is a deliberate product decision, update or remove this test.",
41+
strings.Join(tt.subset, " "), flag, strings.Join(tt.parent, " "))
42+
}
43+
})
44+
}
45+
}
46+
47+
func getFlagNames(cmd *cobra.Command) []string {
48+
var names []string
49+
cmd.Flags().VisitAll(func(f *pflag.Flag) {
50+
names = append(names, f.Name)
51+
})
52+
return names
53+
}
54+
55+
func findCommand(root *cobra.Command, path []string) *cobra.Command {
56+
cmd := root
57+
for _, part := range path {
58+
found := false
59+
for _, subCmd := range cmd.Commands() {
60+
if subCmd.Name() == part {
61+
cmd = subCmd
62+
found = true
63+
break
64+
}
65+
}
66+
if !found {
67+
return nil
68+
}
69+
}
70+
return cmd
71+
}

0 commit comments

Comments
 (0)