Skip to content
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
12 changes: 8 additions & 4 deletions cmd/internal/legacy/ptd_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ func TargetFromName(target string) (t types.Target, err error) {
conf.(types.AWSWorkloadConfig).TailscaleEnabled,
conf.(types.AWSWorkloadConfig).CreateAdminPolicyAsResource,
conf.(types.AWSWorkloadConfig).Sites,
conf.(types.AWSWorkloadConfig).Clusters), nil
conf.(types.AWSWorkloadConfig).Clusters,
conf.(types.AWSWorkloadConfig).IgnoreTags), nil
case types.AWSControlRoomConfig:
return aws.NewTarget(
target,
Expand All @@ -149,7 +150,8 @@ func TargetFromName(target string) (t types.Target, err error) {
conf.(types.AWSControlRoomConfig).TailscaleEnabled,
false, // createAdminPolicyAsResource is not relevant for control room targets
nil,
nil), nil
nil,
conf.(types.AWSControlRoomConfig).IgnoreTags), nil
}
return
}
Expand Down Expand Up @@ -178,7 +180,8 @@ func ControlRoomTargetFromName(target string) (t types.Target, err error) {
false, // tailscaleEnabled isn't relevant for control room.
false, // createAdminPolicyAsResource is not relevant for control room targets
nil,
nil), nil
nil,
nil), nil // ignoreTags: control room's own config isn't loaded here
case types.AWSWorkloadConfig:
c := conf.(types.AWSWorkloadConfig)
if c.ControlRoomClusterName == "" {
Expand All @@ -194,7 +197,8 @@ func ControlRoomTargetFromName(target string) (t types.Target, err error) {
c.TailscaleEnabled,
false, // createAdminPolicyAsResource is not relevant for control room targets
nil,
nil), nil
nil,
nil), nil // ignoreTags: control room's own config isn't loaded here
}
return
}
Expand Down
44 changes: 44 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ spec:
rs:owner: team@example.com
rs:project: posit-team
rs:environment: production

# AWS tag keys PTD must never add or remove on managed resources (AWS-only).
# Wired into the Pulumi AWS provider's ignoreTags.keys so customer-applied tags
# (e.g. from an org-wide tagging policy) are left untouched by our IaC. Exact key match.
# Honored on every `ptd ensure`, including `--refresh` (state is not polluted with ignored tags).
ignore_tags:
- customer:cost-center
- customer:owner
```

## Workload Configuration
Expand Down Expand Up @@ -117,6 +125,14 @@ spec:
components:
traefik_forward_auth_version: "0.0.14"

# AWS tag keys PTD must never add or remove on managed resources (AWS-only).
# Wired into the Pulumi AWS provider's ignoreTags.keys so customer-applied tags
# (e.g. from an org-wide tagging policy) are left untouched by our IaC. Exact key match.
# Honored on every `ptd ensure`, including `--refresh` (state is not polluted with ignored tags).
ignore_tags:
- customer:cost-center
- customer:owner

# Sites in this workload
sites:
main:
Expand All @@ -128,6 +144,34 @@ spec:
domain: analytics-dev.example.com
```

## Adopting `ignore_tags` on an existing target

`ignore_tags` is enforced by the AWS provider's `ignoreTags`, which only takes
effect after a **state-persisting** `ptd ensure` (an apply) has registered it on
the target's AWS provider. On the run that first registers it, Pulumi still plans
against the previously-registered provider — so if a matching tag is *already
tracked in Pulumi state*, that apply will strip it once. Follow this procedure
when enabling `ignore_tags` on an existing target:

1. Add the keys to `ignore_tags`, then run `ptd ensure <target> --dry-run`.
2. **If the preview shows an ignored key being removed** (`- <key>` on a
resource), that key is already tracked in state (a prior `--refresh` pulled it
in). Do **not** apply — the apply would strip the customer tag. Remove it from
state first (a state-only `ptd ensure <target> --refresh` filters ignored keys
out of state without modifying AWS), then repeat step 1.
3. **If the preview is clean**, run `ptd ensure <target>` to apply. This registers
`ignoreTags` on the provider with no tag changes.

