diff --git a/cmd/internal/legacy/ptd_config.go b/cmd/internal/legacy/ptd_config.go index f70520b5..30abbc63 100644 --- a/cmd/internal/legacy/ptd_config.go +++ b/cmd/internal/legacy/ptd_config.go @@ -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, @@ -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 } @@ -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 == "" { @@ -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 } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7a808ff0..887f343e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 @@ -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: @@ -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 --dry-run`. +2. **If the preview shows an ignored key being removed** (`- ` 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 --refresh` filters ignored keys + out of state without modifying AWS), then repeat step 1. +3. **If the preview is clean**, run `ptd ensure ` 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__//site_/site.yaml` diff --git a/examples/workload/ptd.yaml b/examples/workload/ptd.yaml index d61577d5..731d7dc6 100644 --- a/examples/workload/ptd.yaml +++ b/examples/workload/ptd.yaml @@ -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 diff --git a/lib/attestation/attestation_test.go b/lib/attestation/attestation_test.go index 4540929c..b3038898 100644 --- a/lib/attestation/attestation_test.go +++ b/lib/attestation/attestation_test.go @@ -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) diff --git a/lib/aws/target.go b/lib/aws/target.go index 03c5be79..569d7b16 100644 --- a/lib/aws/target.go +++ b/lib/aws/target.go @@ -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" } @@ -52,6 +53,7 @@ func NewTarget(targetName string, accountID string, profile string, customRole * createAdminPolicyAsResource: createAdminPolicyAsResource, sites: sites, Clusters: clusters, + ignoreTags: ignoreTags, } } @@ -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 } diff --git a/lib/aws/target_test.go b/lib/aws/target_test.go index 604ccc18..eca7346b 100644 --- a/lib/aws/target_test.go +++ b/lib/aws/target_test.go @@ -17,6 +17,7 @@ type MockTarget struct { controlRoom bool sites map[string]types.SiteConfig tailscaleEnabled bool + ignoreTags []string } func (m *MockTarget) Name() string { @@ -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 "" } @@ -164,6 +169,7 @@ func TestTarget(t *testing.T) { }}, }, nil, // clusters + nil, // ignoreTags ) // Test basic accessor methods @@ -220,6 +226,7 @@ func TestTargetWithProfile(t *testing.T) { }}, }, nil, // clusters + nil, // ignoreTags ) // Test that the target is created with the profile diff --git a/lib/azure/target.go b/lib/azure/target.go index f71cb882..cc4c2229 100644 --- a/lib/azure/target.go +++ b/lib/azure/target.go @@ -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 } diff --git a/lib/pulumi/common.go b/lib/pulumi/common.go index d95fa51a..15295a9f 100644 --- a/lib/pulumi/common.go +++ b/lib/pulumi/common.go @@ -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 @@ -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) diff --git a/lib/pulumi/inline.go b/lib/pulumi/inline.go index c7641347..ad5fe5ed 100644 --- a/lib/pulumi/inline.go +++ b/lib/pulumi/inline.go @@ -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()) @@ -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 } diff --git a/lib/pulumi/local.go b/lib/pulumi/local.go index 17fa1f09..28e849d8 100644 --- a/lib/pulumi/local.go +++ b/lib/pulumi/local.go @@ -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 } diff --git a/lib/pulumi/pulumi.go b/lib/pulumi/pulumi.go index 53928564..d14acb53 100644 --- a/lib/pulumi/pulumi.go +++ b/lib/pulumi/pulumi.go @@ -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"), @@ -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)) } diff --git a/lib/pulumi/pulumi_test.go b/lib/pulumi/pulumi_test.go index 4bd9d259..085cf4d5 100644 --- a/lib/pulumi/pulumi_test.go +++ b/lib/pulumi/pulumi_test.go @@ -3,6 +3,7 @@ package pulumi import ( "testing" + "github.com/pulumi/pulumi/sdk/v3/go/auto" "github.com/stretchr/testify/assert" ) @@ -55,6 +56,48 @@ func TestK8sEnvVars(t *testing.T) { assert.Equal(t, 3, len(envVars)) } +// TestAWSIgnoreTagsConfig verifies the pure path-config construction for the AWS +// provider's ignoreTags.keys list. The SetConfigWithOptions call inside +// ConfigureStackRegion needs a live automation-API stack (real backend), so only +// this pure helper is unit-tested; the loop simply feeds these entries to the stack. +func TestAWSIgnoreTagsConfig(t *testing.T) { + t.Run("empty list yields no entries", func(t *testing.T) { + assert.Empty(t, awsIgnoreTagsConfig(nil)) + assert.Empty(t, awsIgnoreTagsConfig([]string{})) + }) + + t.Run("keys become indexed path config", func(t *testing.T) { + entries := awsIgnoreTagsConfig([]string{"customer:cost-center", "customer:owner"}) + assert.Equal(t, []ignoreTagsConfigEntry{ + {Path: "aws:ignoreTags.keys[0]", Value: "customer:cost-center"}, + {Path: "aws:ignoreTags.keys[1]", Value: "customer:owner"}, + }, entries) + }) +} + +// TestShouldRunProgramOnRefresh verifies the decision gate that enables +// optrefresh.RunProgram(true) only when aws:ignoreTags is configured with a +// non-empty value, so refresh behavior is unchanged for every other stack. +func TestShouldRunProgramOnRefresh(t *testing.T) { + t.Run("key absent yields false", func(t *testing.T) { + assert.False(t, shouldRunProgramOnRefresh(map[string]auto.ConfigValue{ + "aws:region": {Value: "us-east-2"}, + })) + }) + + t.Run("key present with non-empty value yields true", func(t *testing.T) { + assert.True(t, shouldRunProgramOnRefresh(map[string]auto.ConfigValue{ + "aws:ignoreTags": {Value: "{\"keys\":[\"customer:cost-center\"]}"}, + })) + }) + + t.Run("key present with empty value yields false", func(t *testing.T) { + assert.False(t, shouldRunProgramOnRefresh(map[string]auto.ConfigValue{ + "aws:ignoreTags": {Value: ""}, + })) + }) +} + // We're skipping the BackendUrl test as it would require extensive mocking // of the types.Target interface. Testing would require creating a fully compliant // mock implementation of the Target interface with all required methods. diff --git a/lib/steps/workspaces_aws.go b/lib/steps/workspaces_aws.go index e8c22e87..7b9aa7df 100644 --- a/lib/steps/workspaces_aws.go +++ b/lib/steps/workspaces_aws.go @@ -40,6 +40,9 @@ type awsWorkspacesParams struct { compoundName string trustedUsers []types.TrustedUser requiredTags map[string]string + // ignoreTags are exact AWS tag keys the explicit us-east-1 provider must leave + // untouched on managed resources. Threaded via params to match this file's convention. + ignoreTags []string } func (s *WorkspacesStep) runAWSInlineGo(ctx context.Context, creds types.Credentials, envVars map[string]string) error { @@ -73,6 +76,7 @@ func (s *WorkspacesStep) runAWSInlineGo(ctx context.Context, creds types.Credent compoundName: s.DstTarget.Name(), trustedUsers: cfg.TrustedUsers, requiredTags: requiredTags, + ignoreTags: s.DstTarget.IgnoreTags(), } stack, err := createStack(ctx, s.Name(), s.DstTarget, func(pctx *pulumi.Context, target types.Target) error { @@ -130,9 +134,15 @@ func awsWorkspacesDeploy(pctx *pulumi.Context, target types.Target, params awsWo // Create an explicit us-east-1 provider so workspaces resources go to the // right region regardless of the control room's native region. - use1Provider, err := awsprovider.NewProvider(pctx, "use1", &awsprovider.ProviderArgs{ + use1Args := &awsprovider.ProviderArgs{ Region: pulumi.String("us-east-1"), - }, pulumi.Aliases([]pulumi.Alias{{ + } + if len(params.ignoreTags) > 0 { + use1Args.IgnoreTags = &awsprovider.ProviderIgnoreTagsArgs{ + Keys: pulumi.ToStringArray(params.ignoreTags), + } + } + use1Provider, err := awsprovider.NewProvider(pctx, "use1", use1Args, pulumi.Aliases([]pulumi.Alias{{ URN: pulumi.URN(fmt.Sprintf( "urn:pulumi:%s::%s::pulumi:providers:aws::use1", pctx.Stack(), workspacesProjectName, diff --git a/lib/types/controlroom.go b/lib/types/controlroom.go index bf75be0e..db56d459 100644 --- a/lib/types/controlroom.go +++ b/lib/types/controlroom.go @@ -40,41 +40,45 @@ type TrustedUser struct { } type AWSControlRoomConfig struct { - AccountID string `json:"account_id" yaml:"account_id"` - PowerUserARN string `json:"power_user_arn" yaml:"power_user_arn"` - Domain string `json:"domain" yaml:"domain"` - Environment string `json:"environment" yaml:"environment"` - TrueName string `json:"true_name" yaml:"true_name"` - EksAccessEntries *EKSAccessEntriesConfig `json:"eks_access_entries" yaml:"eks_access_entries"` - DBAllocatedStorage int `json:"db_allocated_storage" yaml:"db_allocated_storage"` - DBEngineVersion string `json:"db_engine_version" yaml:"db_engine_version"` - DBInstanceClass string `json:"db_instance_class" yaml:"db_instance_class"` - EksK8sVersion *string `json:"eks_k8s_version" yaml:"eks_k8s_version"` - EksNodeGroupMax int `json:"eks_node_group_max" yaml:"eks_node_group_max"` - EksNodeGroupMin int `json:"eks_node_group_min" yaml:"eks_node_group_min"` - EksNodeInstanceType string `json:"eks_node_instance_type" yaml:"eks_node_instance_type"` - HostedZoneID *string `json:"hosted_zone_id" yaml:"hosted_zone_id"` - ManageEcrRepositories bool `json:"manage_ecr_repositories" yaml:"manage_ecr_repositories"` - ProtectPersistentResources bool `json:"protect_persistent_resources" yaml:"protect_persistent_resources"` - Region string `json:"region" yaml:"region"` - ResourceTags map[string]string `json:"resource_tags" yaml:"resource_tags"` - TraefikDeploymentReplicas int `json:"traefik_deployment_replicas" yaml:"traefik_deployment_replicas"` - TrustedUsers []TrustedUser `json:"trusted_users" yaml:"trusted_users"` - FrontDoor *string `json:"front_door" yaml:"front_door"` - AwsFsxOpenzfsCsiVersion string `json:"aws_fsx_openzfs_csi_version" yaml:"aws_fsx_openzfs_csi_version"` - AwsLbcVersion string `json:"aws_lbc_version" yaml:"aws_lbc_version"` - ExternalDnsVersion string `json:"external_dns_version" yaml:"external_dns_version"` - GrafanaVersion string `json:"grafana_version" yaml:"grafana_version"` - KubeStateMetricsVersion string `json:"kube_state_metrics_version" yaml:"kube_state_metrics_version"` - MetricsServerVersion string `json:"metrics_server_version" yaml:"metrics_server_version"` - MimirVersion string `json:"mimir_version" yaml:"mimir_version"` - SecretStoreCsiAwsProviderVersion string `json:"secret_store_csi_aws_provider_version" yaml:"secret_store_csi_aws_provider_version"` - SecretStoreCsiVersion string `json:"secret_store_csi_version" yaml:"secret_store_csi_version"` - TailscaleEnabled bool `json:"tailscale_enabled" yaml:"tailscale_enabled"` - TigeraOperatorVersion string `json:"tigera_operator_version" yaml:"tigera_operator_version"` - 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"` + AccountID string `json:"account_id" yaml:"account_id"` + PowerUserARN string `json:"power_user_arn" yaml:"power_user_arn"` + Domain string `json:"domain" yaml:"domain"` + Environment string `json:"environment" yaml:"environment"` + TrueName string `json:"true_name" yaml:"true_name"` + EksAccessEntries *EKSAccessEntriesConfig `json:"eks_access_entries" yaml:"eks_access_entries"` + DBAllocatedStorage int `json:"db_allocated_storage" yaml:"db_allocated_storage"` + DBEngineVersion string `json:"db_engine_version" yaml:"db_engine_version"` + DBInstanceClass string `json:"db_instance_class" yaml:"db_instance_class"` + EksK8sVersion *string `json:"eks_k8s_version" yaml:"eks_k8s_version"` + EksNodeGroupMax int `json:"eks_node_group_max" yaml:"eks_node_group_max"` + EksNodeGroupMin int `json:"eks_node_group_min" yaml:"eks_node_group_min"` + EksNodeInstanceType string `json:"eks_node_instance_type" yaml:"eks_node_instance_type"` + HostedZoneID *string `json:"hosted_zone_id" yaml:"hosted_zone_id"` + ManageEcrRepositories bool `json:"manage_ecr_repositories" yaml:"manage_ecr_repositories"` + ProtectPersistentResources bool `json:"protect_persistent_resources" yaml:"protect_persistent_resources"` + Region string `json:"region" yaml:"region"` + ResourceTags map[string]string `json:"resource_tags" yaml:"resource_tags"` + // IgnoreTags is a flat list of exact AWS tag keys that the Pulumi AWS provider should + // never add or remove on managed resources. Used so customer-applied tags are left + // untouched by our IaC. AWS-only; wired into the provider's ignoreTags.keys. + IgnoreTags []string `json:"ignore_tags" yaml:"ignore_tags"` + TraefikDeploymentReplicas int `json:"traefik_deployment_replicas" yaml:"traefik_deployment_replicas"` + TrustedUsers []TrustedUser `json:"trusted_users" yaml:"trusted_users"` + FrontDoor *string `json:"front_door" yaml:"front_door"` + AwsFsxOpenzfsCsiVersion string `json:"aws_fsx_openzfs_csi_version" yaml:"aws_fsx_openzfs_csi_version"` + AwsLbcVersion string `json:"aws_lbc_version" yaml:"aws_lbc_version"` + ExternalDnsVersion string `json:"external_dns_version" yaml:"external_dns_version"` + GrafanaVersion string `json:"grafana_version" yaml:"grafana_version"` + KubeStateMetricsVersion string `json:"kube_state_metrics_version" yaml:"kube_state_metrics_version"` + MetricsServerVersion string `json:"metrics_server_version" yaml:"metrics_server_version"` + MimirVersion string `json:"mimir_version" yaml:"mimir_version"` + SecretStoreCsiAwsProviderVersion string `json:"secret_store_csi_aws_provider_version" yaml:"secret_store_csi_aws_provider_version"` + SecretStoreCsiVersion string `json:"secret_store_csi_version" yaml:"secret_store_csi_version"` + TailscaleEnabled bool `json:"tailscale_enabled" yaml:"tailscale_enabled"` + TigeraOperatorVersion string `json:"tigera_operator_version" yaml:"tigera_operator_version"` + 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"` } // The following accessor methods return the value from config, or the Python diff --git a/lib/types/controlroom_test.go b/lib/types/controlroom_test.go index 373c1876..7a4ba53a 100644 --- a/lib/types/controlroom_test.go +++ b/lib/types/controlroom_test.go @@ -41,6 +41,7 @@ func TestAWSControlRoomConfigSerialization(t *testing.T) { "Environment": "staging", "Project": "ptd", }, + IgnoreTags: []string{"customer:cost-center", "customer:owner"}, TrustedUsers: []TrustedUser{ { Email: "alice@example.com", @@ -100,6 +101,9 @@ func TestAWSControlRoomConfigSerialization(t *testing.T) { assert.Equal(t, config.ResourceTags["Environment"], unmarshaledConfig.ResourceTags["Environment"]) assert.Equal(t, config.ResourceTags["Project"], unmarshaledConfig.ResourceTags["Project"]) + // IgnoreTags (AWS-only) must survive the round trip + assert.Equal(t, config.IgnoreTags, unmarshaledConfig.IgnoreTags) + // Check booleans assert.Equal(t, config.ManageEcrRepositories, unmarshaledConfig.ManageEcrRepositories) assert.Equal(t, config.ProtectPersistentResources, unmarshaledConfig.ProtectPersistentResources) diff --git a/lib/types/types.go b/lib/types/types.go index 282cc957..bf320b38 100644 --- a/lib/types/types.go +++ b/lib/types/types.go @@ -76,6 +76,9 @@ type Target interface { StateBucketName() string Sites() map[string]SiteConfig TailscaleEnabled() bool + // IgnoreTags returns the list of exact AWS tag keys the Pulumi AWS provider should + // leave untouched on managed resources. AWS-only; Azure targets return nil. + IgnoreTags() []string PulumiBackendUrl() string PulumiSecretsProviderKey() string HashName() string diff --git a/lib/types/typestest/testtypes.go b/lib/types/typestest/testtypes.go index 9f01622b..3d3903be 100644 --- a/lib/types/typestest/testtypes.go +++ b/lib/types/typestest/testtypes.go @@ -67,6 +67,14 @@ func (m *MockTarget) TailscaleEnabled() bool { return args.Bool(0) } +func (m *MockTarget) IgnoreTags() []string { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).([]string) +} + func (m *MockTarget) PulumiBackendUrl() string { args := m.Called() return args.String(0) @@ -100,6 +108,7 @@ func DefaultAzureTarget() *MockTarget { azt.On("PulumiSecretsProviderKey").Return("example://example/secrets") azt.On("HashName").Return("test-az-staging-hash") azt.On("ResourceTags").Return(map[string]string(nil)) + azt.On("IgnoreTags").Return([]string(nil)) return azt } diff --git a/lib/types/workload.go b/lib/types/workload.go index 9adf440a..8c67cabb 100644 --- a/lib/types/workload.go +++ b/lib/types/workload.go @@ -393,17 +393,21 @@ type AWSWorkloadConfig struct { PublicLoadBalancer *bool `json:"public_load_balancer" yaml:"public_load_balancer"` Region string `json:"region" yaml:"region"` ResourceTags map[string]string `json:"resource_tags" yaml:"resource_tags"` - RoleArn *string `json:"role_arn" yaml:"role_arn"` - TailscaleEnabled bool `json:"tailscale_enabled" yaml:"tailscale_enabled"` - SecretsStoreAddonEnabled *bool `json:"secrets_store_addon_enabled,omitempty" yaml:"secrets_store_addon_enabled,omitempty"` - TrustedPrincipals []string `json:"trusted_principals" yaml:"trusted_principals"` - HostedZoneID *string `json:"hosted_zone_id" yaml:"hosted_zone_id"` - HostedZoneManagementEnabled *bool `json:"hosted_zone_management_enabled,omitempty" yaml:"hosted_zone_management_enabled,omitempty"` - VpcAzCount int `json:"vpc_az_count" yaml:"vpc_az_count"` - VpcCidr string `json:"vpc_cidr" yaml:"vpc_cidr"` - ThirdPartyTelemetryEnabled *bool `json:"third_party_telemetry_enabled,omitempty" yaml:"third_party_telemetry_enabled,omitempty"` - NetworkTrust string `json:"network_trust" yaml:"network_trust"` - NvidiaGpuEnabled bool `json:"nvidia_gpu_enabled" yaml:"nvidia_gpu_enabled"` + // IgnoreTags is a flat list of exact AWS tag keys that the Pulumi AWS provider should + // never add or remove on managed resources. Used so customer-applied tags are left + // untouched by our IaC. AWS-only; wired into the provider's ignoreTags.keys. + IgnoreTags []string `json:"ignore_tags" yaml:"ignore_tags"` + RoleArn *string `json:"role_arn" yaml:"role_arn"` + TailscaleEnabled bool `json:"tailscale_enabled" yaml:"tailscale_enabled"` + SecretsStoreAddonEnabled *bool `json:"secrets_store_addon_enabled,omitempty" yaml:"secrets_store_addon_enabled,omitempty"` + TrustedPrincipals []string `json:"trusted_principals" yaml:"trusted_principals"` + HostedZoneID *string `json:"hosted_zone_id" yaml:"hosted_zone_id"` + HostedZoneManagementEnabled *bool `json:"hosted_zone_management_enabled,omitempty" yaml:"hosted_zone_management_enabled,omitempty"` + VpcAzCount int `json:"vpc_az_count" yaml:"vpc_az_count"` + VpcCidr string `json:"vpc_cidr" yaml:"vpc_cidr"` + ThirdPartyTelemetryEnabled *bool `json:"third_party_telemetry_enabled,omitempty" yaml:"third_party_telemetry_enabled,omitempty"` + NetworkTrust string `json:"network_trust" yaml:"network_trust"` + NvidiaGpuEnabled bool `json:"nvidia_gpu_enabled" yaml:"nvidia_gpu_enabled"` // FilterControlRoomMetrics enables the per-workload metric filter before forwarding to the // control room Mimir remote_write. When true, only metrics referenced by grafana_alerts and // grafana_dashboards are forwarded. Defaults to false so rollout can be done per-workload. diff --git a/lib/types/workload_test.go b/lib/types/workload_test.go index 7f643457..ca702c3e 100644 --- a/lib/types/workload_test.go +++ b/lib/types/workload_test.go @@ -52,6 +52,7 @@ func TestAWSWorkloadConfigSerialization(t *testing.T) { "Environment": "test", "Owner": "team", }, + IgnoreTags: []string{"customer:cost-center", "customer:owner"}, Sites: map[string]SiteConfig{ "main": {Spec: SiteConfigSpec{ ZoneID: "Z123456789", @@ -93,6 +94,9 @@ func TestAWSWorkloadConfigSerialization(t *testing.T) { assert.Equal(t, config.Sites["main"].Spec.Domain, unmarshaledConfig.Sites["main"].Spec.Domain) assert.Equal(t, config.Clusters["main"].Spec.ClusterName, unmarshaledConfig.Clusters["main"].Spec.ClusterName) assert.Equal(t, config.Clusters["main"].Spec.NodeInstanceType, unmarshaledConfig.Clusters["main"].Spec.NodeInstanceType) + + // IgnoreTags (AWS-only) must survive the round trip + assert.Equal(t, config.IgnoreTags, unmarshaledConfig.IgnoreTags) } func TestSystemNodesConfigFromYAML(t *testing.T) {