Skip to content

Commit 155c0de

Browse files
ostermanclaude
andcommitted
fix: address CodeRabbit review on PR #2883
- Deep-merge pro: for custom component types (container/emulator), matching the built-in types' m.Merge pattern instead of a shallow top-level-key copy that let a component-local pro.pull_request with one activity wipe out other globally-configured activities under the same key. - Dedupe the 'settings.pro is deprecated' debug notice to once per process instead of once per LoadConfig call (which fires repeatedly per CLI invocation), regenerating the golden snapshots that captured the noise. - Fix skill/doc wording: attach "deprecated alias" to settings.pro instead of the new pro: form; correct git_sts.git_config_mode/revoke_on_exit override location (github/sts integration spec, not an identity spec); split the atmos-pro skill's precedence note into its two real behaviors (per-field fallback for atmos.yaml, whole-block precedence for stack/component pro:). - Expand pro.go migration test coverage to every ProSettings field and add an ATMOS_PRO_* env-var precedence test; add a disabled-Pro test case for the CI "test" template scenario. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d2c8718 commit 155c0de

21 files changed

Lines changed: 254 additions & 61 deletions

agent-skills/skills/atmos-modernization/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ the umbrella term for replacing legacy patterns with supported, current patterns
2727
| Large inline workflow/custom-command shell scripts, repeated `echo`, shell loops, ad hoc sleeps | Native step types such as `atmos`, `toast`, `table`, `parallel`, `matrix`, `wait`, `container`, `emulator`, and `http` |
2828
| Hand-rolled scheduled drift GitHub Actions | Atmos Pro drift detection |
2929
| `cloudposse/github-action-atmos-terraform-drift-*` | `pro.drift_detection` plus `atmos terraform plan --upload-status` |
30-
| `settings.pro` in `atmos.yaml` (CLI connection config: `base_url`/`token`/`workspace_id`/etc.) | Top-level `pro:` in `atmos.yaml` (deprecated alias, still works) |
31-
| `settings.pro.*` per component/stack (`enabled`/`drift_detection`/`pull_request`/`release`/`merge_group`) | Top-level `pro:` component section, a sibling of `vars:`/`metadata:`/`settings:` (deprecated alias, still works) |
30+
| `settings.pro` in `atmos.yaml` (CLI connection config: `base_url`/`token`/`workspace_id`/etc.) | Top-level `pro:` in `atmos.yaml` (`settings.pro` remains supported as a deprecated alias) |
31+
| `settings.pro.*` per component/stack (`enabled`/`drift_detection`/`pull_request`/`release`/`merge_group`) | Top-level `pro:` component section, a sibling of `vars:`/`metadata:`/`settings:` (`settings.pro` remains supported as a deprecated alias) |
3232
| Secret values through raw store calls | Declared `secrets.vars` plus `!secret` |
3333
| Legacy hook event spelling | Modern dotted lifecycle events such as `after.terraform.plan` |
3434
| Static GitHub tokens in URLs | Atmos Auth `github/sts` through Atmos Pro |

agent-skills/skills/atmos-pro/SKILL.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ Pro mint short-lived GitHub App installation tokens in CI without storing long-l
155155
`atmos describe affected --upload` runs on `merge_group` events.
156156
- If drift is not dispatched, verify `pro.enabled`, `pro.drift_detection.enabled`,
157157
and that the instance appears in `atmos list instances --upload`.
158-
- If a config sets both `pro:` and `settings.pro:`, the top-level `pro:` block wins outright — it
159-
is not merged with `settings.pro:`. A stray `settings.pro:` left behind after a partial migration
160-
can silently override an intended `pro:` change; check both.
158+
- If `atmos.yaml` sets both `pro:` and `settings.pro:`, each field falls back independently: a
159+
`pro.<field>` set at the top level wins; a field left unset there still falls back to
160+
`settings.pro.<field>`. A stray `settings.pro:` field left behind after a partial migration can
161+
still take effect for any field the top-level `pro:` block leaves unset; check both.
162+
- If a stack/component config sets both `pro:` and `settings.pro:`, the top-level `pro:` block wins
163+
outright as a whole block — it is not merged field-by-field with `settings.pro:`. A stray
164+
`settings.pro:` left behind after a partial migration is ignored entirely once a local `pro:`
165+
block exists on the same component; check both.

