Skip to content

Commit 8d2db91

Browse files
authored
test(ci): validate OCI registry authentication (#6662)
* docs: address review feedback on the oci and autoinclude docs * docs: OCI cas docs simplification * chore: OCI live test update * chore: Live repo pull * chore: OCI repo cleanup * chore: live cleanup * chore: live test cleanup * chore: test cleanup * workflows update * chore: cleanup * chore: cleanup * chore: tests cleanup * chore: lint fixes * chore: secrets cleanup * chore: secrets cleanup * chore: limit test update * OCI tests fixes * chore: tests update * chore: cleanup
1 parent 6856c54 commit 8d2db91

6 files changed

Lines changed: 373 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
: "${ENV_FILE:?ENV_FILE is not set}"
6+
7+
if [[ -z "${TG_OCI_TEST_ECR_REPOSITORY:-}" ]]; then
8+
echo "Skipping docker-credential-ecr-login because the ECR repository is not configured"
9+
exit 0
10+
fi
11+
12+
VERSION="0.10.1"
13+
SHA256="ae5f2c0f1a2283f687e24a529f41d58f29c0a5bcfa0b60d1d3f8dc33b7eac4f2"
14+
URL="https://amazon-ecr-credential-helper-releases.s3.us-east-2.amazonaws.com/${VERSION}/linux-amd64/docker-credential-ecr-login"
15+
16+
DEST_DIR="${RUNNER_TEMP:-/tmp}/ecr-credential-helper"
17+
mkdir -p "$DEST_DIR"
18+
19+
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-all-errors --connect-timeout 10 --max-time 120 \
20+
-o "$DEST_DIR/docker-credential-ecr-login" "$URL"
21+
echo "${SHA256} ${DEST_DIR}/docker-credential-ecr-login" | sha256sum -c -
22+
chmod +x "$DEST_DIR/docker-credential-ecr-login"
23+
24+
touch "$ENV_FILE"
25+
printf "export PATH='%s:'\"\$PATH\"\n" "$DEST_DIR" >>"$ENV_FILE"
26+
27+
echo "Installed docker-credential-ecr-login ${VERSION} to ${DEST_DIR}"

.github/scripts/setup/generate-secrets.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ for SECRET in $SECRETS; do
7070
printf "export TG_AZURE_TEST_STORAGE_ACCOUNT='%s'\n" "${TG_AZURE_TEST_STORAGE_ACCOUNT}" >>"$ENV_FILE"
7171
elif [[ "$SECRET" == "TG_AZURE_TEST_SUBSCRIPTION_ID" && -n "${TG_AZURE_TEST_SUBSCRIPTION_ID:-}" ]]; then
7272
printf "export TG_AZURE_TEST_SUBSCRIPTION_ID='%s'\n" "${TG_AZURE_TEST_SUBSCRIPTION_ID}" >>"$ENV_FILE"
73+
elif [[ "$SECRET" == "TG_OCI_TEST_ECR_REPOSITORY" && -n "${TG_OCI_TEST_ECR_REPOSITORY:-}" ]]; then
74+
printf "export TG_OCI_TEST_ECR_REPOSITORY='%s'\n" "${TG_OCI_TEST_ECR_REPOSITORY}" >>"$ENV_FILE"
75+
elif [[ "$SECRET" == "TG_OCI_TEST_GHCR_REPOSITORY" && -n "${TG_OCI_TEST_GHCR_REPOSITORY:-}" ]]; then
76+
printf "export TG_OCI_TEST_GHCR_REPOSITORY='%s'\n" "${TG_OCI_TEST_GHCR_REPOSITORY}" >>"$ENV_FILE"
77+
elif [[ "$SECRET" == "TG_OCI_TEST_GHCR_USERNAME" && -n "${TG_OCI_TEST_GHCR_USERNAME:-}" ]]; then
78+
printf "export TG_OCI_TEST_GHCR_USERNAME='%s'\n" "${TG_OCI_TEST_GHCR_USERNAME}" >>"$ENV_FILE"
79+
elif [[ "$SECRET" == "TG_OCI_TEST_GHCR_TOKEN" && -n "${TG_OCI_TEST_GHCR_TOKEN:-}" ]]; then
80+
printf "export TG_OCI_TEST_GHCR_TOKEN='%s'\n" "${TG_OCI_TEST_GHCR_TOKEN}" >>"$ENV_FILE"
7381
fi
7482
done
7583

.github/workflows/integration-test.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,24 @@ jobs:
115115
TG_AZURE_TEST_STORAGE_ACCOUNT,
116116
TG_AZURE_TEST_SUBSCRIPTION_ID,
117117
]
118+
- name: OCI Registries
119+
os: ubuntu
120+
target: ./test
121+
tags: ocilive
122+
run: "^TestOCILive"
123+
test_args: "-count=1"
124+
skip: false
125+
setup_scripts:
126+
- .github/scripts/setup/ecr-credential-helper.sh
127+
secrets:
128+
[
129+
AWS_ACCESS_KEY_ID,
130+
AWS_SECRET_ACCESS_KEY,
131+
TG_OCI_TEST_ECR_REPOSITORY,
132+
TG_OCI_TEST_GHCR_REPOSITORY,
133+
TG_OCI_TEST_GHCR_USERNAME,
134+
TG_OCI_TEST_GHCR_TOKEN,
135+
]
118136
- name: Windows
119137
os: windows
120138
target: ./...
@@ -224,6 +242,10 @@ jobs:
224242
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
225243
TG_AZURE_TEST_STORAGE_ACCOUNT: ${{ secrets.TG_AZURE_TEST_STORAGE_ACCOUNT }}
226244
TG_AZURE_TEST_SUBSCRIPTION_ID: ${{ secrets.TG_AZURE_TEST_SUBSCRIPTION_ID }}
245+
TG_OCI_TEST_ECR_REPOSITORY: ${{ secrets.TG_OCI_TEST_ECR_REPOSITORY }}
246+
TG_OCI_TEST_GHCR_REPOSITORY: ${{ secrets.TG_OCI_TEST_GHCR_REPOSITORY }}
247+
TG_OCI_TEST_GHCR_USERNAME: ${{ secrets.TG_OCI_TEST_GHCR_USERNAME }}
248+
TG_OCI_TEST_GHCR_TOKEN: ${{ secrets.TG_OCI_TEST_GHCR_TOKEN }}
227249
shell: bash
228250

