diff --git a/lib/steps/workspaces.go b/lib/steps/workspaces.go index bc4ff6a..4de45e7 100644 --- a/lib/steps/workspaces.go +++ b/lib/steps/workspaces.go @@ -3,7 +3,9 @@ package steps import ( "context" "fmt" + "log/slog" + "github.com/posit-dev/ptd/lib/helpers" "github.com/posit-dev/ptd/lib/types" ) @@ -38,6 +40,28 @@ func (s *WorkspacesStep) Run(ctx context.Context) error { return fmt.Errorf("workspaces step can only be run on control room targets") } + // The AWS WorkSpaces environment is opt-out per control room via the + // `workspaces_enabled` config toggle (default on). When a control room sets + // `workspaces_enabled: false`, the whole step is a no-op. This gate is loaded and + // evaluated before any credential fetch or Pulumi stack creation so it applies to + // BOTH the apply and destroy paths: a `--destroy` on a control room that has the + // toggle off is also a no-op (once the stack has been destroyed it stays gone). + // Config is loaded the same way runAWSInlineGo loads it (helpers.ConfigForTarget + + // type-assert to AWSControlRoomConfig), which is the only control-room config kind. + rawConfig, err := helpers.ConfigForTarget(s.DstTarget) + if err != nil { + return fmt.Errorf("workspaces: failed to load config: %w", err) + } + cfg, ok := rawConfig.(types.AWSControlRoomConfig) + if !ok { + return fmt.Errorf("workspaces: expected AWSControlRoomConfig, got %T", rawConfig) + } + if !cfg.WorkspacesIsEnabled() { + slog.Info("skipping workspaces step: workspaces_enabled is false for this control room", + "target", s.DstTarget.Name()) + return nil + } + creds, err := s.DstTarget.Credentials(ctx) if err != nil { return err diff --git a/lib/steps/workspaces_test.go b/lib/steps/workspaces_test.go index 45fe86b..0cfbad1 100644 --- a/lib/steps/workspaces_test.go +++ b/lib/steps/workspaces_test.go @@ -2,14 +2,20 @@ package steps import ( "context" + "errors" + "os" + "path/filepath" "sync" "testing" "github.com/pulumi/pulumi/sdk/v3/go/common/resource" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/posit-dev/ptd/lib/helpers" "github.com/posit-dev/ptd/lib/types" "github.com/posit-dev/ptd/lib/types/typestest" ) @@ -457,3 +463,60 @@ func TestWorkspacesStepNotControlRoom(t *testing.T) { err := step.Run(context.Background()) assert.ErrorContains(t, err, "control room") } + +// writeControlRoomConfig writes a control-room ptd.yaml with the given spec body +// into a temp targets dir and points viper at it for the duration of the test, so +// helpers.ConfigForTarget(target) loads it. specBody is the YAML under `spec:`. +func writeControlRoomConfig(t *testing.T, name, specBody string) { + t.Helper() + dir := t.TempDir() + crDir := filepath.Join(dir, helpers.CtrlDir, name) + require.NoError(t, os.MkdirAll(crDir, 0o755)) + body := "apiVersion: ptd/v1\nkind: AWSControlRoomConfig\nspec:\n" + specBody + require.NoError(t, os.WriteFile(filepath.Join(crDir, "ptd.yaml"), []byte(body), 0o644)) + + orig := viper.GetString("targets_config_dir") + viper.Set("targets_config_dir", dir) + t.Cleanup(func() { viper.Set("targets_config_dir", orig) }) +} + +// TestWorkspacesStepDisabledNoOp verifies that a control room with +// workspaces_enabled: false cleanly no-ops: Run returns nil and never fetches +// credentials or creates a Pulumi stack. This gate covers both the apply and +// destroy paths, since it precedes any credential fetch or stack creation. +func TestWorkspacesStepDisabledNoOp(t *testing.T) { + writeControlRoomConfig(t, "main01-staging", " workspaces_enabled: false\n") + + tgt := mockAWSControlRoomTarget("main01-staging") + + step := &WorkspacesStep{} + step.Set(tgt, nil, StepOptions{}) + err := step.Run(context.Background()) + + require.NoError(t, err) + // The gate must short-circuit before any credential fetch (and therefore + // before any Pulumi stack create/preview/destroy). + tgt.AssertNotCalled(t, "Credentials") +} + +// TestWorkspacesStepEnabledGetsPastGate verifies that a control room with the +// toggle unset (default on) does NOT short-circuit: Run proceeds past the gate and +// attempts to fetch credentials. Credentials are stubbed to fail so the test does +// not need real cloud/Pulumi wiring; asserting Credentials is called proves the +// gate did not skip the step. +func TestWorkspacesStepEnabledGetsPastGate(t *testing.T) { + // No workspaces_enabled key => nil => WorkspacesIsEnabled() true (default on). + writeControlRoomConfig(t, "main01-staging", " region: us-east-1\n") + + tgt := mockAWSControlRoomTarget("main01-staging") + tgt.On("Credentials", mock.Anything). + Return(typestest.DefaultCredentials(), errors.New("creds unavailable in unit test")) + + step := &WorkspacesStep{} + step.Set(tgt, nil, StepOptions{}) + err := step.Run(context.Background()) + + // It got past the gate and tried (and failed) to fetch credentials. + require.ErrorContains(t, err, "creds unavailable in unit test") + tgt.AssertCalled(t, "Credentials", mock.Anything) +} diff --git a/lib/types/controlroom.go b/lib/types/controlroom.go index d5aaa31..b14c225 100644 --- a/lib/types/controlroom.go +++ b/lib/types/controlroom.go @@ -78,6 +78,24 @@ type AWSControlRoomConfig struct { TraefikForwardAuthVersion string `json:"traefik_forward_auth_version" yaml:"traefik_forward_auth_version"` TraefikVersion string `json:"traefik_version" yaml:"traefik_version"` EbsCsiAddonVersion string `json:"ebs_csi_addon_version" yaml:"ebs_csi_addon_version"` + // WorkspacesEnabled toggles the AWS WorkSpaces (control-room `workspaces`) step. + // It is a pointer so an absent field can be distinguished from an explicit false. + // The historical behavior is always-on, so an unset value (nil) must resolve to + // true; only an explicit `workspaces_enabled: false` disables the step. Resolve + // via WorkspacesIsEnabled (nil → true). + WorkspacesEnabled *bool `json:"workspaces_enabled" yaml:"workspaces_enabled"` +} + +// WorkspacesIsEnabled resolves the WorkspacesEnabled flag (default true). Returns +// true when the field is unset (nil) or explicitly true; returns false only when +// WorkspacesEnabled is explicitly set to false. The nil → true default preserves +// the historical always-on behavior of the AWS WorkSpaces step, mirroring how +// EKSAccessEntriesConfig.IsEnabled treats an absent flag as enabled. +func (c AWSControlRoomConfig) WorkspacesIsEnabled() bool { + if c.WorkspacesEnabled == nil { + return true + } + return *c.WorkspacesEnabled } // The following accessor methods return the value from config, or the Python