internal/exec/stack_processor_process_stacks.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,15 +1372,19 @@ func ProcessStackConfig(
13721372
if len(componentSettings) > 0 {
13731373
componentMap[cfg.SettingsSectionName] = componentSettings
13741374
}
1375-
// Merge global pro into component pro.
1376-
componentPro := map[string]any{}
1377-
for k, v := range globalProSection {
1378-
componentPro[k] = v
1379-
}
1375+
// Merge global pro into component pro. Uses a deep merge (mirroring the
1376+
// built-in component types above) rather than a shallow top-level-key copy,
1377+
// because `pro:` nests multi-level maps (e.g. `pull_request.opened` vs
1378+
// `pull_request.synchronize`) -- a shallow copy would let a component-local
1379+
// `pro.pull_request` with only one activity silently wipe out other
1380+
// globally-configured activities under the same key.
1381+
var componentLocalPro map[string]any
13801382
if pro, ok := componentMap[cfg.ProSectionName].(map[string]any); ok {
1381-
for k, v := range pro {
1382-
componentPro[k] = v
1383-
}
1383+
componentLocalPro = pro
1384+
}
1385+
componentPro, err := m.Merge(atmosConfig, []map[string]any{globalProSection, componentLocalPro})
1386+
if err != nil {
1387+
return nil, err
13841388
}
13851389
if len(componentPro) > 0 {
13861390
componentMap[cfg.ProSectionName] = componentPro

internal/exec/stack_processor_process_stacks_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,92 @@ func TestProcessStackConfig_CustomComponentTypeGlobalMetadata(t *testing.T) {
13941394
assert.Equal(t, []any{"prod"}, overrideMetadata["tags"], "custom component must still inherit global keys it doesn't override locally")
13951395
}
13961396

1397+
// TestProcessStackConfig_CustomComponentTypeProDeepMerge verifies that a
1398+
// custom (non-built-in) component type's own `pro:` block is deep-merged
1399+
// with the global stack `pro:` section rather than replacing it wholesale.
1400+
// `pro:` nests multi-level maps (e.g. `pull_request.opened` vs
1401+
// `pull_request.synchronize`), so a shallow top-level-key merge would let a
1402+
// component-local `pro.pull_request` with only one activity silently wipe
1403+
// out other globally-configured activities under the same key.
1404+
func TestProcessStackConfig_CustomComponentTypeProDeepMerge(t *testing.T) {
1405+
atmosConfig := &schema.AtmosConfiguration{}
1406+
1407+
config := map[string]any{
1408+
cfg.ProSectionName: map[string]any{
1409+
"enabled": true,
1410+
"pull_request": map[string]any{
1411+
"opened": map[string]any{
1412+
"workflows": map[string]any{"atmos-terraform-plan.yaml": map[string]any{}},
1413+
},
1414+
},
1415+
},
1416+
cfg.ComponentsSectionName: map[string]any{
1417+
"container": map[string]any{
1418+
"no-local-pro": map[string]any{
1419+
cfg.VarsSectionName: map[string]any{"name": "no-local-pro"},
1420+
},
1421+
"local-override": map[string]any{
1422+
cfg.VarsSectionName: map[string]any{"name": "local-override"},
1423+
cfg.ProSectionName: map[string]any{
1424+
"pull_request": map[string]any{
1425+
"synchronize": map[string]any{
1426+
"workflows": map[string]any{"atmos-terraform-plan.yaml": map[string]any{}},
1427+
},
1428+
},
1429+
},
1430+
},
1431+
},
1432+
},
1433+
}
1434+
1435+
result, err := ProcessStackConfig(
1436+
atmosConfig,
1437+
"/test/stacks",
1438+
"/test/terraform",
1439+
"/test/helmfile",
1440+
"/test/packer",
1441+
"/test/ansible",
1442+
"test-stack.yaml",
1443+
config,
1444+
false,
1445+
false,
1446+
"",
1447+
map[string]map[string][]string{},
1448+
map[string]map[string]any{},
1449+
false,
1450+
)
1451+
require.NoError(t, err)
1452+
require.NotNil(t, result)
1453+
1454+
components, ok := result[cfg.ComponentsSectionName].(map[string]any)
1455+
require.True(t, ok, "components section should exist")
1456+
containerSection, ok := components["container"].(map[string]any)
1457+
require.True(t, ok, "container components should be present")
1458+
1459+
// A component with no local `pro:` must simply inherit the global block.
1460+
noLocalPro, ok := containerSection["no-local-pro"].(map[string]any)
1461+
require.True(t, ok, "no-local-pro component should exist")
1462+
noLocalProSection, ok := noLocalPro[cfg.ProSectionName].(map[string]any)
1463+
require.True(t, ok, "no-local-pro must have a pro section merged in from global, got: %v", noLocalPro[cfg.ProSectionName])
1464+
assert.Equal(t, true, noLocalProSection["enabled"])
1465+
pullRequest, ok := noLocalProSection["pull_request"].(map[string]any)
1466+
require.True(t, ok, "pull_request must be present")
1467+
assert.Contains(t, pullRequest, "opened", "global pull_request.opened must be inherited")
1468+
1469+
// A component with a local `pro.pull_request.synchronize` must retain the
1470+
// global `pro.pull_request.opened` -- not replace the whole `pull_request`
1471+
// map.
1472+
localOverride, ok := containerSection["local-override"].(map[string]any)
1473+
require.True(t, ok, "local-override component should exist")
1474+
overrideProSection, ok := localOverride[cfg.ProSectionName].(map[string]any)
1475+
require.True(t, ok, "local-override must have a pro section, got: %v", localOverride[cfg.ProSectionName])
1476+
assert.Equal(t, true, overrideProSection["enabled"], "component must still inherit global pro.enabled")
1477+
overridePullRequest, ok := overrideProSection["pull_request"].(map[string]any)
1478+
require.True(t, ok, "pull_request must be present")
1479+
assert.Contains(t, overridePullRequest, "opened", "global pull_request.opened must survive the deep merge")
1480+
assert.Contains(t, overridePullRequest, "synchronize", "component-local pull_request.synchronize must be preserved")
1481+
}
1482+
13971483
// componentHooks extracts the merged hooks section for a terraform component
13981484
// from a ProcessStackConfig result. It fails the test if the component or its
13991485
// hooks section is missing, so inheritance assertions read cleanly.

pkg/ci/plugins/terraform/template_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,31 @@ func TestTemplateRendering(t *testing.T) {
500500
"<sub>__Atmos Pro__ is enabled.</sub>",
501501
},
502502
},
503+
{
504+
name: "test with pro disabled shows silver badge and disabled footer",
505+
templateName: "test",
506+
context: &TerraformTemplateContext{
507+
TemplateContext: &plugin.TemplateContext{
508+
Component: "vpc",
509+
ComponentType: "terraform",
510+
Stack: "dev",
511+
Command: "test",
512+
Result: &plugin.OutputResult{ExitCode: 0},
513+
},
514+
ProEnabled: false,
515+
TestResult: &plugin.TerraformTestOutputData{Total: 1, Pass: 1},
516+
},
517+
wantContains: []string{
518+
"PRO-DISABLED-silver",
519+
"https://atmos-pro.com",
520+
"<sub>__Atmos Pro__ is disabled.</sub>",
521+
},
522+
wantNotContains: []string{
523+
"PRO-ENABLED",
524+
"is enabled.",
525+
"/dashboard",
526+
},
527+
},
503528
}
504529

