|
| 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