Skip to content

Commit dbbe23e

Browse files
ostermanatmos-pro[bot]aknysh
authored
test: stabilize Terraform cache coverage (#2620)
* test: stabilize terraform cache coverage * [autocommit] formatting fixes * fix: avoid windows current-user root prompt in CI * test: run registry cache acceptance across platforms * Fix PR review follow-ups * ci: run registry cache acceptance before broad suite * ci: match registry cache test cgo settings * fix: wrap sha pinning comment api calls --------- Co-authored-by: atmos-pro[bot] <173522224+atmos-pro[bot]@users.noreply.github.com> Co-authored-by: Andriy Knysh <aknysh@users.noreply.github.com>
1 parent 3a070ff commit dbbe23e

13 files changed

Lines changed: 954 additions & 60 deletions

File tree

.github/actions/verify-sha-pinning/action.yml

Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,29 @@ runs:
3939
const path = require('path');
4040
4141
const workflowDir = process.env.WORKFLOW_DIR;
42+
const tagShaCache = new Map();
43+
const nonRetryableStatuses = new Set([400, 401, 403, 404, 422]);
44+
45+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
46+
47+
async function withRetries(operation, label, maxAttempts = 3) {
48+
let lastErr;
49+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
50+
try {
51+
return await operation();
52+
} catch (err) {
53+
if (nonRetryableStatuses.has(err.status)) {
54+
throw err;
55+
}
56+
lastErr = err;
57+
if (attempt < maxAttempts) {
58+
core.warning(`${label} failed on attempt ${attempt}/${maxAttempts}: ${err.message}; retrying`);
59+
await sleep(250 * attempt);
60+
}
61+
}
62+
}
63+
throw lastErr;
64+
}
4265
4366
// Find all workflow files
4467
const files = fs.readdirSync(workflowDir)
@@ -91,28 +114,44 @@ runs:
91114
92115
// Resolve a tag to its commit SHA, handling annotated tags
93116
async function resolveTagSha(owner, repo, tag) {
117+
const cacheKey = `${owner}/${repo}@${tag}`;
118+
if (tagShaCache.has(cacheKey)) {
119+
return tagShaCache.get(cacheKey);
120+
}
121+
94122
try {
95-
const ref = await github.rest.git.getRef({
96-
owner,
97-
repo,
98-
ref: `tags/${tag}`,
99-
});
123+
const ref = await withRetries(
124+
() => github.rest.git.getRef({
125+
owner,
126+
repo,
127+
ref: `tags/${tag}`,
128+
}),
129+
`Resolving ${cacheKey}`
130+
);
100131
101132
const obj = ref.data.object;
133+
let sha;
102134
103135
// Lightweight tag — points directly to a commit
104136
if (obj.type === 'commit') {
105-
return obj.sha;
137+
sha = obj.sha;
138+
tagShaCache.set(cacheKey, sha);
139+
return sha;
106140
}
107141
108142
// Annotated tag — dereference to get the commit
109143
if (obj.type === 'tag') {
110-
const tagObj = await github.rest.git.getTag({
111-
owner,
112-
repo,
113-
tag_sha: obj.sha,
114-
});
115-
return tagObj.data.object.sha;
144+
const tagObj = await withRetries(
145+
() => github.rest.git.getTag({
146+
owner,
147+
repo,
148+
tag_sha: obj.sha,
149+
}),
150+
`Dereferencing ${cacheKey}`
151+
);
152+
sha = tagObj.data.object.sha;
153+
tagShaCache.set(cacheKey, sha);
154+
return sha;
116155
}
117156
118157
throw new Error(`Unexpected ref object type: ${obj.type}`);
@@ -130,7 +169,10 @@ runs:
130169
131170
// Check if the pinned SHA exists in this repo at all
132171
try {
133-
await github.rest.repos.getCommit({ owner, repo, ref: pinnedSha });
172+
await withRetries(
173+
() => github.rest.repos.getCommit({ owner, repo, ref: pinnedSha }),
174+
`Checking ${owner}/${repo}@${pinnedSha}`
175+
);
134176
details.existsInRepo = true;
135177
} catch (err) {
136178
details.existsInRepo = (err.status !== 404 && err.status !== 422);
@@ -139,9 +181,12 @@ runs:
139181
// Find which tags (if any) point to this SHA
140182
if (details.existsInRepo) {
141183
try {
142-
const tags = await github.paginate(github.rest.repos.listTags, {
143-
owner, repo, per_page: 100,
144-
});
184+
const tags = await withRetries(
185+
() => github.paginate(github.rest.repos.listTags, {
186+
owner, repo, per_page: 100,
187+
}),
188+
`Listing tags for ${owner}/${repo}`
189+
);
145190
details.matchingTags = tags
146191
.filter(t => t.commit.sha === pinnedSha)
147192
.map(t => t.name);
@@ -238,6 +283,28 @@ runs:
238283
github-token: ${{ inputs.github-token }}
239284
script: |
240285
const marker = '<!-- verify-sha-pinning -->';
286+
const nonRetryableStatuses = new Set([400, 401, 403, 404, 422]);
287+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
288+
289+
async function withRetries(operation, label, maxAttempts = 3) {
290+
let lastErr;
291+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
292+
try {
293+
return await operation();
294+
} catch (err) {
295+
if (nonRetryableStatuses.has(err.status)) {
296+
throw err;
297+
}
298+
lastErr = err;
299+
if (attempt < maxAttempts) {
300+
core.warning(`${label} failed on attempt ${attempt}/${maxAttempts}: ${err.message}; retrying`);
301+
await sleep(250 * attempt);
302+
}
303+
}
304+
}
305+
throw lastErr;
306+
}
307+
241308
const prNumber = context.issue.number;
242309
if (!prNumber) {
243310
core.info('Not a PR context — skipping comment');
@@ -251,11 +318,14 @@ runs:
251318
const status = process.env.STATUS || 'pass';
252319
253320
// Find existing comment
254-
const { data: comments } = await github.rest.issues.listComments({
255-
owner: context.repo.owner,
256-
repo: context.repo.repo,
257-
issue_number: prNumber,
258-
});
321+
const { data: comments } = await withRetries(
322+
() => github.rest.issues.listComments({
323+
owner: context.repo.owner,
324+
repo: context.repo.repo,
325+
issue_number: prNumber,
326+
}),
327+
`Listing comments for PR #${prNumber}`
328+
);
259329
const existing = comments.find(c => c.body?.includes(marker));
260330
261331
let body;
@@ -308,19 +378,25 @@ runs:
308378
}
309379
310380
if (existing) {
311-
await github.rest.issues.updateComment({
312-
owner: context.repo.owner,
313-
repo: context.repo.repo,
314-
comment_id: existing.id,
315-
body,
316-
});
381+
await withRetries(
382+
() => github.rest.issues.updateComment({
383+
owner: context.repo.owner,
384+
repo: context.repo.repo,
385+
comment_id: existing.id,
386+
body,
387+
}),
388+
`Updating comment #${existing.id}`
389+
);
317390
core.info(`Updated existing comment #${existing.id}`);
318391
} else {
319-
await github.rest.issues.createComment({
320-
owner: context.repo.owner,
321-
repo: context.repo.repo,
322-
issue_number: prNumber,
323-
body,
324-
});
392+
await withRetries(
393+
() => github.rest.issues.createComment({
394+
owner: context.repo.owner,
395+
repo: context.repo.repo,
396+
issue_number: prNumber,
397+
body,
398+
}),
399+
`Creating comment for PR #${prNumber}`
400+
);
325401
core.info('Created new PR comment');
326402
}

.github/workflows/test.yml

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,12 @@ jobs:
179179
with:
180180
version: ${{ env.PACKER_VERSION }}
181181

182-
# Dogfood `atmos ci cache`: cache ~/.cache/atmos (where the toolchain
183-
# installs helm/helmfile) via the recommended composite action. Atmos
184-
# derives the key/paths from atmos.yaml (ci.cache) and native actions/cache
185-
# does the storage — restore now, save automatically in its post step. No
186-
# runtime token needed. continue-on-error so cache issues never fail the
187-
# job — the cache is a pure accelerator.
182+
# Dogfood `atmos ci cache`: cache the configured Atmos cache root via the
183+
# recommended composite action. Do not export ATMOS_XDG_CACHE_HOME or
184+
# TF_PLUGIN_CACHE_DIR for the full acceptance job: many tests assert XDG
185+
# defaults and Terraform's plugin cache is not safe for shared concurrent
186+
# use. This cache step restores/saves toolchain bits only; it must stay a
187+
# pure accelerator.
188188
- name: Cache Atmos toolchain
189189
if: ${{ ! ( matrix.flavor.target == 'windows' && github.event.pull_request.draft ) }}
190190
continue-on-error: true
@@ -230,6 +230,17 @@ jobs:
230230
run: |
231231
make deps
232232
233+
- name: Terraform registry cache acceptance test
234+
timeout-minutes: 10
235+
if: matrix.flavor.target == 'linux' || matrix.flavor.target == 'macos' || (matrix.flavor.target == 'windows' && ! github.event.pull_request.draft)
236+
env:
237+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
238+
PACKER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
239+
ATMOS_TEST_SKIP_PRECONDITION_CHECKS: true
240+
ATMOS_TEST_TERRAFORM_REGISTRY_CACHE: 1
241+
CGO_ENABLED: 0
242+
run: go test ./tests -run '^TestTerraformRegistryCache$' -count=1 -timeout 10m -v
243+
233244
# Enable this after merging test-cases
234245
# Only seems to work with remote schema files
235246
#- name: Validate YAML Schema for Test Cases

atmos.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -408,19 +408,19 @@ docs:
408408
indent_level: 2
409409

410410
# CI build cache (dogfooded in .github/workflows/test.yml). The cache archives
411-
# the Atmos XDG cache root (~/.cache/atmos), where `atmos toolchain install`
412-
# places helm/helmfile, so CI restores them instead of re-downloading. The key
413-
# is defined here (once) rather than in the workflow because Atmos supplies
414-
# {{.OS}}/{{.Arch}}, avoiding any GitHub Actions context juggling.
411+
# the Atmos XDG cache root (~/.cache/atmos), where CI keeps toolchain installs,
412+
# and Terraform's provider plugin cache. The key is defined here (once) rather
413+
# than in the workflow because Atmos supplies {{.OS}}/{{.Arch}}, avoiding any
414+
# GitHub Actions context juggling.
415415
#
416416
# The cache is purely an accelerator: the workflow always installs exact pinned
417417
# versions, so it stays correct no matter what the cache already holds. The
418-
# trailing token (v1) is a manual cache-bust — bump it to start from a clean
418+
# trailing version token is a manual cache-bust — bump it to start from a clean
419419
# cache. restore_keys lets a bumped token still seed from the previous blob.
420420
ci:
421421
cache:
422422
enabled: true
423-
key: 'atmos-toolchain-{{.OS}}-{{.Arch}}-v1'
423+
key: 'atmos-toolchain-{{.OS}}-{{.Arch}}-v2'
424424
restore_keys:
425425
- 'atmos-toolchain-{{.OS}}-{{.Arch}}-'
426426

go.mod

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/config/load.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,8 @@ func setEnv(v *viper.Viper) {
415415
// Terraform plugin cache configuration.
416416
bindEnv(v, "components.terraform.plugin_cache", "ATMOS_COMPONENTS_TERRAFORM_PLUGIN_CACHE")
417417
bindEnv(v, "components.terraform.plugin_cache_dir", "ATMOS_COMPONENTS_TERRAFORM_PLUGIN_CACHE_DIR")
418+
bindEnv(v, "components.terraform.cache.enabled", "ATMOS_COMPONENTS_TERRAFORM_CACHE_ENABLED")
419+
bindEnv(v, "components.terraform.cache.location", "ATMOS_COMPONENTS_TERRAFORM_CACHE_LOCATION")
418420
bindEnv(v, "components.terraform.auto_provision_workdir_for_outputs", "ATMOS_COMPONENTS_TERRAFORM_AUTO_PROVISION_WORKDIR_FOR_OUTPUTS")
419421

420422
bindEnv(v, "settings.github_token", "GITHUB_TOKEN")

pkg/config/plugin_cache_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package config
22

33
import (
4+
"path/filepath"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/require"
89

10+
"github.com/cloudposse/atmos/pkg/config/homedir"
911
"github.com/cloudposse/atmos/pkg/schema"
12+
tfcache "github.com/cloudposse/atmos/pkg/terraform/cache"
1013
)
1114

1215
// TestViperBindEnv_PluginCache tests that plugin cache env vars are properly bound
@@ -72,6 +75,83 @@ func TestViperBindEnv_PluginCache(t *testing.T) {
7275
}
7376
}
7477

78+
// TestViperBindEnv_TerraformRegistryCache tests that registry cache env vars are
79+
// bound via Viper and populate the nested components.terraform.cache config.
80+
func TestViperBindEnv_TerraformRegistryCache(t *testing.T) {
81+
customLocation := filepath.Join(t.TempDir(), "terraform-registry-cache")
82+
83+
tests := []struct {
84+
name string
85+
envEnabled string
86+
envLocation string
87+
expectedEnabled bool
88+
expectedLocation string
89+
}{
90+
{
91+
name: "registry cache enabled via env var",
92+
envEnabled: "true",
93+
expectedEnabled: true,
94+
},
95+
{
96+
name: "registry cache disabled via env var",
97+
envEnabled: "false",
98+
expectedEnabled: false,
99+
},
100+
{
101+
name: "registry cache with custom location",
102+
envLocation: customLocation,
103+
expectedEnabled: false,
104+
expectedLocation: customLocation,
105+
},
106+
{
107+
name: "registry cache enabled with custom location",
108+
envEnabled: "true",
109+
envLocation: customLocation,
110+
expectedEnabled: true,
111+
expectedLocation: customLocation,
112+
},
113+
}
114+
115+
for _, tt := range tests {
116+
t.Run(tt.name, func(t *testing.T) {
117+
t.Setenv("ATMOS_COMPONENTS_TERRAFORM_CACHE_ENABLED", "false")
118+
t.Setenv("ATMOS_COMPONENTS_TERRAFORM_CACHE_LOCATION", "")
119+
120+
if tt.envEnabled != "" {
121+
t.Setenv("ATMOS_COMPONENTS_TERRAFORM_CACHE_ENABLED", tt.envEnabled)
122+
}
123+
if tt.envLocation != "" {
124+
t.Setenv("ATMOS_COMPONENTS_TERRAFORM_CACHE_LOCATION", tt.envLocation)
125+
}
126+
127+
config, err := LoadConfig(&schema.ConfigAndStacksInfo{})
128+
require.NoError(t, err)
129+
130+
require.NotNil(t, config.Components.Terraform.Cache)
131+
assert.Equal(t, tt.expectedEnabled, config.Components.Terraform.Cache.Enabled)
132+
assert.Equal(t, tt.expectedLocation, config.Components.Terraform.Cache.Location)
133+
})
134+
}
135+
}
136+
137+
func TestViperBindEnv_TerraformRegistryCacheLocationExpands(t *testing.T) {
138+
home := t.TempDir()
139+
t.Setenv("HOME", home)
140+
t.Setenv("USERPROFILE", home)
141+
homedir.Reset()
142+
t.Cleanup(homedir.Reset)
143+
144+
t.Setenv("ATMOS_COMPONENTS_TERRAFORM_CACHE_LOCATION", "~/terraform-registry-cache")
145+
146+
config, err := LoadConfig(&schema.ConfigAndStacksInfo{})
147+
require.NoError(t, err)
148+
require.NotNil(t, config.Components.Terraform.Cache)
149+
150+
root, err := tfcache.ResolveRoot(&config)
151+
require.NoError(t, err)
152+
assert.Equal(t, filepath.Join(home, "terraform-registry-cache"), root)
153+
}
154+
75155
func TestDefaultConfig_PluginCache(t *testing.T) {
76156
// Verify that the default config has plugin cache enabled.
77157
assert.True(t, defaultCliConfig.Components.Terraform.PluginCache)

0 commit comments

Comments
 (0)