diff --git a/git_test.go b/git_test.go index 2ce6a207..35a1289c 100644 --- a/git_test.go +++ b/git_test.go @@ -15,6 +15,7 @@ import ( "github.com/coder/envbuilder" "github.com/coder/envbuilder/internal/notcodersdk" "github.com/coder/envbuilder/testutil/gittest" + "github.com/coder/envbuilder/testutil/mwtest" "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-billy/v5/osfs" @@ -82,7 +83,7 @@ func TestCloneRepo(t *testing.T) { t.Run("AlreadyCloned", func(t *testing.T) { srvFS := memfs.New() _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!")) - authMW := gittest.BasicAuthMW(tc.srvUsername, tc.srvPassword) + authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword) srv := httptest.NewServer(authMW(gittest.NewServer(srvFS))) clientFS := memfs.New() // A repo already exists! @@ -101,7 +102,7 @@ func TestCloneRepo(t *testing.T) { t.Parallel() srvFS := memfs.New() _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!")) - authMW := gittest.BasicAuthMW(tc.srvUsername, tc.srvPassword) + authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword) srv := httptest.NewServer(authMW(gittest.NewServer(srvFS))) clientFS := memfs.New() @@ -134,7 +135,7 @@ func TestCloneRepo(t *testing.T) { t.Parallel() srvFS := memfs.New() _ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!")) - authMW := gittest.BasicAuthMW(tc.srvUsername, tc.srvPassword) + authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword) srv := httptest.NewServer(authMW(gittest.NewServer(srvFS))) authURL, err := url.Parse(srv.URL) diff --git a/integration/integration_test.go b/integration/integration_test.go index 85d6a877..4b0e82e8 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -24,6 +24,7 @@ import ( "github.com/coder/envbuilder" "github.com/coder/envbuilder/devcontainer/features" "github.com/coder/envbuilder/testutil/gittest" + "github.com/coder/envbuilder/testutil/mwtest" "github.com/coder/envbuilder/testutil/registrytest" clitypes "github.com/docker/cli/cli/config/types" "github.com/docker/docker/api/types" @@ -776,7 +777,7 @@ func TestPrivateRegistry(t *testing.T) { t.Parallel() // Even if something goes wrong with auth, // the pull will fail as "scratch" is a reserved name. - image := setupPassthroughRegistry(t, "scratch", ®istryAuth{ + image := setupPassthroughRegistry(t, "scratch", &setupPassthroughRegistryOptions{ Username: "user", Password: "test", }) @@ -795,7 +796,7 @@ func TestPrivateRegistry(t *testing.T) { }) t.Run("Auth", func(t *testing.T) { t.Parallel() - image := setupPassthroughRegistry(t, "envbuilder-test-alpine:latest", ®istryAuth{ + image := setupPassthroughRegistry(t, "envbuilder-test-alpine:latest", &setupPassthroughRegistryOptions{ Username: "user", Password: "test", }) @@ -827,7 +828,7 @@ func TestPrivateRegistry(t *testing.T) { t.Parallel() // Even if something goes wrong with auth, // the pull will fail as "scratch" is a reserved name. - image := setupPassthroughRegistry(t, "scratch", ®istryAuth{ + image := setupPassthroughRegistry(t, "scratch", &setupPassthroughRegistryOptions{ Username: "user", Password: "banana", }) @@ -857,38 +858,43 @@ func TestPrivateRegistry(t *testing.T) { }) } -type registryAuth struct { +type setupPassthroughRegistryOptions struct { Username string Password string + Upstream string } -func setupPassthroughRegistry(t *testing.T, image string, auth *registryAuth) string { +func setupPassthroughRegistry(t *testing.T, image string, opts *setupPassthroughRegistryOptions) string { t.Helper() - dockerURL, err := url.Parse("http://localhost:5000") + if opts.Upstream == "" { + // Default to local test registry + opts.Upstream = "http://localhost:5000" + } + upstreamURL, err := url.Parse(opts.Upstream) require.NoError(t, err) - proxy := httputil.NewSingleHostReverseProxy(dockerURL) + proxy := httputil.NewSingleHostReverseProxy(upstreamURL) // The Docker registry uses short-lived JWTs to authenticate // anonymously to pull images. To test our MITM auth, we need to // generate a JWT for the proxy to use. - registry, err := name.NewRegistry("localhost:5000") + registry, err := name.NewRegistry(upstreamURL.Host) require.NoError(t, err) proxy.Transport, err = transport.NewWithContext(context.Background(), registry, authn.Anonymous, http.DefaultTransport, []string{}) require.NoError(t, err) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.Host = "localhost:5000" - r.URL.Host = "localhost:5000" - r.URL.Scheme = "http" + r.Host = upstreamURL.Host + r.URL.Host = upstreamURL.Host + r.URL.Scheme = upstreamURL.Scheme - if auth != nil { + if opts != nil { user, pass, ok := r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", "Basic realm=\"Access to the staging site\", charset=\"UTF-8\"") w.WriteHeader(http.StatusUnauthorized) return } - if user != auth.Username || pass != auth.Password { + if user != opts.Username || pass != opts.Password { w.WriteHeader(http.StatusUnauthorized) return } @@ -1008,7 +1014,7 @@ func TestPushImage(t *testing.T) { }) // Given: an empty registry - testReg := setupInMemoryRegistry(t) + testReg := setupInMemoryRegistry(t, setupInMemoryRegistryOpts{}) testRepo := testReg + "/test" ref, err := name.ParseReference(testRepo + ":latest") require.NoError(t, err) @@ -1062,7 +1068,7 @@ func TestPushImage(t *testing.T) { }) // Given: an empty registry - testReg := setupInMemoryRegistry(t) + testReg := setupInMemoryRegistry(t, setupInMemoryRegistryOpts{}) testRepo := testReg + "/test" ref, err := name.ParseReference(testRepo + ":latest") require.NoError(t, err) @@ -1101,6 +1107,130 @@ func TestPushImage(t *testing.T) { require.NoError(t, err) }) + t.Run("CacheAndPushAuth", func(t *testing.T) { + t.Parallel() + + srv := createGitServer(t, gitServerOptions{ + files: map[string]string{ + ".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine), + ".devcontainer/devcontainer.json": `{ + "name": "Test", + "build": { + "dockerfile": "Dockerfile" + }, + }`, + }, + }) + + // Given: an empty registry + opts := setupInMemoryRegistryOpts{ + Username: "testing", + Password: "testing", + } + remoteAuthOpt := remote.WithAuth(&authn.Basic{Username: opts.Username, Password: opts.Password}) + testReg := setupInMemoryRegistry(t, opts) + testRepo := testReg + "/test" + regAuthJSON, err := json.Marshal(envbuilder.DockerConfig{ + AuthConfigs: map[string]clitypes.AuthConfig{ + testRepo: { + Username: opts.Username, + Password: opts.Password, + }, + }, + }) + require.NoError(t, err) + ref, err := name.ParseReference(testRepo + ":latest") + require.NoError(t, err) + _, err = remote.Image(ref, remoteAuthOpt) + require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") + + // When: we run envbuilder with GET_CACHED_IMAGE + _, err = runEnvbuilder(t, options{env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("CACHE_REPO", testRepo), + envbuilderEnv("GET_CACHED_IMAGE", "1"), + }}) + require.ErrorContains(t, err, "error probing build cache: uncached command") + // Then: it should fail to build the image and nothing should be pushed + _, err = remote.Image(ref, remoteAuthOpt) + require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") + + // When: we run envbuilder with PUSH_IMAGE set + _, err = runEnvbuilder(t, options{env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("CACHE_REPO", testRepo), + envbuilderEnv("PUSH_IMAGE", "1"), + envbuilderEnv("DOCKER_CONFIG_BASE64", base64.StdEncoding.EncodeToString(regAuthJSON)), + }}) + require.NoError(t, err) + + // Then: the image should be pushed + _, err = remote.Image(ref, remoteAuthOpt) + require.NoError(t, err, "expected image to be present after build + push") + + // Then: re-running envbuilder with GET_CACHED_IMAGE should succeed + _, err = runEnvbuilder(t, options{env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("CACHE_REPO", testRepo), + envbuilderEnv("GET_CACHED_IMAGE", "1"), + envbuilderEnv("DOCKER_CONFIG_BASE64", base64.StdEncoding.EncodeToString(regAuthJSON)), + }}) + require.NoError(t, err) + }) + + t.Run("CacheAndPushAuthFail", func(t *testing.T) { + t.Parallel() + + srv := createGitServer(t, gitServerOptions{ + files: map[string]string{ + ".devcontainer/Dockerfile": fmt.Sprintf("FROM %s\nRUN date --utc > /root/date.txt", testImageAlpine), + ".devcontainer/devcontainer.json": `{ + "name": "Test", + "build": { + "dockerfile": "Dockerfile" + }, + }`, + }, + }) + + // Given: an empty registry + opts := setupInMemoryRegistryOpts{ + Username: "testing", + Password: "testing", + } + remoteAuthOpt := remote.WithAuth(&authn.Basic{Username: opts.Username, Password: opts.Password}) + testReg := setupInMemoryRegistry(t, opts) + testRepo := testReg + "/test" + ref, err := name.ParseReference(testRepo + ":latest") + require.NoError(t, err) + _, err = remote.Image(ref, remoteAuthOpt) + require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") + + // When: we run envbuilder with GET_CACHED_IMAGE + _, err = runEnvbuilder(t, options{env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("CACHE_REPO", testRepo), + envbuilderEnv("GET_CACHED_IMAGE", "1"), + }}) + require.ErrorContains(t, err, "error probing build cache: uncached command") + // Then: it should fail to build the image and nothing should be pushed + _, err = remote.Image(ref, remoteAuthOpt) + require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") + + // When: we run envbuilder with PUSH_IMAGE set + _, err = runEnvbuilder(t, options{env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("CACHE_REPO", testRepo), + envbuilderEnv("PUSH_IMAGE", "1"), + }}) + // Then: it should fail with an Unauthorized error + require.ErrorContains(t, err, "401 Unauthorized", "expected unauthorized error using no auth when cache repo requires it") + + // Then: the image should not be pushed + _, err = remote.Image(ref, remoteAuthOpt) + require.ErrorContains(t, err, "NAME_UNKNOWN", "expected image to not be present before build + push") + }) + t.Run("CacheAndPushMultistage", func(t *testing.T) { // Currently fails with: // /home/coder/src/coder/envbuilder/integration/integration_test.go:1417: "error: unable to get cached image: error fake building stage: failed to optimize instructions: failed to get files used from context: failed to get fileinfo for /.envbuilder/0/root/date.txt: lstat /.envbuilder/0/root/date.txt: no such file or directory" @@ -1122,7 +1252,7 @@ COPY --from=a /root/date.txt /date.txt`, testImageAlpine, testImageAlpine), }) // Given: an empty registry - testReg := setupInMemoryRegistry(t) + testReg := setupInMemoryRegistry(t, setupInMemoryRegistryOpts{}) testRepo := testReg + "/test" ref, err := name.ParseReference(testRepo + ":latest") require.NoError(t, err) @@ -1224,11 +1354,17 @@ COPY --from=a /root/date.txt /date.txt`, testImageAlpine, testImageAlpine), }) } -func setupInMemoryRegistry(t *testing.T) string { +type setupInMemoryRegistryOpts struct { + Username string + Password string +} + +func setupInMemoryRegistry(t *testing.T, opts setupInMemoryRegistryOpts) string { t.Helper() tempDir := t.TempDir() - testReg := registry.New(registry.WithBlobHandler(registry.NewDiskBlobHandler(tempDir))) - regSrv := httptest.NewServer(testReg) + regHandler := registry.New(registry.WithBlobHandler(registry.NewDiskBlobHandler(tempDir))) + authHandler := mwtest.BasicAuthMW(opts.Username, opts.Password)(regHandler) + regSrv := httptest.NewServer(authHandler) t.Cleanup(func() { regSrv.Close() }) regSrvURL, err := url.Parse(regSrv.URL) require.NoError(t, err) @@ -1274,7 +1410,7 @@ type gitServerOptions struct { func createGitServer(t *testing.T, opts gitServerOptions) *httptest.Server { t.Helper() if opts.authMW == nil { - opts.authMW = gittest.BasicAuthMW(opts.username, opts.password) + opts.authMW = mwtest.BasicAuthMW(opts.username, opts.password) } commits := make([]gittest.CommitFunc, 0) for path, content := range opts.files { diff --git a/testutil/gittest/gittest.go b/testutil/gittest/gittest.go index de432c27..ffa9bd01 100644 --- a/testutil/gittest/gittest.go +++ b/testutil/gittest/gittest.go @@ -249,18 +249,3 @@ func WriteFile(t *testing.T, fs billy.Filesystem, path, content string) { err = file.Close() require.NoError(t, err) } - -func BasicAuthMW(username, password string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if username != "" || password != "" { - authUser, authPass, ok := r.BasicAuth() - if !ok || username != authUser || password != authPass { - w.WriteHeader(http.StatusUnauthorized) - return - } - } - next.ServeHTTP(w, r) - }) - } -} diff --git a/testutil/mwtest/auth_basic.go b/testutil/mwtest/auth_basic.go new file mode 100644 index 00000000..fffa1aec --- /dev/null +++ b/testutil/mwtest/auth_basic.go @@ -0,0 +1,18 @@ +package mwtest + +import "net/http" + +func BasicAuthMW(username, password string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if username != "" || password != "" { + authUser, authPass, ok := r.BasicAuth() + if !ok || username != authUser || password != authPass { + w.WriteHeader(http.StatusUnauthorized) + return + } + } + next.ServeHTTP(w, r) + }) + } +}