505530
fs := defaultTemplates

pkg/config/load.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"runtime"
1313
"slices"
1414
"strings"
15+
"sync"
1516

1617
"github.com/go-viper/mapstructure/v2"
1718
"github.com/joho/godotenv"
@@ -546,18 +547,54 @@ func LoadConfig(configAndStacksInfo *schema.ConfigAndStacksInfo) (schema.AtmosCo
546547
return atmosConfig, nil
547548
}
548549

550+
// proDeprecationWarnMu guards proDeprecationWarned.
551+
var proDeprecationWarnMu sync.Mutex
552+
553+
// proDeprecationWarned tracks whether the `settings.pro` deprecation notice has already been
554+
// logged in this process. See warnProSettingsDeprecatedOnce for why this is a manually-gated
555+
// latch rather than a sync.Once.
556+
var proDeprecationWarned bool
557+
558+
// warnProSettingsDeprecatedOnce logs the `settings.pro` deprecation notice at most once per
559+
// process. LoadConfig (and therefore resolveProSettings) runs many times within a single CLI
560+
// invocation -- once per InitCliConfig call, and InitCliConfig is called repeatedly during stack
561+
// and component processing -- so without a guard the same warning is emitted on every debug-level
562+
// stderr capture, once per LoadConfig call.
563+
//
564+
// This intentionally does NOT use sync.Once: the very first LoadConfig call in a command often
565+
// runs before the CLI has synced --verbose/--logs-level into the logger, so log.Debug is a no-op
566+
// at that point. A plain sync.Once would still consume its single call there, silently swallowing
567+
// the notice for the rest of the process even once debug logging becomes active. Checking
568+
// log.GetLevel() first, and only latching after the message was actually eligible to print,
569+
// avoids that trap.
570+
func warnProSettingsDeprecatedOnce() {
571+
if log.GetLevel() > log.DebugLevel {
572+
return
573+
}
574+
575+
proDeprecationWarnMu.Lock()
576+
defer proDeprecationWarnMu.Unlock()
577+
if proDeprecationWarned {
578+
return
579+
}
580+
proDeprecationWarned = true
581+
log.Debug("'settings.pro' is deprecated, use 'pro' instead. See: https://atmos.tools/cli/configuration/settings/pro")
582+
}
583+
549584
// resolveProSettings resolves the deprecated `settings.pro` path into the top-level `pro` field.
550585
// The top-level field wins field-by-field whenever both are set; the deprecated path is otherwise
551586
// used as a fallback. Emits a deprecation notice whenever `settings.pro` is present, regardless of
552587
// which value wins, mirroring the existing 'settings.depends_on' deprecation notice
553-
// (internal/exec/describe_affected_components.go).
588+
// (internal/exec/describe_affected_components.go). The notice itself is logged at most once per
589+
// process via warnProSettingsDeprecatedOnce to avoid flooding debug output across repeated
590+
// LoadConfig calls.
554591
func resolveProSettings(atmosConfig *schema.AtmosConfiguration) {
555592
legacy := atmosConfig.Settings.Pro //nolint:staticcheck // Deliberate read of the deprecated field to implement the fallback itself.
556593
if legacy == (schema.ProSettings{}) {
557594
return
558595
}
559596

560-
log.Debug("'settings.pro' is deprecated, use 'pro' instead. See: https://atmos.tools/cli/configuration/settings/pro")
597+
warnProSettingsDeprecatedOnce()
561598

562599
pro := &atmosConfig.Pro
563600
if pro.BaseURL == "" {

pkg/config/load_test.go

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,31 +2008,106 @@ func TestResolveProSettings(t *testing.T) {
20082008
})
20092009

20102010
t.Run("legacy only, falls back field by field", func(t *testing.T) {
2011+
revokeOnExit := true
20112012
cfg := &schema.AtmosConfiguration{
20122013
Settings: schema.AtmosSettings{Pro: schema.ProSettings{
2013-
WorkspaceID: "legacy-ws",
2014-
Token: "legacy-token",
2014+
WorkspaceID: "legacy-ws",
2015+
Token: "legacy-token",
2016+
Endpoint: "legacy/api",
2017+
MaxPayloadBytes: 1024,
2018+
GitHubHeadRef: "legacy-branch",
2019+
GithubOIDC: schema.GithubOIDCSettings{
2020+
RequestURL: "https://legacy.example.com/oidc",
2021+
RequestToken: "legacy-oidc-token",
2022+
},
2023+
GitSTS: schema.GitSTSSettings{
2024+
GitConfigMode: "file",
2025+
RevokeOnExit: &revokeOnExit,
2026+
},
20152027
}},
20162028
}
20172029
resolveProSettings(cfg)
20182030
assert.Equal(t, "legacy-ws", cfg.Pro.WorkspaceID)
20192031
assert.Equal(t, "legacy-token", cfg.Pro.Token)
2032+
assert.Equal(t, "legacy/api", cfg.Pro.Endpoint)
2033+
assert.Equal(t, 1024, cfg.Pro.MaxPayloadBytes)
2034+
assert.Equal(t, "legacy-branch", cfg.Pro.GitHubHeadRef)
2035+
assert.Equal(t, "https://legacy.example.com/oidc", cfg.Pro.GithubOIDC.RequestURL)
2036+
assert.Equal(t, "legacy-oidc-token", cfg.Pro.GithubOIDC.RequestToken)
2037+
assert.Equal(t, "file", cfg.Pro.GitSTS.GitConfigMode)
2038+
require.NotNil(t, cfg.Pro.GitSTS.RevokeOnExit)
2039+
assert.True(t, *cfg.Pro.GitSTS.RevokeOnExit)
20202040
})
20212041

20222042
t.Run("both set, top-level wins per field", func(t *testing.T) {
2043+
legacyRevokeOnExit := true
2044+
topRevokeOnExit := false
20232045
cfg := &schema.AtmosConfiguration{
2024-
Pro: schema.ProSettings{WorkspaceID: "top-ws"},
2046+
Pro: schema.ProSettings{
2047+
WorkspaceID: "top-ws",
2048+
GitSTS: schema.GitSTSSettings{GitConfigMode: "env", RevokeOnExit: &topRevokeOnExit},
2049+
},
20252050
Settings: schema.AtmosSettings{Pro: schema.ProSettings{
2026-
WorkspaceID: "legacy-ws",
2027-
Token: "legacy-token",
2051+
WorkspaceID: "legacy-ws",
2052+
Token: "legacy-token",
2053+
Endpoint: "legacy/api",
2054+
MaxPayloadBytes: 1024,
2055+
GitHubHeadRef: "legacy-branch",
2056+
GithubOIDC: schema.GithubOIDCSettings{RequestURL: "https://legacy.example.com/oidc"},
2057+
GitSTS: schema.GitSTSSettings{GitConfigMode: "file", RevokeOnExit: &legacyRevokeOnExit},
20282058
}},
20292059
}
20302060
resolveProSettings(cfg)
20312061
assert.Equal(t, "top-ws", cfg.Pro.WorkspaceID, "top-level value must not be overwritten by the legacy fallback")
20322062
assert.Equal(t, "legacy-token", cfg.Pro.Token, "fields unset at top level still fall back to the legacy value")
2063+
assert.Equal(t, "legacy/api", cfg.Pro.Endpoint, "Endpoint unset at top level falls back to legacy")
2064+
assert.Equal(t, 1024, cfg.Pro.MaxPayloadBytes, "MaxPayloadBytes unset at top level falls back to legacy")
2065+
assert.Equal(t, "legacy-branch", cfg.Pro.GitHubHeadRef, "GitHubHeadRef unset at top level falls back to legacy")
2066+
assert.Equal(t, "https://legacy.example.com/oidc", cfg.Pro.GithubOIDC.RequestURL, "GithubOIDC unset at top level falls back to legacy")
2067+
assert.Equal(t, "env", cfg.Pro.GitSTS.GitConfigMode, "GitSTS set at top level must not be overwritten by the legacy fallback")
2068+
require.NotNil(t, cfg.Pro.GitSTS.RevokeOnExit)
2069+
assert.False(t, *cfg.Pro.GitSTS.RevokeOnExit, "GitSTS set at top level must not be overwritten by the legacy fallback")
20332070
})
20342071
}
20352072