229251
- name: Setup

test/fixtures/oci-live/main.tf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
output "live" {
2+
value = "live"
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
output "sub" {
2+
value = "sub"
3+
}

test/integration_oci_live_test.go

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
//go:build ocilive
2+
3+
package test_test
4+
5+
import (
6+
"archive/zip"
7+
"bytes"
8+
"embed"
9+
"encoding/json"
10+
"errors"
11+
"io/fs"
12+
"os"
13+
"os/exec"
14+
"path/filepath"
15+
"slices"
16+
"strings"
17+
"testing"
18+
19+
"github.com/opencontainers/go-digest"
20+
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
21+
"github.com/stretchr/testify/require"
22+
oras "oras.land/oras-go/v2"
23+
"oras.land/oras-go/v2/content/memory"
24+
"oras.land/oras-go/v2/errdef"
25+
"oras.land/oras-go/v2/registry/remote"
26+
"oras.land/oras-go/v2/registry/remote/auth"
27+
"oras.land/oras-go/v2/registry/remote/retry"
28+
29+
"github.com/gruntwork-io/terragrunt/internal/getter"
30+
"github.com/gruntwork-io/terragrunt/internal/venv"
31+
"github.com/gruntwork-io/terragrunt/test/helpers/logger"
32+
)
33+
34+
const (
35+
// ociLivePackCreated pins the packed manifest timestamp, so the fixture digest is reproducible locally.
36+
ociLivePackCreated = "2026-01-01T00:00:00Z"
37+
testFixtureOCILive = "fixtures/oci-live"
38+
)
39+
40+
//go:embed fixtures/oci-live
41+
var ociLiveFixtureFS embed.FS
42+
43+
// TestOCILiveECR pulls the published fixture from a real ECR repository through the ecr-login helper.
44+
func TestOCILiveECR(t *testing.T) {
45+
t.Parallel()
46+
47+
repository := venv.OSVenv().Env["TG_OCI_TEST_ECR_REPOSITORY"]
48+
if repository == "" {
49+
t.Skip("TG_OCI_TEST_ECR_REPOSITORY is required for live test")
50+
}
51+
52+
registryHost, _, found := strings.Cut(repository, "/")
53+
require.True(t, found, "TG_OCI_TEST_ECR_REPOSITORY must be <registry-host>/<repository>")
54+
55+
ensureOCILiveFixture(t, repository, ecrLoginCredential(t, registryHost))
56+
57+
// The pull authenticates through the helper named in the ambient Docker config.
58+
home := t.TempDir()
59+
linkAWSConfigInto(t, home)
60+
61+
dockerConfig := `{"credHelpers":{"` + registryHost + `":"ecr-login"}}`
62+
63+
require.NoError(t, os.MkdirAll(filepath.Join(home, ".docker"), 0o700))
64+
require.NoError(t, os.WriteFile(filepath.Join(home, ".docker", "config.json"), []byte(dockerConfig), 0o600))
65+
66+
manifest := ociLiveFixtureManifest(t)
67+
pullOCILiveModule(t, home, "oci://"+repository+"?tag="+ociLiveFixtureTag(manifest.Digest))
68+
pullOCILiveModule(t, home, "oci://"+repository+"?digest="+manifest.Digest.String())
69+
}
70+
71+
// TestOCILiveGHCR pulls the published fixture from GHCR through basic credentials in a CLI-config block.
72+
func TestOCILiveGHCR(t *testing.T) {
73+
t.Parallel()
74+
75+
env := venv.OSVenv().Env
76+
repository := env["TG_OCI_TEST_GHCR_REPOSITORY"]
77+
username := env["TG_OCI_TEST_GHCR_USERNAME"]
78+
token := env["TG_OCI_TEST_GHCR_TOKEN"]
79+
80+
if repository == "" || username == "" || token == "" {
81+
t.Skip("TG_OCI_TEST_GHCR_REPOSITORY, TG_OCI_TEST_GHCR_USERNAME, and TG_OCI_TEST_GHCR_TOKEN are required")
82+
}
83+
84+
registryHost, _, found := strings.Cut(repository, "/")
85+
require.True(t, found, "TG_OCI_TEST_GHCR_REPOSITORY must be <registry-host>/<repository>")
86+
87+
cred := auth.Credential{Username: username, Password: token}
88+
89+
ensureOCILiveFixture(t, repository, cred)
90+
91+
// The pull authenticates through the token in an oci_credentials CLI-config block.
92+
home := t.TempDir()
93+
tofurc := `oci_credentials "` + registryHost + `" {
94+
username = "` + username + `"
95+
password = "` + token + `"
96+
}
97+
`
98+
require.NoError(t, os.WriteFile(filepath.Join(home, ".tofurc"), []byte(tofurc), 0o600))
99+
100+
manifest := ociLiveFixtureManifest(t)
101+
pullOCILiveModule(t, home, "oci://"+repository+"?tag="+ociLiveFixtureTag(manifest.Digest))
102+
pullOCILiveModule(t, home, "oci://"+repository+"?digest="+manifest.Digest.String())
103+
}
104+
105+
// pullOCILiveModule downloads src through the production getter chain rooted at home and checks the tree.
106+
func pullOCILiveModule(t *testing.T, home, src string) {
107+
t.Helper()
108+
109+
v := venv.OSVenv().WithEnvCloned().WithUserHomeDir(func() (string, error) { return home, nil })
110+
v.Env["HOME"] = home
111+
112+
// The hermetic home is the only credential source, so ambient developer config cannot leak in.
113+
for _, name := range []string{"TF_CLI_CONFIG_FILE", "TERRAFORM_CONFIG", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR"} {
114+
delete(v.Env, name)
115+
}
116+
117+
dst := filepath.Join(t.TempDir(), "module")
118+
client := getter.NewClient(v, getter.WithOCI(getter.NewOCIGetter(logger.CreateLogger(), v)))
119+
120+
_, err := client.Get(t.Context(), &getter.Request{Src: src, Dst: dst})
121+
require.NoError(t, err)
122+
123+
for name, content := range ociLiveFixtureFiles(t) {
124+
data, readErr := os.ReadFile(filepath.Join(dst, filepath.FromSlash(name)))
125+
require.NoError(t, readErr)
126+
require.Equal(t, content, data)
127+
}
128+
}
129+
130+
// ensureOCILiveFixture publishes the fixture when the repository does not serve it yet.
131+
func ensureOCILiveFixture(t *testing.T, repository string, cred auth.Credential) {
132+
t.Helper()
133+
134+
registryHost, _, _ := strings.Cut(repository, "/")
135+
expected := ociLiveFixtureManifest(t)
136+
137+
repo, err := remote.NewRepository(repository)
138+
require.NoError(t, err)
139+
140+
repo.Client = &auth.Client{
141+
Client: retry.DefaultClient,
142+
Cache: auth.NewCache(),
143+
Credential: auth.StaticCredential(registryHost, cred),
144+
}
145+
146+
tag := ociLiveFixtureTag(expected.Digest)
147+
148+
published, err := repo.Resolve(t.Context(), tag)
149+
switch {
150+
case err == nil && published.Digest == expected.Digest:
151+
return
152+
case err == nil, errors.Is(err, errdef.ErrNotFound):
153+
pushOCILiveFixture(t, repository, cred)
154+
default:
155+
require.NoError(t, err, "resolving the fixture from %s must succeed", repository)
156+
}
157+
}
158+
159+
// ociLiveFixtureStaging packs the fixture into a fresh in-memory store and returns it with the manifest.
160+
func ociLiveFixtureStaging(t *testing.T) (*memory.Store, ociv1.Descriptor) {
161+
t.Helper()
162+
163+
staging := memory.New()
164+
165+
var buf bytes.Buffer
166+
167+
archive := zip.NewWriter(&buf)
168+
files := ociLiveFixtureFiles(t)
169+
170+
// Sorted names keep the zip bytes, and therefore the fixture digest, reproducible.
171+
names := make([]string, 0, len(files))
172+
for name := range files {
173+
names = append(names, name)
174+
}
175+
176+
slices.Sort(names)
177+
178+
for _, name := range names {
179+
entry, err := archive.Create(name)
180+
require.NoError(t, err)
181+
182+
_, err = entry.Write(files[name])
183+
require.NoError(t, err)
184+
}
185+
186+
require.NoError(t, archive.Close())
187+
188+
layer := ociv1.Descriptor{
189+
MediaType: getter.MediaTypeModuleZip,
190+
Digest: digest.FromBytes(buf.Bytes()),
191+
Size: int64(buf.Len()),
192+
}
193+
require.NoError(t, staging.Push(t.Context(), layer, bytes.NewReader(buf.Bytes())))
194+
195+
manifest, err := oras.PackManifest(
196+
t.Context(),
197+
staging,
198+
oras.PackManifestVersion1_1,
199+
getter.ArtifactTypeModulePkg,
200+
oras.PackManifestOptions{
201+
Layers: []ociv1.Descriptor{layer},
202+
ManifestAnnotations: map[string]string{ociv1.AnnotationCreated: ociLivePackCreated},
203+
},
204+
)
205+
require.NoError(t, err)
206+
207+
return staging, manifest
208+
}
209+
210+
// ociLiveFixtureFiles loads the module tree committed under test/fixtures.
211+
func ociLiveFixtureFiles(t *testing.T) map[string][]byte {
212+
t.Helper()
213+
214+
files := make(map[string][]byte)
215+
216+
err := fs.WalkDir(ociLiveFixtureFS, testFixtureOCILive, func(path string, entry fs.DirEntry, walkErr error) error {
217+
if walkErr != nil {
218+
return walkErr
219+
}
220+
221+
if entry.IsDir() {
222+
return nil
223+
}
224+
225+
data, err := ociLiveFixtureFS.ReadFile(path)
226+
if err != nil {
227+
return err
228+
}
229+
230+
files[strings.TrimPrefix(path, testFixtureOCILive+"/")] = data
231+
232+
return nil
233+
})
234+
require.NoError(t, err)
235+
require.NotEmpty(t, files)
236+
237+
return files
238+
}
239+
240+
// ociLiveFixtureManifest computes the fixture's manifest descriptor locally, without any registry access.
241+
func ociLiveFixtureManifest(t *testing.T) ociv1.Descriptor {
242+
t.Helper()
243+
244+
_, manifest := ociLiveFixtureStaging(t)
245+
246+
return manifest
247+
}
248+
249+
// ociLiveFixtureTag isolates fixture revisions in concurrent runs.
250+
func ociLiveFixtureTag(manifestDigest digest.Digest) string {
251+
return "live-fixture-" + manifestDigest.Encoded()
252+
}
253+
254+
// pushOCILiveFixture publishes the fixture under repository at the fixture tag.
255+
func pushOCILiveFixture(t *testing.T, repository string, cred auth.Credential) {
256+
t.Helper()
257+
258+
registryHost, _, _ := strings.Cut(repository, "/")
259+
staging, manifest := ociLiveFixtureStaging(t)
260+
tag := ociLiveFixtureTag(manifest.Digest)
261+
require.NoError(t, staging.Tag(t.Context(), manifest, tag))
262+
263+
repo, err := remote.NewRepository(repository)
264+
require.NoError(t, err)
265+
266+
repo.Client = &auth.Client{
267+
Client: retry.DefaultClient,
268+
Cache: auth.NewCache(),
269+
Credential: auth.StaticCredential(registryHost, cred),
270+
}
271+
272+
_, err = oras.Copy(t.Context(), staging, tag, repo, tag, oras.DefaultCopyOptions)
273+
require.NoError(t, err, "publishing the fixture to %s must succeed", repository)
274+
}
275+
276+
// ecrLoginCredential mints a registry credential through the same helper the pull under test uses.
277+
func ecrLoginCredential(t *testing.T, registryHost string) auth.Credential {
278+
t.Helper()
279+
280+
cmd := exec.CommandContext(t.Context(), "docker-credential-ecr-login", "get")
281+
cmd.Stdin = strings.NewReader(registryHost)
282+
283+
output, err := cmd.Output()
284+
require.NoError(t, err, "docker-credential-ecr-login must be on PATH with ambient AWS credentials")
285+
286+
var minted struct {
287+
Username string `json:"Username"`
288+
Secret string `json:"Secret"`
289+
}
290+
require.NoError(t, json.Unmarshal(output, &minted))
291+
292+
return auth.Credential{Username: minted.Username, Password: minted.Secret}
293+
}
294+
295+
// linkAWSConfigInto links the real ~/.aws into the hermetic home, so SSO-based helper credentials keep working.
296+
func linkAWSConfigInto(t *testing.T, home string) {
297+
t.Helper()
298+
299+
realHome, err := os.UserHomeDir()
300+
if err != nil {
301+
return
302+
}
303+
304+
awsDir := filepath.Join(realHome, ".aws")
305+
if _, err := os.Stat(awsDir); err != nil {
306+
return
307+
}
308+
309+
require.NoError(t, os.Symlink(awsDir, filepath.Join(home, ".aws")))
310+
}

0 commit comments

Comments
 (0)