After this one-time registration, tags matching `ignore_tags` are ignored on
every subsequent `ptd ensure` (including `--refresh`) — never added, removed, or
pulled into state. A key added to `ignore_tags` later requires another
registering `ptd ensure` (on then-clean state) before it is protected.

> **Fleet rollout:** running `ptd ensure` across all targets while their state is
> clean of the configured keys registers `ignoreTags` everywhere, so customer
> tags applied afterward are ignored from then on. Use the `--dry-run` check
> above to catch any target that already has a matching tag in state.

## Site Configuration

**File:** `__work__/<workload>/site_<name>/site.yaml`
Expand Down
13 changes: 13 additions & 0 deletions examples/workload/ptd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ spec:
# AWS region for deployment
region: us-east-2

# Tags applied to every managed AWS resource (cost tracking, ownership, etc.)
# resource_tags:
# rs:owner: team@example.com
# rs:project: posit-team

# AWS tag keys that PTD should never add or remove on managed resources.
# Use this for customer-applied tags (e.g. tags injected by an org-wide tagging
# policy) so our IaC leaves them untouched instead of reverting them on every run.
# Exact key match; AWS-only. Honored on every `ptd ensure`, including `--refresh`.
# ignore_tags:
# - customer:cost-center
# - customer:owner

# RDS PostgreSQL configuration
db_engine_version: "15.18"
db_instance_class: db.m5d.large
Expand Down
2 changes: 1 addition & 1 deletion lib/attestation/attestation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ func TestCollectBootstrapResourcesAWS(t *testing.T) {
}
// createAdminPolicyAsResource is set explicitly so the expected resource
// count is deterministic (the admin policy is only emitted when true).
target := aws.NewTarget("example01-production", "123456789012", "example-profile", nil, "us-east-2", false, false, true, sites, nil)
target := aws.NewTarget("example01-production", "123456789012", "example-profile", nil, "us-east-2", false, false, true, sites, nil, nil)

res := collectBootstrapResources(target)

Expand Down
8 changes: 7 additions & 1 deletion lib/aws/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ type Target struct {
createAdminPolicyAsResource bool
skipControlRoomMimirPasswordWrite bool
sites map[string]types.SiteConfig
ignoreTags []string

// clusters is currently only relevant/supported for aws.
Clusters map[string]types.AWSWorkloadClusterConfig
}

func NewTarget(targetName string, accountID string, profile string, customRole *types.CustomRoleConfig, region string, isControlRoom bool, tailscaleEnabled bool, createAdminPolicyAsResource bool, sites map[string]types.SiteConfig, clusters map[string]types.AWSWorkloadClusterConfig) Target {
func NewTarget(targetName string, accountID string, profile string, customRole *types.CustomRoleConfig, region string, isControlRoom bool, tailscaleEnabled bool, createAdminPolicyAsResource bool, sites map[string]types.SiteConfig, clusters map[string]types.AWSWorkloadClusterConfig, ignoreTags []string) Target {
if region == "" {
region = "us-east-2"
}
Expand All @@ -52,6 +53,7 @@ func NewTarget(targetName string, accountID string, profile string, customRole *
createAdminPolicyAsResource: createAdminPolicyAsResource,
sites: sites,
Clusters: clusters,
ignoreTags: ignoreTags,
}
}

Expand Down Expand Up @@ -133,6 +135,10 @@ func (t Target) TailscaleEnabled() bool {
return t.tailscaleEnabled
}

func (t Target) IgnoreTags() []string {
return t.ignoreTags
}

func (t Target) CreateAdminPolicyAsResource() bool {
return t.createAdminPolicyAsResource
}
Expand Down
7 changes: 7 additions & 0 deletions lib/aws/target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type MockTarget struct {
controlRoom bool
sites map[string]types.SiteConfig
tailscaleEnabled bool
ignoreTags []string
}

func (m *MockTarget) Name() string {
Expand Down Expand Up @@ -59,6 +60,10 @@ func (m *MockTarget) TailscaleEnabled() bool {
return m.tailscaleEnabled
}

func (m *MockTarget) IgnoreTags() []string {
return m.ignoreTags
}

func (m *MockTarget) PulumiBackendUrl() string {
return ""
}
Expand Down Expand Up @@ -164,6 +169,7 @@ func TestTarget(t *testing.T) {
}},
},
nil, // clusters
nil, // ignoreTags
)

