From 2fc88bc8018845b20111846d34695671c2bb5ff7 Mon Sep 17 00:00:00 2001 From: Derek Tamsen Date: Tue, 22 Apr 2025 07:09:17 +0000 Subject: [PATCH] feat: add support for features in registries that require authentication Add support for fetching feature layers from registries that require authentication. The authentication pattern mimics what is done in other places in the codebase. It will search the running environment for registry credentials and use them to authenticate. To setup authentication follow the [same documentation as for pulling](https://github.com/coder/envbuilder/blob/main/docs/container-registry-auth.md) other images from private registries. fixes #457 --- devcontainer/devcontainer_test.go | 6 +- devcontainer/features/features.go | 3 +- devcontainer/features/features_test.go | 13 ++-- integration/integration_test.go | 92 +++++++++++++++++++++++++- testutil/registrytest/registrytest.go | 4 +- 5 files changed, 105 insertions(+), 13 deletions(-) diff --git a/devcontainer/devcontainer_test.go b/devcontainer/devcontainer_test.go index d304e763..81d2b63b 100644 --- a/devcontainer/devcontainer_test.go +++ b/devcontainer/devcontainer_test.go @@ -24,6 +24,8 @@ import ( const workingDir = "/.envbuilder" +var emptyRemoteOpts []remote.Option + func stubLookupEnv(string) (string, bool) { return "", false } @@ -46,7 +48,7 @@ func TestParse(t *testing.T) { func TestCompileWithFeatures(t *testing.T) { t.Parallel() registry := registrytest.New(t) - featureOne := registrytest.WriteContainer(t, registry, "coder/one:tomato", features.TarLayerMediaType, map[string]any{ + featureOne := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/one:tomato", features.TarLayerMediaType, map[string]any{ "install.sh": "hey", "devcontainer-feature.json": features.Spec{ ID: "rust", @@ -58,7 +60,7 @@ func TestCompileWithFeatures(t *testing.T) { }, }, }) - featureTwo := registrytest.WriteContainer(t, registry, "coder/two:potato", features.TarLayerMediaType, map[string]any{ + featureTwo := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/two:potato", features.TarLayerMediaType, map[string]any{ "install.sh": "hey", "devcontainer-feature.json": features.Spec{ ID: "go", diff --git a/devcontainer/features/features.go b/devcontainer/features/features.go index 4775aad3..bb044d5f 100644 --- a/devcontainer/features/features.go +++ b/devcontainer/features/features.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + "github.com/GoogleContainerTools/kaniko/pkg/creds" "github.com/go-git/go-billy/v5" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -25,7 +26,7 @@ func extractFromImage(fs billy.Filesystem, directory, reference string) error { if err != nil { return fmt.Errorf("parse feature ref %s: %w", reference, err) } - image, err := remote.Image(ref) + image, err := remote.Image(ref, remote.WithAuthFromKeychain(creds.GetKeychain())) if err != nil { return fmt.Errorf("fetch feature image %s: %w", reference, err) } diff --git a/devcontainer/features/features_test.go b/devcontainer/features/features_test.go index 389193c6..7f2d3bcd 100644 --- a/devcontainer/features/features_test.go +++ b/devcontainer/features/features_test.go @@ -7,15 +7,18 @@ import ( "github.com/coder/envbuilder/devcontainer/features" "github.com/coder/envbuilder/testutil/registrytest" "github.com/go-git/go-billy/v5/memfs" + "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/stretchr/testify/require" ) +var emptyRemoteOpts []remote.Option + func TestExtract(t *testing.T) { t.Parallel() t.Run("MissingMediaType", func(t *testing.T) { t.Parallel() registry := registrytest.New(t) - ref := registrytest.WriteContainer(t, registry, "coder/test:latest", "some/type", nil) + ref := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/test:latest", "some/type", nil) fs := memfs.New() _, err := features.Extract(fs, "", "/", ref) require.ErrorContains(t, err, "no tar layer found") @@ -23,7 +26,7 @@ func TestExtract(t *testing.T) { t.Run("MissingInstallScript", func(t *testing.T) { t.Parallel() registry := registrytest.New(t) - ref := registrytest.WriteContainer(t, registry, "coder/test:latest", features.TarLayerMediaType, map[string]any{ + ref := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/test:latest", features.TarLayerMediaType, map[string]any{ "devcontainer-feature.json": "{}", }) fs := memfs.New() @@ -33,7 +36,7 @@ func TestExtract(t *testing.T) { t.Run("MissingFeatureFile", func(t *testing.T) { t.Parallel() registry := registrytest.New(t) - ref := registrytest.WriteContainer(t, registry, "coder/test:latest", features.TarLayerMediaType, map[string]any{ + ref := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/test:latest", features.TarLayerMediaType, map[string]any{ "install.sh": "hey", }) fs := memfs.New() @@ -43,7 +46,7 @@ func TestExtract(t *testing.T) { t.Run("MissingFeatureProperties", func(t *testing.T) { t.Parallel() registry := registrytest.New(t) - ref := registrytest.WriteContainer(t, registry, "coder/test:latest", features.TarLayerMediaType, map[string]any{ + ref := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/test:latest", features.TarLayerMediaType, map[string]any{ "install.sh": "hey", "devcontainer-feature.json": features.Spec{}, }) @@ -54,7 +57,7 @@ func TestExtract(t *testing.T) { t.Run("Success", func(t *testing.T) { t.Parallel() registry := registrytest.New(t) - ref := registrytest.WriteContainer(t, registry, "coder/test:latest", features.TarLayerMediaType, map[string]any{ + ref := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/test:latest", features.TarLayerMediaType, map[string]any{ "install.sh": "hey", "devcontainer-feature.json": features.Spec{ ID: "go", diff --git a/integration/integration_test.go b/integration/integration_test.go index 913ab567..f221bfb1 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -72,6 +72,8 @@ QFBgc= -----END OPENSSH PRIVATE KEY-----` ) +var emptyRemoteOpts []remote.Option + func TestLogs(t *testing.T) { t.Parallel() @@ -494,7 +496,7 @@ func TestBuildFromDevcontainerWithFeatures(t *testing.T) { t.Parallel() registry := registrytest.New(t) - feature1Ref := registrytest.WriteContainer(t, registry, "coder/test1:latest", features.TarLayerMediaType, map[string]any{ + feature1Ref := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/test1:latest", features.TarLayerMediaType, map[string]any{ "devcontainer-feature.json": &features.Spec{ ID: "test1", Name: "test1", @@ -508,7 +510,7 @@ func TestBuildFromDevcontainerWithFeatures(t *testing.T) { "install.sh": "echo $BANANAS > /test1output", }) - feature2Ref := registrytest.WriteContainer(t, registry, "coder/test2:latest", features.TarLayerMediaType, map[string]any{ + feature2Ref := registrytest.WriteContainer(t, registry, emptyRemoteOpts, "coder/test2:latest", features.TarLayerMediaType, map[string]any{ "devcontainer-feature.json": &features.Spec{ ID: "test2", Name: "test2", @@ -574,6 +576,90 @@ func TestBuildFromDevcontainerWithFeatures(t *testing.T) { require.Equal(t, "hello from test 3!", strings.TrimSpace(test3Output)) } +func TestBuildFromDevcontainerWithFeaturesInAuthRepo(t *testing.T) { + t.Parallel() + + // Given: an empty registry with auth enabled + authOpts := setupInMemoryRegistryOpts{ + Username: "testing", + Password: "testing", + } + remoteAuthOpt := append(emptyRemoteOpts, remote.WithAuth(&authn.Basic{Username: authOpts.Username, Password: authOpts.Password})) + testReg := setupInMemoryRegistry(t, authOpts) + regAuthJSON, err := json.Marshal(envbuilder.DockerConfig{ + AuthConfigs: map[string]clitypes.AuthConfig{ + testReg: { + Username: authOpts.Username, + Password: authOpts.Password, + }, + }, + }) + require.NoError(t, err) + + // push a feature to the registry + featureRef := registrytest.WriteContainer(t, testReg, remoteAuthOpt, "features/test-feature:latest", features.TarLayerMediaType, map[string]any{ + "devcontainer-feature.json": &features.Spec{ + ID: "test1", + Name: "test1", + Version: "1.0.0", + Options: map[string]features.Option{ + "bananas": { + Type: "string", + }, + }, + }, + "install.sh": "echo $BANANAS > /test1output", + }) + + // Create a git repo with a devcontainer.json that uses the feature + srv := gittest.CreateGitServer(t, gittest.Options{ + Files: map[string]string{ + ".devcontainer/devcontainer.json": `{ + "name": "Test", + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "` + featureRef + `": { + "bananas": "hello from test 1!" + } + } + }`, + ".devcontainer/Dockerfile": "FROM " + testImageUbuntu, + }, + }) + opts := []string{ + envbuilderEnv("GIT_URL", srv.URL), + } + + // Test that things fail when no auth is provided + t.Run("NoAuth", func(t *testing.T) { + t.Parallel() + + // run the envbuilder with the auth config + _, err := runEnvbuilder(t, runOpts{env: opts}) + require.ErrorContains(t, err, "Unauthorized") + }) + + // test that things work when auth is provided + t.Run("WithAuth", func(t *testing.T) { + t.Parallel() + + optsWithAuth := append( + opts, + envbuilderEnv("DOCKER_CONFIG_BASE64", base64.StdEncoding.EncodeToString(regAuthJSON)), + ) + + // run the envbuilder with the auth config + ctr, err := runEnvbuilder(t, runOpts{env: optsWithAuth}) + require.NoError(t, err) + + // check that the feature was installed correctly + testOutput := execContainer(t, ctr, "cat /test1output") + require.Equal(t, "hello from test 1!", strings.TrimSpace(testOutput)) + }) +} + func TestBuildFromDockerfileAndConfig(t *testing.T) { t.Parallel() @@ -1545,7 +1631,7 @@ func TestPushImage(t *testing.T) { t.Parallel() // Write a test feature to an in-memory registry. - testFeature := registrytest.WriteContainer(t, registrytest.New(t), "features/test-feature:latest", features.TarLayerMediaType, map[string]any{ + testFeature := registrytest.WriteContainer(t, registrytest.New(t), emptyRemoteOpts, "features/test-feature:latest", features.TarLayerMediaType, map[string]any{ "install.sh": `#!/bin/sh echo "${MESSAGE}" > /root/message.txt`, "devcontainer-feature.json": features.Spec{ diff --git a/testutil/registrytest/registrytest.go b/testutil/registrytest/registrytest.go index 632c1836..8c456ba8 100644 --- a/testutil/registrytest/registrytest.go +++ b/testutil/registrytest/registrytest.go @@ -47,7 +47,7 @@ func New(t testing.TB, mws ...func(http.Handler) http.Handler) string { // WriteContainer uploads a container to the registry server. // It returns the reference to the uploaded container. -func WriteContainer(t *testing.T, serverURL, containerRef, mediaType string, files map[string]any) string { +func WriteContainer(t *testing.T, serverURL string, remoteOpt []remote.Option, containerRef, mediaType string, files map[string]any) string { var buf bytes.Buffer hasher := crypto.SHA256.New() mw := io.MultiWriter(&buf, hasher) @@ -110,7 +110,7 @@ func WriteContainer(t *testing.T, serverURL, containerRef, mediaType string, fil ref, err := name.ParseReference(strings.TrimPrefix(parsedStr, "http://")) require.NoError(t, err) - err = remote.Write(ref, image) + err = remote.Write(ref, image, remoteOpt...) require.NoError(t, err) return ref.String()