2073+
// TestLoadConfig_ProSettingsEnvVarPrecedence verifies that ATMOS_PRO_* environment variables
2074+
// override both the top-level `pro.*` and the deprecated `settings.pro.*` config-file paths,
2075+
// regardless of which one a project has set in atmos.yaml.
2076+
func TestLoadConfig_ProSettingsEnvVarPrecedence(t *testing.T) {
2077+
tests := []struct {
2078+
name string
2079+
content string
2080+
}{
2081+
{
2082+
name: "env overrides top-level pro",
2083+
content: "base_path: .\n" +
2084+
"pro:\n" +
2085+
" token: file-pro-token\n",
2086+
},
2087+
{
2088+
name: "env overrides deprecated settings.pro",
2089+
content: "base_path: .\n" +
2090+
"settings:\n" +
2091+
" pro:\n" +
2092+
" token: file-settings-pro-token\n",
2093+
},
2094+
}
2095+
2096+
for _, tt := range tests {
2097+
t.Run(tt.name, func(t *testing.T) {
2098+
t.Setenv(AtmosProTokenEnvVarName, "env-token")
2099+
tempDir := t.TempDir()
2100+
configPath := createTestConfig(t, tempDir, tt.content)
2101+
configInfo := &schema.ConfigAndStacksInfo{
2102+
AtmosConfigFilesFromArg: []string{configPath},
2103+
}
2104+
cfg, err := LoadConfig(configInfo)
2105+
require.NoError(t, err)
2106+
assert.Equal(t, "env-token", cfg.Pro.Token, "ATMOS_PRO_TOKEN must override the config-file value")
2107+
})
2108+
}
2109+
}
2110+
20362111
func TestAutoProvisionWorkdirForOutputsEnvVarBinding(t *testing.T) {
20372112
t.Setenv("ATMOS_COMPONENTS_TERRAFORM_AUTO_PROVISION_WORKDIR_FOR_OUTPUTS", "false")
20382113
tempDir := t.TempDir()

tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/snapshots/TestCLICommands_Valid_Log_Level_in_Environment_Variable.stderr.golden

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/snapshots/TestCLICommands_Valid_log_file_in_env_should_be_priortized_over_config.stdout.golden

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)