// Test basic accessor methods
Expand Down Expand Up @@ -220,6 +226,7 @@ func TestTargetWithProfile(t *testing.T) {
}},
},
nil, // clusters
nil, // ignoreTags
)

// Test that the target is created with the profile
Expand Down
5 changes: 5 additions & 0 deletions lib/azure/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ func (t Target) TailscaleEnabled() bool {
return false
}

func (t Target) IgnoreTags() []string {
// ignore_tags is AWS-only; the Azure provider has no equivalent.
return nil
}

func (t Target) Clusters() map[string]types.AzureWorkloadClusterConfig {
return t.clusters
}
Expand Down
44 changes: 42 additions & 2 deletions lib/pulumi/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ type StackConfig struct {
BackendURL string
SecretsProvider string
EnvVars map[string]string
// IgnoreTags is the list of exact AWS tag keys the default AWS provider should
// leave untouched on managed resources. AWS-only; empty for other clouds.
IgnoreTags []string
}

// buildProjectName creates a standardized project name
Expand Down Expand Up @@ -63,13 +66,50 @@ func (c *StackConfig) EnvVarsOption() auto.LocalWorkspaceOption {
return auto.EnvVars(c.EnvVars)
}

// ConfigureStackRegion sets the appropriate region configuration for the stack based on cloud provider
func ConfigureStackRegion(ctx context.Context, stack auto.Stack, cloud string, region string) error {
// ignoreTagsConfigEntry is a single path-config key/value pair used to populate the
// AWS provider's ignoreTags.keys list.
type ignoreTagsConfigEntry struct {
Path string
Value string
}

// awsIgnoreTagsConfig maps a flat list of tag keys to the ordered path-config entries
// (aws:ignoreTags.keys[i] -> key) that wire them into the default AWS provider's ignoreTags.
// Extracted as a pure function so the path construction can be unit-tested without a live stack.
func awsIgnoreTagsConfig(ignoreTags []string) []ignoreTagsConfigEntry {
entries := make([]ignoreTagsConfigEntry, 0, len(ignoreTags))
for i, key := range ignoreTags {
entries = append(entries, ignoreTagsConfigEntry{
Path: fmt.Sprintf("aws:ignoreTags.keys[%d]", i),
Value: key,
})
}
return entries
}

// ConfigureStackRegion sets the appropriate region configuration for the stack based on cloud provider.
// For AWS, any ignoreTags keys are wired into the default provider's aws:ignoreTags.keys so the
// listed customer tag keys are never added or removed on managed resources.
func ConfigureStackRegion(ctx context.Context, stack auto.Stack, cloud string, region string, ignoreTags []string) error {
switch cloud {
case "aws":
if err := stack.SetConfig(ctx, "aws:region", auto.ConfigValue{Value: region}); err != nil {
return fmt.Errorf("failed to set AWS region: %w", err)
}
// Stack() creates an ephemeral workspace per invocation (auto.NewLocalWorkspace
// with an inline program and no WorkDir → a fresh temp dir), so the config
// below is rebuilt from scratch every run and always reflects exactly the
// current ignore_tags list. There are no stale keys[i] entries to clean up
// when the list shrinks. (If this ever moves to a persistent WorkDir, the
// aws:ignoreTags key would need to be removed before rewriting it.)
for _, entry := range awsIgnoreTagsConfig(ignoreTags) {
if err := stack.SetConfigWithOptions(ctx,
entry.Path,
auto.ConfigValue{Value: entry.Value},
&auto.ConfigOptions{Path: true}); err != nil {
return fmt.Errorf("failed to set AWS ignoreTags: %w", err)
}
}
case "azure":
if err := stack.SetConfig(ctx, "azure-native:location", auto.ConfigValue{Value: region}); err != nil {
return fmt.Errorf("failed to set Azure location: %w", err)
Expand Down
3 changes: 2 additions & 1 deletion lib/pulumi/inline.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func Stack(ctx context.Context, baseName string, target types.Target, program fu
BackendURL: target.PulumiBackendUrl(),
SecretsProvider: target.PulumiSecretsProviderKey(),
EnvVars: allEnvVars,
IgnoreTags: target.IgnoreTags(),
}

slog.Info("Pulumi stack", "project", config.ProjectName(), "stack", config.StackName())
Expand Down Expand Up @@ -70,7 +71,7 @@ func Stack(ctx context.Context, baseName string, target types.Target, program fu
return auto.Stack{}, fmt.Errorf("failed to initialize stack %s: %w", config.StackName(), err)
}

if err := ConfigureStackRegion(ctx, stack, config.Cloud, config.TargetRegion); err != nil {
if err := ConfigureStackRegion(ctx, stack, config.Cloud, config.TargetRegion, config.IgnoreTags); err != nil {
return auto.Stack{}, err
}

Expand Down
3 changes: 2 additions & 1 deletion lib/pulumi/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ func LocalStack(
return stack, err
}

if err := ConfigureStackRegion(ctx, stack, config.Cloud, config.TargetRegion); err != nil {
// LocalStack backs state-only workspaces (no pulumi up), so provider ignoreTags is irrelevant.
if err := ConfigureStackRegion(ctx, stack, config.Cloud, config.TargetRegion, nil); err != nil {
return stack, err
}

Expand Down
33 changes: 33 additions & 0 deletions lib/pulumi/pulumi.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ func pulumiDebugLogging() *debug.LoggingOptions {
}
}

// shouldRunProgramOnRefresh reports whether a refresh should re-run the program
// (optrefresh.RunProgram(true)) based on the stack's config. It returns true only
// when aws:ignoreTags is set to a non-empty value, so the AWS provider is
// reconfigured with ignoreTags during refresh (pulumi/pulumi#13860). Refresh
// behavior is left unchanged for every stack that does not set ignoreTags.
//
// ConfigureStackRegion writes ignoreTags as nested path config
// (aws:ignoreTags.keys[i]); this relies on GetAllConfig surfacing those entries
// merged under the aggregated aws:ignoreTags key, which is asserted in
// TestShouldRunProgramOnRefresh.
func shouldRunProgramOnRefresh(cfg map[string]auto.ConfigValue) bool {
v, ok := cfg["aws:ignoreTags"]
return ok && v.Value != ""
}

func RefreshStack(ctx context.Context, stack auto.Stack) (refreshResult auto.RefreshResult, err error) {
refreshOpts := []optrefresh.Option{
optrefresh.Color("always"),
Expand All @@ -42,6 +57,24 @@ func RefreshStack(ctx context.Context, stack auto.Stack) (refreshResult auto.Ref
optrefresh.ClearPendingCreates(),
}

// When aws:ignoreTags is configured on this stack, run the program during
// refresh so the AWS provider is (re)configured with it. Without this,
// refresh reuses the provider config recorded at the last `up`
// (pulumi/pulumi#13860), so ignoreTags is not honored during refresh and
// ignored customer tags get pulled into state and diffed away on the next
// up. Scoped to stacks that set ignoreTags so refresh behavior is unchanged
// for every other workload.
if allCfg, cfgErr := stack.GetAllConfig(ctx); cfgErr != nil {
// Don't silently degrade: if we can't read config we can't tell whether
// ignoreTags is set, so RunProgram is skipped and a stack that relies on
// ignoreTags would fall back to the broken refresh behavior
// (pulumi/pulumi#13860). Surface it so the operator knows why.
slog.Warn("could not read stack config to decide refresh RunProgram; ignoreTags may not be honored on this refresh",
"stack", stack.Name(), "error", cfgErr)
} else if shouldRunProgramOnRefresh(allCfg) {
refreshOpts = append(refreshOpts, optrefresh.RunProgram(true))
}

if debugOpts := pulumiDebugLogging(); debugOpts != nil {
refreshOpts = append(refreshOpts, optrefresh.DebugLogging(*debugOpts))
}
Expand Down
Loading
Loading