diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..225a6a3 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,40 @@ +coverage: + status: + project: + default: + target: auto + threshold: 2% + unit: + target: auto + threshold: 1% + flags: + - unittests + integration: + target: auto + threshold: 5% + flags: + - integration + patch: + default: + target: 80% + threshold: 0% + +flags: + unittests: + paths: + - "!testdata/" + carryforward: true + integration: + paths: + - "!testdata/" + carryforward: true + +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false + require_base: false + require_head: true + +ignore: + - "testdata/**" diff --git a/conformance/helpers_test.go b/conformance/helpers_test.go new file mode 100644 index 0000000..68af0b8 --- /dev/null +++ b/conformance/helpers_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conformance + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestIsRegistryRateLimit verifies the rate-limit heuristic string matching. +func TestIsRegistryRateLimit(t *testing.T) { + t.Parallel() + + var ( + errTooManyRequests = errors.New("toomanyrequests") //nolint:err113 + errRateLimitLower = errors.New("rate limit exceeded") //nolint:err113 + errPullRateLimit = errors.New("You have reached your pull rate limit") //nolint:err113 + errCaseInsensitive = errors.New("TooManyRequests from registry") //nolint:err113 + errUnrelated = errors.New("connection refused") //nolint:err113 + errEmpty = errors.New("") //nolint:err113 + ) + + tests := []struct { + name string + err error + want bool + }{ + {"nil error", nil, false}, + {"toomanyrequests", errTooManyRequests, true}, + {"rate limit lower", errRateLimitLower, true}, + {"pull rate limit", errPullRateLimit, true}, + {"case-insensitive", errCaseInsensitive, true}, + {"unrelated error", errUnrelated, false}, + {"empty message", errEmpty, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, isRegistryRateLimit(tc.err)) + }) + } +} diff --git a/credentials_test.go b/credentials_test.go index 1ed2dae..4229fc2 100644 --- a/credentials_test.go +++ b/credentials_test.go @@ -773,86 +773,114 @@ func TestCredentials_ParseAuthFile_Missing(t *testing.T) { assert.Empty(t, cfg.Auths) } -// TestCredentials_ParseRegistriesConf_WithHelpers verifies -// registries.conf with helpers parses correctly. -func TestCredentials_ParseRegistriesConf_WithHelpers(t *testing.T) { +// TestCredentials_ParseRegistriesConf verifies all variants of +// parseRegistriesConf: with helpers, sentinel-only, empty, and missing. +func TestCredentials_ParseRegistriesConf(t *testing.T) { t.Parallel() - cfg, err := parseRegistriesConf("testdata/credentials/registries/with_helpers.conf") - require.NoError(t, err) - - assert.Equal(t, []string{"test-helper", "containers-auth.json"}, cfg.CredentialHelpers) -} - -// TestCredentials_ParseRegistriesConf_SentinelOnly verifies only the -// sentinel helper name is returned. -func TestCredentials_ParseRegistriesConf_SentinelOnly(t *testing.T) { - t.Parallel() - - cfg, err := parseRegistriesConf("testdata/credentials/registries/sentinel_only.conf") - require.NoError(t, err) - - assert.Equal(t, []string{"containers-auth.json"}, cfg.CredentialHelpers) -} - -// TestCredentials_ParseRegistriesConf_Empty verifies an empty -// registries.conf returns no helpers. -func TestCredentials_ParseRegistriesConf_Empty(t *testing.T) { - t.Parallel() - - cfg, err := parseRegistriesConf("testdata/credentials/registries/empty.conf") - require.NoError(t, err) - - assert.Empty(t, cfg.CredentialHelpers) -} - -// TestCredentials_ParseRegistriesConf_Missing verifies a missing -// registries.conf returns no helpers without error. -func TestCredentials_ParseRegistriesConf_Missing(t *testing.T) { - t.Parallel() - - cfg, err := parseRegistriesConf("/does/not/exist/registries.conf") - require.NoErrorf(t, err, "missing file must not error") - assert.Empty(t, cfg.CredentialHelpers) -} - -// TestCredentials_DecodeInlineEntry_ColonInPassword verifies passwords with -// colons split only on the first colon. -func TestCredentials_DecodeInlineEntry_ColonInPassword(t *testing.T) { - t.Parallel() - - // "admin:s3cr3t:pass" — password contains a colon. Must split on FIRST colon only. - raw := rawAuthEntry{Auth: "YWRtaW46czNjcjN0OnBhc3M="} - entry := decodeInlineEntry("registry.example.com", raw) - - assert.Equal(t, "admin", entry.Username) - assert.Equal(t, "s3cr3t:pass", entry.Password) -} - -// TestCredentials_DecodeInlineEntry_IdentityToken verifies an -// identity-token-only entry has no Auth field. -func TestCredentials_DecodeInlineEntry_IdentityToken(t *testing.T) { - t.Parallel() - - raw := rawAuthEntry{IdentityToken: "some-oauth-token"} - entry := decodeInlineEntry("myregistry.io", raw) + tests := []struct { + name string + path string + wantHelpers []string + }{ + { + name: "with helpers", + path: "testdata/credentials/registries/with_helpers.conf", + wantHelpers: []string{"test-helper", "containers-auth.json"}, + }, + { + name: "sentinel only", + path: "testdata/credentials/registries/sentinel_only.conf", + wantHelpers: []string{"containers-auth.json"}, + }, + { + name: "empty file", + path: "testdata/credentials/registries/empty.conf", + wantHelpers: nil, + }, + { + name: "missing file", + path: "/does/not/exist/registries.conf", + wantHelpers: nil, + }, + } - assert.Emptyf(t, entry.Auth, "no Auth when only IdentityToken is present") - assert.Equal(t, "some-oauth-token", entry.IdentityToken) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg, err := parseRegistriesConf(tc.path) + require.NoError(t, err) + if tc.wantHelpers == nil { + assert.Empty(t, cfg.CredentialHelpers) + } else { + assert.Equal(t, tc.wantHelpers, cfg.CredentialHelpers) + } + }) + } } -// TestCredentials_DecodeInlineEntry_DirectFields verifies -// Username+Password synthesizes the Auth field. -func TestCredentials_DecodeInlineEntry_DirectFields(t *testing.T) { +// TestCredentials_DecodeInlineEntry verifies the three distinct decoding +// paths: colon-in-password, identity-token-only, and direct username/password. +func TestCredentials_DecodeInlineEntry(t *testing.T) { t.Parallel() - // Some clients write username/password directly without the auth field. - raw := rawAuthEntry{Username: "alice", Password: "secret"} - entry := decodeInlineEntry("example.io", raw) + tests := []struct { + name string + registry string + raw rawAuthEntry + wantUser string + wantPass string + wantToken string + wantAuthEmpty bool + wantAuthSet bool + }{ + { + // "admin:s3cr3t:pass" — password contains a colon; must split on first colon only. + name: "colon in password", + registry: "registry.example.com", + raw: rawAuthEntry{Auth: "YWRtaW46czNjcjN0OnBhc3M="}, + wantUser: "admin", + wantPass: "s3cr3t:pass", + }, + { + name: "identity token only", + registry: "myregistry.io", + raw: rawAuthEntry{IdentityToken: "some-oauth-token"}, + wantToken: "some-oauth-token", + wantAuthEmpty: true, + }, + { + // Some clients write username/password directly without the auth field. + name: "direct username and password", + registry: "example.io", + raw: rawAuthEntry{Username: "alice", Password: "secret"}, + wantUser: "alice", + wantPass: "secret", + wantAuthSet: true, + }, + } - assert.Equal(t, "alice", entry.Username) - assert.Equal(t, "secret", entry.Password) - assert.NotEmptyf(t, entry.Auth, "Auth must be synthesized from Username:Password") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + entry := decodeInlineEntry(tc.registry, tc.raw) + if tc.wantUser != "" { + assert.Equal(t, tc.wantUser, entry.Username) + } + if tc.wantPass != "" { + assert.Equal(t, tc.wantPass, entry.Password) + } + if tc.wantToken != "" { + assert.Equal(t, tc.wantToken, entry.IdentityToken) + } + if tc.wantAuthEmpty { + assert.Emptyf(t, entry.Auth, "no Auth when only IdentityToken is present") + } + if tc.wantAuthSet { + assert.NotEmptyf(t, entry.Auth, "Auth must be synthesized from Username:Password") + } + }) + } } // TestCredentials_PodmanAuthPaths_ExplicitOverride verifies @@ -884,81 +912,251 @@ func TestCredentials_PodmanAuthPaths_LegacyLast(t *testing.T) { assert.Equal(t, "/home/user/.dockercfg", last.path) } -// TestCredentials_DockerConfigFile_EnvOverride verifies DOCKER_CONFIG -// overrides the default directory. -func TestCredentials_DockerConfigFile_EnvOverride(t *testing.T) { +// TestCredentials_DockerConfigFile verifies that dockerConfigFile uses +// DOCKER_CONFIG when set and falls back to $HOME/.docker otherwise. +func TestCredentials_DockerConfigFile(t *testing.T) { t.Parallel() - env := credEnv{homeDir: "/home/user", dockerConfig: "/custom/docker"} - assert.Equal(t, "/custom/docker/config.json", dockerConfigFile(env)) + tests := []struct { + name string + env credEnv + want string + }{ + { + name: "DOCKER_CONFIG override", + env: credEnv{homeDir: "/home/user", dockerConfig: "/custom/docker"}, + want: "/custom/docker/config.json", + }, + { + name: "default home dir", + env: credEnv{homeDir: "/home/user"}, + want: "/home/user/.docker/config.json", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, dockerConfigFile(tc.env)) + }) + } } -// TestCredentials_DockerConfigFile_DefaultHome verifies the default config -// path uses $HOME/.docker. -func TestCredentials_DockerConfigFile_DefaultHome(t *testing.T) { +// TestFakeCredentials verifies the credential behavior of the test fake: +// empty by default, injectable via withFakeCredentials, and immutable from +// the caller's perspective (each call returns a fresh copy). +func TestFakeCredentials(t *testing.T) { t.Parallel() - env := credEnv{homeDir: "/home/user"} - assert.Equal(t, "/home/user/.docker/config.json", dockerConfigFile(env)) + t.Run("empty by default", func(t *testing.T) { + t.Parallel() + fake := newFakeForTest() + cp, ok := interface{}(fake).(CredentialProvider) + require.Truef(t, ok, "Fake must implement CredentialProvider") + + creds, err := cp.Credentials(t.Context()) + require.NoError(t, err) + assert.Emptyf(t, creds, "default fake returns empty credentials") + }) + + t.Run("withFakeCredentials injects fixed map", func(t *testing.T) { + t.Parallel() + input := map[string]AuthEntry{ + "ghcr.io": { + ServerURL: "ghcr.io", + Username: "testuser", + Password: "testpass", + Auth: "dGVzdHVzZXI6dGVzdHBhc3M=", + }, + } + fake := newFakeForTest(withFakeCredentials(input)) + cp, ok := interface{}(fake).(CredentialProvider) + require.True(t, ok) + + creds, err := cp.Credentials(t.Context()) + require.NoError(t, err) + assert.Equal(t, input, creds) + }) + + t.Run("returns a copy so callers cannot mutate fake state", func(t *testing.T) { + t.Parallel() + input := map[string]AuthEntry{ + "ghcr.io": {Username: "user"}, + } + fake := newFakeForTest(withFakeCredentials(input)) + cp, ok := interface{}(fake).(CredentialProvider) + require.Truef(t, ok, "testFake must implement CredentialProvider") + + c1, err := cp.Credentials(t.Context()) + require.NoError(t, err) + c1["ghcr.io"] = AuthEntry{Username: "mutated"} + + c2, err := cp.Credentials(t.Context()) + require.NoError(t, err) + assert.Equalf(t, "user", c2["ghcr.io"].Username, + "Credentials() must return a copy; mutating the result must not affect the fake") + }) } -// TestFakeCredentials_Empty verifies the test fake implements -// CredentialProvider and returns empty by default. -func TestFakeCredentials_Empty(t *testing.T) { +// TestCredentials_DecodeBase64CredentialsEmpty verifies that +// decodeBase64Credentials returns empty strings for inputs that cannot be +// decoded into a "username:password" pair. +func TestCredentials_DecodeBase64CredentialsEmpty(t *testing.T) { t.Parallel() - fake := newFakeForTest() - cp, ok := interface{}(fake).(CredentialProvider) - require.Truef(t, ok, "Fake must implement CredentialProvider") + tests := []struct { + name string + auth string + }{ + { + name: "invalid base64 characters", + auth: "!!invalid-base64!!", + }, + { + // base64("justausername") — valid base64 but no colon separator + name: "no colon in decoded payload", + auth: "anVzdGF1c2VybmFtZQ==", + }, + } - creds, err := cp.Credentials(t.Context()) - require.NoError(t, err) - assert.Emptyf(t, creds, "default fake returns empty credentials") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + u, p := decodeBase64Credentials(tc.auth) + assert.Emptyf(t, u, "username must be empty") + assert.Emptyf(t, p, "password must be empty") + }) + } +} + +// TestCredentials_ParseAuthFileMalformed verifies that parseAuthFile returns +// an error when the file contains malformed JSON. +func TestCredentials_ParseAuthFileMalformed(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + require.NoError(t, os.WriteFile(path, []byte("this is not json"), 0o600)) + + _, err := parseAuthFile(authFilePath{path: path}) + require.Errorf(t, err, "malformed JSON must return an error") } -// TestFakeCredentials_WithCredentials verifies withFakeCredentials injects -// a fixed credential map. -func TestFakeCredentials_WithCredentials(t *testing.T) { +// TestCredentials_ResolvePodmanRegistryCredHelperFallthrough verifies that +// resolvePodmanRegistry falls through to inline auths when a per-registry +// credHelper is registered but the helper binary is not on PATH. +func TestCredentials_ResolvePodmanRegistryCredHelperFallthrough(t *testing.T) { t.Parallel() - input := map[string]AuthEntry{ - "ghcr.io": { - ServerURL: "ghcr.io", - Username: "testuser", - Password: "testpass", - Auth: "dGVzdHVzZXI6dGVzdHBhc3M=", + cfg := registryConfig{ + // A helper that definitely does not exist on PATH. + CredHelpers: map[string]string{ + "my-registry.io": "definitely-not-installed-xyz-helper", + }, + Auths: map[string]rawAuthEntry{ + "my-registry.io": {Username: "alice", Password: "s3cr3t"}, }, } - fake := newFakeForTest(withFakeCredentials(input)) - cp, ok := interface{}(fake).(CredentialProvider) - require.True(t, ok) + entry, found := resolvePodmanRegistry( + t.Context(), + noopLogger(), + "my-registry.io", + cfg, + nil, // no global helpers + ) + require.Truef(t, found, "should fall through to inline auths") + assert.Equal(t, "alice", entry.Username) + assert.Equal(t, "s3cr3t", entry.Password) +} + +// TestCredentials_SnapshotCredEnv verifies that snapshotCredEnv reads the +// relevant environment variables into the credEnv struct. +func TestCredentials_SnapshotCredEnv(t *testing.T) { + t.Setenv("DOCKER_CONFIG", "/custom/docker") + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + t.Setenv("XDG_CONFIG_HOME", "/home/user/.config") + t.Setenv("REGISTRY_AUTH_FILE", "/custom/auth.json") + + env := snapshotCredEnv() + + assert.Equal(t, "/custom/docker", env.dockerConfig) + assert.Equal(t, "/run/user/1000", env.xdgRuntimeDir) + assert.Equal(t, "/home/user/.config", env.xdgConfigHome) + assert.Equal(t, "/custom/auth.json", env.registryAuthFile) + // homeDir is populated from os.UserHomeDir (non-empty on any CI machine). + assert.NotEmpty(t, env.homeDir) +} + +// TestCredentials_ResolveDockerRegistryEmptyAuths verifies that +// resolveDockerRegistry returns no entry when the registry is present in +// Auths but has all empty fields. +func TestCredentials_ResolveDockerRegistryEmptyAuths(t *testing.T) { + t.Parallel() - creds, err := cp.Credentials(t.Context()) - require.NoError(t, err) - assert.Equal(t, input, creds) + cfg := registryConfig{ + Auths: map[string]rawAuthEntry{ + "sparse.io": {}, // all fields are zero-value + }, + CredHelpers: map[string]string{}, + } + + _, found := resolveDockerRegistry(t.Context(), noopLogger(), "sparse.io", cfg) + assert.Falsef(t, found, "empty auths entry must not produce a credential") } -// TestFakeCredentials_ReturnsCopy verifies Credentials returns a copy so -// callers cannot mutate the fake's state. -func TestFakeCredentials_ReturnsCopy(t *testing.T) { +// TestCredentials_ResolvePodmanRegistryNotFound verifies resolvePodmanRegistry +// returns not-found for inputs that cannot yield a credential. +func TestCredentials_ResolvePodmanRegistryNotFound(t *testing.T) { t.Parallel() - input := map[string]AuthEntry{ - "ghcr.io": {Username: "user"}, + tests := []struct { + name string + registry string + cfg registryConfig + }{ + { + name: "registry absent from all sources", + registry: "missing.io", + cfg: registryConfig{ + Auths: map[string]rawAuthEntry{"other.io": {Username: "user"}}, + }, + }, + { + name: "inline auth entry present but all fields zero", + registry: "empty.io", + cfg: registryConfig{ + Auths: map[string]rawAuthEntry{"empty.io": {}}, + }, + }, } - fake := newFakeForTest(withFakeCredentials(input)) - cp, ok := interface{}(fake).(CredentialProvider) - require.Truef(t, ok, "testFake must implement CredentialProvider") - c1, err := cp.Credentials(t.Context()) - require.NoError(t, err) - c1["ghcr.io"] = AuthEntry{Username: "mutated"} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, found := resolvePodmanRegistry(t.Context(), noopLogger(), tc.registry, tc.cfg, nil) + assert.False(t, found) + }) + } +} - c2, err := cp.Credentials(t.Context()) - require.NoError(t, err) - assert.Equalf(t, "user", c2["ghcr.io"].Username, - "Credentials() must return a copy; mutating the result must not affect the fake") +// TestCredentials_ResolvePodmanRegistryGlobalHelperMiss verifies that +// resolvePodmanRegistry tries the global helpers list and falls through to +// inline auths when each helper misses the registry. +func TestCredentials_ResolvePodmanRegistryGlobalHelperMiss(t *testing.T) { + t.Parallel() + + cfg := registryConfig{ + Auths: map[string]rawAuthEntry{ + "my.io": {Username: "alice", Password: "secret"}, + }, + } + // Non-existent global helper: helperGet returns (false, nil), so we continue. + globalHelpers := []string{"definitely-nonexistent-global-helper-xyz"} + + entry, found := resolvePodmanRegistry(t.Context(), noopLogger(), "my.io", cfg, globalHelpers) + require.Truef(t, found, "should fall through to inline auths after global helper miss") + assert.Equal(t, "alice", entry.Username) } // These thin wrappers let credentials_test.go reach into the currustest diff --git a/currus_test.go b/currus_test.go index b706669..df9cd27 100644 --- a/currus_test.go +++ b/currus_test.go @@ -144,6 +144,15 @@ func TestWithTracerProvider(t *testing.T) { assert.Equal(t, tp, cfg.tracer) } +// TestWithDaemonSocket verifies that WithDaemonSocket writes the socket path +// override to engineConfig.daemonSocket. +func TestWithDaemonSocket(t *testing.T) { + t.Parallel() + var cfg engineConfig + WithDaemonSocket("/custom/daemon.sock")(&cfg) + assert.Equal(t, "/custom/daemon.sock", cfg.daemonSocket) +} + // testCertPEM generates a self-signed ECDSA certificate and returns the cert // and key encoded as PEM byte slices. func testCertPEM(t *testing.T) ([]byte, []byte) { diff --git a/currustest/fake_test.go b/currustest/fake_test.go index 40e2277..9442db0 100644 --- a/currustest/fake_test.go +++ b/currustest/fake_test.go @@ -15,9 +15,11 @@ package currustest_test import ( + "context" "io" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -675,3 +677,190 @@ func TestFakeListContainersFilter(t *testing.T) { } assert.Truef(t, found, "created container should appear with All=true") } + +// TestFakeWaitContainerContextCancel verifies that WaitContainer returns an +// error through the result channel when the context is canceled while the +// container is still running. +func TestFakeWaitContainerContextCancel(t *testing.T) { + t.Parallel() + eng := currustest.New() + ctx := t.Context() + + id, err := eng.CreateContainer(ctx, currus.ContainerSpec{Image: "alpine"}) + require.NoError(t, err) + require.NoError(t, eng.StartContainer(ctx, id)) + + cancelCtx, cancel := context.WithCancel(ctx) + ch, err := eng.WaitContainer(cancelCtx, id, currus.WaitContainerOpts{}) + require.NoError(t, err) + + // Cancel before the container is stopped. + cancel() + + select { + case res := <-ch: + assert.NotEmptyf(t, res.Error, "cancelled context should populate result.Error") + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for WaitContainer to propagate context cancellation") + } +} + +// TestFakeWaitContainerStopThenWait verifies that calling WaitContainer after +// the container is already stopped yields StatusCode 0 immediately. +func TestFakeWaitContainerStopThenWait(t *testing.T) { + t.Parallel() + eng := currustest.New() + ctx := t.Context() + + id, err := eng.CreateContainer(ctx, currus.ContainerSpec{Image: "alpine"}) + require.NoError(t, err) + require.NoError(t, eng.StartContainer(ctx, id)) + require.NoError(t, eng.StopContainer(ctx, id, currus.StopContainerOpts{})) + + ch, err := eng.WaitContainer(ctx, id, currus.WaitContainerOpts{}) + require.NoError(t, err) + + select { + case res := <-ch: + assert.Equal(t, 0, res.StatusCode) + assert.Empty(t, res.Error) + case <-time.After(time.Second): + t.Fatal("timed out: WaitContainer on stopped container should resolve immediately") + } +} + +// TestFakeRemoveContainerForceRunning verifies that RemoveContainer with +// Force=true removes a running container without returning ErrConflict. +func TestFakeRemoveContainerForceRunning(t *testing.T) { + t.Parallel() + eng := currustest.New() + ctx := t.Context() + + id, err := eng.CreateContainer(ctx, currus.ContainerSpec{Image: "alpine"}) + require.NoError(t, err) + require.NoError(t, eng.StartContainer(ctx, id)) + + // Without Force, a running container returns ErrConflict. + err = eng.RemoveContainer(ctx, id, currus.RemoveContainerOpts{Force: false}) + require.Error(t, err) + assert.ErrorIs(t, err, currus.ErrConflict) + + // With Force=true it must succeed even though the container is running. + require.NoError(t, eng.RemoveContainer(ctx, id, currus.RemoveContainerOpts{Force: true})) + + // Container is gone. + list, err := eng.ListContainers(ctx, currus.ListContainersOpts{All: true}) + require.NoError(t, err) + for _, c := range list { + assert.NotEqualf(t, id, c.ID, "force-removed container should not appear in list") + } +} + +// TestFakeCreateContainerDuplicateName verifies that two containers can be +// created with the same name because the fake assigns unique IDs. +func TestFakeCreateContainerDuplicateName(t *testing.T) { + t.Parallel() + eng := currustest.New() + ctx := t.Context() + + id1, err := eng.CreateContainer(ctx, currus.ContainerSpec{Image: "alpine", Name: "app"}) + require.NoError(t, err) + + // The fake does not enforce unique names, matching behavior the + // conformance suite documents: duplicate-name conflicts are left to the + // real daemon. A second create with the same name should succeed. + id2, err := eng.CreateContainer(ctx, currus.ContainerSpec{Image: "nginx", Name: "app"}) + require.NoError(t, err) + + // Both IDs must be distinct. + assert.NotEqual(t, id1, id2) +} + +// TestFakeCredentials covers the Credentials method in both the default +// (empty) and the WithCredentials-injected cases. +func TestFakeCredentials(t *testing.T) { + t.Parallel() + + t.Run("default returns empty non-nil map", func(t *testing.T) { + t.Parallel() + + eng := currustest.New() + got, err := eng.Credentials(t.Context()) + require.NoError(t, err) + assert.Empty(t, got) + assert.NotNil(t, got) + }) + + t.Run("WithCredentials injects fixed credential map", func(t *testing.T) { + t.Parallel() + + creds := map[string]currus.AuthEntry{ + "registry.example.com": {Username: "user", Password: "pass"}, + } + eng := currustest.New(currustest.WithCredentials(creds)) + + got, err := eng.Credentials(t.Context()) + require.NoError(t, err) + require.Len(t, got, 1) + entry, ok := got["registry.example.com"] + require.True(t, ok) + assert.Equal(t, "user", entry.Username) + assert.Equal(t, "pass", entry.Password) + }) +} + +// TestFakeStatsRunningContainer verifies that Stats succeeds on a running +// container (returning zeroed stats, since the fake doesn't collect real data). +func TestFakeStatsRunningContainer(t *testing.T) { + t.Parallel() + eng := currustest.New() + ctx := t.Context() + + id, err := eng.CreateContainer(ctx, currus.ContainerSpec{Image: "alpine"}) + require.NoError(t, err) + require.NoError(t, eng.StartContainer(ctx, id)) + + _, err = eng.Stats(ctx, id, currus.StatsOpts{}) + // The fake returns zero-value stats; just verify the call succeeds. + require.NoError(t, err) +} + +// TestFakeResolveEphemeralPorts verifies that containers created with +// Host==0 port bindings receive monotonically assigned ephemeral host ports. +func TestFakeResolveEphemeralPorts(t *testing.T) { + t.Parallel() + eng := currustest.New() + ctx := t.Context() + + spec := currus.ContainerSpec{ + Image: "nginx", + Ports: []currus.Port{ + {Container: 80, Host: 0, Protocol: "tcp"}, // ephemeral + {Container: 443, Host: 8443, Protocol: "tcp"}, // explicit + }, + } + id, err := eng.CreateContainer(ctx, spec) + require.NoError(t, err) + + info, err := eng.Inspect(ctx, id) + require.NoError(t, err) + require.Len(t, info.Ports, 2) + + // Port 443 must keep its explicit host port. + var port443 currus.Port + for _, p := range info.Ports { + if p.Container == 443 { + port443 = p + } + } + assert.Equal(t, uint16(8443), port443.Host) + + // Port 80 must have a non-zero assigned host port. + var port80 currus.Port + for _, p := range info.Ports { + if p.Container == 80 { + port80 = p + } + } + assert.NotZerof(t, port80.Host, "ephemeral port should be assigned") +} diff --git a/detect_test.go b/detect_test.go index 1df4dc7..04fa69e 100644 --- a/detect_test.go +++ b/detect_test.go @@ -20,8 +20,11 @@ import ( "fmt" "log/slog" "net" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -123,22 +126,28 @@ func TestOpenKindUnsupported(t *testing.T) { assert.ErrorIs(t, err, ErrUnsupported) } -// TestOpenKindDockerTLSError verifies that openKind surfaces TLS configuration -// errors when connecting to a Docker endpoint. -func TestOpenKindDockerTLSError(t *testing.T) { +// TestOpenKindTLSErrors verifies that openKind surfaces TLS configuration +// errors for both Docker and Podman when invalid cert/key bytes are provided. +func TestOpenKindTLSErrors(t *testing.T) { t.Parallel() - ep := Endpoint{TLS: &TLSConfig{Cert: []byte("not-a-cert"), Key: []byte("not-a-key")}} - _, err := openKind(t.Context(), Docker, buildEngineConfig([]Option{WithEndpoint(ep)})) - assert.Error(t, err) -} -// TestOpenKindPodmanTLSError is the same as TestOpenKindDockerTLSError but for -// the Podman path. -func TestOpenKindPodmanTLSError(t *testing.T) { - t.Parallel() ep := Endpoint{TLS: &TLSConfig{Cert: []byte("not-a-cert"), Key: []byte("not-a-key")}} - _, err := openKind(t.Context(), Podman, buildEngineConfig([]Option{WithEndpoint(ep)})) - assert.Error(t, err) + + tests := []struct { + name string + kind EngineKind + }{ + {"docker", Docker}, + {"podman", Podman}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := openKind(t.Context(), tc.kind, buildEngineConfig([]Option{WithEndpoint(ep)})) + assert.Error(t, err) + }) + } } // TestOpenKindDocker verifies that openKind returns a Docker-kind engine. @@ -387,6 +396,18 @@ func TestDockerTLSFromEnv(t *testing.T) { _, err := dockerTLSFromEnv() require.Error(t, err) }) + + t.Run("DOCKER_CERT_PATH empty falls back to home dir", func(t *testing.T) { + // Use a temp dir as HOME so ~/.docker/ca.pem won't exist. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("DOCKER_TLS_VERIFY", "1") + t.Setenv("DOCKER_CERT_PATH", "") + _, err := dockerTLSFromEnv() + // Either the fallback path works and ca.pem is missing (read error), + // or UserHomeDir fails — in both cases an error is expected. + require.Error(t, err) + }) } // TestEnvEndpoint covers the full envEndpoint resolution logic. @@ -526,42 +547,48 @@ func TestEnvEndpoint(t *testing.T) { }) } -// TestNewViaDOCKER_HOST verifies that New() picks up DOCKER_HOST. -// This test is daemon-adaptive: when no daemon listens at the fake socket, -// resolveInfo fails with ErrDaemonInfo. -func TestNewViaDOCKER_HOST(t *testing.T) { - clearDockerEnv(t) - t.Setenv("DOCKER_HOST", "unix:///tmp/fake-docker.sock") - eng, err := New(t.Context()) - if err != nil { - assert.ErrorIs(t, err, ErrDaemonInfo) - return +// TestNewViaHostEnvVar verifies that New() honors both DOCKER_HOST and +// CONTAINER_HOST and maps each to the expected engine kind. The tests are +// daemon-adaptive: when no daemon listens at the fake socket, resolveInfo +// fails with ErrDaemonInfo, which is also an accepted outcome. +func TestNewViaHostEnvVar(t *testing.T) { + tests := []struct { + name string + envKey string + envVal string + wantKind EngineKind + }{ + { + name: "DOCKER_HOST", + envKey: "DOCKER_HOST", + envVal: "unix:///tmp/fake-docker.sock", + wantKind: Docker, + }, + { + name: "CONTAINER_HOST", + envKey: "CONTAINER_HOST", + envVal: "unix:///tmp/fake-podman.sock", + wantKind: Podman, + }, } - t.Cleanup(func() { - if closeErr := eng.Close(); closeErr != nil { - t.Logf("close engine: %v", closeErr) - } - }) - assert.Equal(t, Docker, eng.Kind()) -} -// TestNewViaCONTAINER_HOST verifies that New() picks up CONTAINER_HOST. -// This test is daemon-adaptive: when no daemon listens at the fake socket, -// resolveInfo fails with ErrDaemonInfo. -func TestNewViaCONTAINER_HOST(t *testing.T) { - clearDockerEnv(t) - t.Setenv("CONTAINER_HOST", "unix:///tmp/fake-podman.sock") - eng, err := New(t.Context()) - if err != nil { - assert.ErrorIs(t, err, ErrDaemonInfo) - return + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearDockerEnv(t) + t.Setenv(tc.envKey, tc.envVal) + eng, err := New(t.Context()) + if err != nil { + assert.ErrorIs(t, err, ErrDaemonInfo) + return + } + t.Cleanup(func() { + if closeErr := eng.Close(); closeErr != nil { + t.Logf("close engine: %v", closeErr) + } + }) + assert.Equal(t, tc.wantKind, eng.Kind()) + }) } - t.Cleanup(func() { - if closeErr := eng.Close(); closeErr != nil { - t.Logf("close engine: %v", closeErr) - } - }) - assert.Equal(t, Podman, eng.Kind()) } // TestNewDockerHostContextConflict verifies that New() returns ErrInvalidSpec @@ -652,3 +679,130 @@ func writeTLSFiles(t *testing.T) string { return dir } + +// newMockDockerInfo starts a minimal mock Docker daemon that handles /info +// (for resolveInfo) and /_ping (for API version negotiation). It returns an +// Endpoint pointing at the mock server. +func newMockDockerInfo(t *testing.T) Endpoint { + t.Helper() + + mux := http.NewServeMux() + + mux.HandleFunc("HEAD /_ping", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Api-Version", "1.54") + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /_ping", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Api-Version", "1.54") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) //nolint:errcheck + }) + + // Version-stripping wrapper so the moby client's /v1.54/info request + // reaches our registered handler at /info. + outer := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if strings.HasPrefix(path, "/v") { + if idx := strings.Index(path[2:], "/"); idx >= 0 { + path = path[2+idx:] + } + } + r2 := r.Clone(r.Context()) + r2.URL.Path = path + mux.ServeHTTP(w, r2) + }) + + mux.HandleFunc("GET /info", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"SecurityOptions":[]}`)) //nolint:errcheck + }) + + srv := httptest.NewServer(outer) + t.Cleanup(srv.Close) + + return Endpoint{Host: "tcp://" + srv.Listener.Addr().String()} +} + +// TestMustNewSuccess verifies that MustNew returns a valid engine when a +// Docker daemon is reachable. A minimal mock daemon serves /info so that +// resolveInfo succeeds. +func TestMustNewSuccess(t *testing.T) { + t.Parallel() + ep := newMockDockerInfo(t) + + eng := MustNew(t.Context(), + WithEngine(Docker), + WithEndpoint(ep), + ) + t.Cleanup(func() { assert.NoError(t, eng.Close()) }) + assert.Equal(t, Docker, eng.Kind()) +} + +// TestAutoDetectSkipsEmptySocket exercises the branch in autoDetect that +// skips candidates whose socket path is empty. Setting HOME="" on Linux +// causes dockerDesktopSocket() and potentially podmanRootlessSocket() to +// return "", which are then skipped. This test is environment-adaptive: if a +// real daemon is still reachable via a hardcoded path, we assert the returned +// engine is non-nil; if no daemon is reachable we assert ErrNoEngine. +func TestAutoDetectSkipsEmptySocket(t *testing.T) { + t.Setenv("DOCKER_HOST", "") + t.Setenv("CONTAINER_HOST", "") + t.Setenv("DOCKER_CONTEXT", "") + t.Setenv("CONTAINER_ENGINE", "") + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + + eng, err := New(t.Context()) + if err != nil { + // Expected in environments without a running daemon. + assert.ErrorIs(t, err, ErrNoEngine) + return + } + // A daemon was found via a hardcoded socket path; close it. + t.Cleanup(func() { assert.NoError(t, eng.Close()) }) + assert.NotNil(t, eng) +} + +// TestDockerDesktopSocketNoHome verifies that dockerDesktopSocket returns "" +// when the HOME environment variable is set to an invalid value that prevents +// [os.UserHomeDir] from succeeding. +func TestDockerDesktopSocketNoHome(t *testing.T) { + // os.UserHomeDir on Linux uses $HOME. Temporarily clear it to simulate + // the error path. + t.Setenv("HOME", "") + // On Linux, os.UserHomeDir falls back to the passwd file, which still + // works in most environments. Skip the test if a home dir is still found. + home, err := os.UserHomeDir() + if err == nil && home != "" { + t.Skipf("os.UserHomeDir() still returns %q with HOME=; skipping error-path test", home) + } + assert.Empty(t, dockerDesktopSocket()) +} + +// TestPodmanRootlessSocketNoHome verifies that podmanRootlessSocket returns "" +// when neither XDG_RUNTIME_DIR nor a resolvable home directory is available. +func TestPodmanRootlessSocketNoHome(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") + t.Setenv("HOME", "") + home, err := os.UserHomeDir() + if err == nil && home != "" { + t.Skipf("os.UserHomeDir() still returns %q with HOME=; skipping error-path test", home) + } + assert.Empty(t, podmanRootlessSocket()) +} + +// TestBuildAndResolveDockerEngineError verifies that buildAndResolveDockerEngine +// returns an error (wrapping ErrDaemonInfo) when the daemon is unreachable at +// the resolveInfo step. +func TestBuildAndResolveDockerEngineError(t *testing.T) { + t.Parallel() + // Point at a TCP address with no server; resolveInfo will fail. + _, err := buildAndResolveDockerEngine(t.Context(), dockerConfig{ + Host: "tcp://127.0.0.1:1", + Logger: slog.Default(), + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrDaemonInfo) +} diff --git a/driver_containerd_test.go b/driver_containerd_test.go index e0ce290..286e063 100644 --- a/driver_containerd_test.go +++ b/driver_containerd_test.go @@ -114,6 +114,24 @@ func TestCtrdCtx(t *testing.T) { assert.Equal(t, ns, got) } +// TestContainerdEngineEndpoint verifies that Endpoint assembles the correct +// host URI and propagates the daemonSocket and namespace fields. +func TestContainerdEngineEndpoint(t *testing.T) { + t.Parallel() + + e := &containerdEngine{ + socket: "/run/containerd/containerd.sock", + daemonSocket: "/host/run/containerd/containerd.sock", + namespace: "k8s.io", + logger: slog.Default(), + } + + ep := e.Endpoint() + assert.Equal(t, "unix:///run/containerd/containerd.sock", ep.Host) + assert.Equal(t, "/host/run/containerd/containerd.sock", ep.DaemonSocket) + assert.Equal(t, "k8s.io", ep.Namespace) +} + // TestNewContainerdEngineDefaults verifies that newContainerdEngine applies // the expected default namespace and logger when the config fields are empty. // A real unix socket listener is created so the gRPC client constructor diff --git a/driver_docker_mock_test.go b/driver_docker_mock_test.go new file mode 100644 index 0000000..5071fb6 --- /dev/null +++ b/driver_docker_mock_test.go @@ -0,0 +1,1224 @@ +// Copyright 2026 The Gopherly Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package currus + +import ( + "archive/tar" + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + containertypes "github.com/moby/moby/api/types/container" + eventtypes "github.com/moby/moby/api/types/events" + imagetypes "github.com/moby/moby/api/types/image" + networktypes "github.com/moby/moby/api/types/network" + volumetypes "github.com/moby/moby/api/types/volume" +) + +// newMockDockerDaemon starts an [httptest.Server] that simulates a Docker +// daemon and returns a *dockerEngine pointing at it. The handlers map uses +// patterns like "GET /containers/json" (without version prefix). The server +// automatically strips the Docker API version prefix (/v1.XX) before +// dispatching. +// +// A default /_ping handler is always registered so the client can negotiate +// the API version. +func newMockDockerDaemon(t *testing.T, handlers map[string]http.HandlerFunc) *dockerEngine { + t.Helper() + + inner := http.NewServeMux() + for pattern, h := range handlers { + inner.HandleFunc(pattern, h) + } + + // /_ping is required for Ping() calls and version negotiation. + if _, ok := handlers["HEAD /_ping"]; !ok { + inner.HandleFunc("HEAD /_ping", mockPingHandler) + } + if _, ok := handlers["GET /_ping"]; !ok { + inner.HandleFunc("GET /_ping", mockPingHandler) + } + + // Version-stripping middleware: the moby client prefixes all paths with + // /vMAJOR.MINOR (e.g. /v1.54/containers/json). The mux registered above + // uses un-versioned paths, so we strip the prefix before dispatching. + outer := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if strings.HasPrefix(path, "/v") { + if idx := strings.Index(path[2:], "/"); idx >= 0 { + path = path[2+idx:] + } + } + r2 := r.Clone(r.Context()) + r2.URL.Path = path + inner.ServeHTTP(w, r2) + }) + + srv := httptest.NewServer(outer) + t.Cleanup(srv.Close) + + addr := srv.Listener.Addr().String() + cli, err := client.New( + client.WithHost("tcp://"+addr), + client.WithAPIVersion("1.54"), + client.WithHTTPClient(srv.Client()), + ) + require.NoError(t, err) + + return &dockerEngine{ + cli: cli, + kind: Docker, + logger: slog.Default(), + } +} + +func mockPingHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Api-Version", "1.54") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) //nolint:errcheck +} + +func writeJSON(w http.ResponseWriter, statusCode int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(v); err != nil { + panic("test mock: encode JSON response: " + err.Error()) + } +} + +// TestDockerEngineKindAndCapabilities verifies that Kind, Capabilities, and +// Endpoint return the values set at construction time without making HTTP calls. +func TestDockerEngineKindAndCapabilities(t *testing.T) { + t.Parallel() + + e := &dockerEngine{ + kind: Docker, + caps: Caps{Rootless: true}, + host: "tcp://127.0.0.1:0", + daemonSocket: "/var/run/docker.sock", + logger: slog.Default(), + } + + assert.Equal(t, Docker, e.Kind()) + assert.True(t, e.Capabilities().Rootless) + ep := e.Endpoint() + assert.Equal(t, "tcp://127.0.0.1:0", ep.Host) + assert.Equal(t, "/var/run/docker.sock", ep.DaemonSocket) +} + +// TestDockerEnginePing verifies that Ping reaches the mock daemon. +func TestDockerEnginePing(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, nil) + require.NoError(t, e.Ping(t.Context())) +} + +// TestDockerEngineClose verifies that Close releases the HTTP client. +func TestDockerEngineClose(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, nil) + assert.NoError(t, e.Close()) +} + +// TestDockerEnginePullImage verifies that PullImage posts /images/create and +// waits for the JSON-stream response without error. +func TestDockerEnginePullImage(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /images/create": func(w http.ResponseWriter, _ *http.Request) { + // Return a minimal JSON stream message to simulate a pull. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"Pull complete"}` + "\n")) //nolint:errcheck + }, + }) + + err := e.PullImage(t.Context(), "nginx:latest", PullImageOpts{}) + require.NoError(t, err) +} + +// TestDockerEnginePullImageInvalidPlatform verifies that an invalid platform +// string is rejected before any HTTP call is made. +func TestDockerEnginePullImageInvalidPlatform(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, nil) + err := e.PullImage(t.Context(), "nginx:latest", PullImageOpts{Platform: "not/a/valid/platform"}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidSpec) +} + +// TestDockerEngineCreateContainer verifies that CreateContainer posts +// /containers/create and returns the container ID from the response. +func TestDockerEngineCreateContainer(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/create": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusCreated, containertypes.CreateResponse{ + ID: "abc123", + Warnings: []string{}, + }) + }, + }) + + id, err := e.CreateContainer(t.Context(), ContainerSpec{Image: "nginx:latest"}) + require.NoError(t, err) + assert.Equal(t, ContainerID("abc123"), id) +} + +// TestDockerEngineStartContainer verifies that StartContainer posts the +// correct endpoint and returns no error on HTTP 204. +func TestDockerEngineStartContainer(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/abc123/start": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + }) + + require.NoError(t, e.StartContainer(t.Context(), "abc123")) +} + +// TestDockerEngineStopContainer verifies that StopContainer posts the +// correct endpoint and returns no error on HTTP 204. +func TestDockerEngineStopContainer(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/abc123/stop": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + }) + + require.NoError(t, e.StopContainer(t.Context(), "abc123", StopContainerOpts{Timeout: 5 * time.Second})) +} + +// TestDockerEngineRemoveContainer verifies that RemoveContainer deletes the +// correct endpoint and returns no error on HTTP 204. +func TestDockerEngineRemoveContainer(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "DELETE /containers/abc123": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + }) + + require.NoError(t, e.RemoveContainer(t.Context(), "abc123", RemoveContainerOpts{Force: true})) +} + +// TestDockerEngineListContainers verifies that ListContainers decodes the +// container list from the mock response. +func TestDockerEngineListContainers(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /containers/json": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []containertypes.Summary{ + { + ID: "abc123", + Names: []string{"/mycontainer"}, + Image: "nginx:latest", + State: "running", + }, + }) + }, + }) + + containers, err := e.ListContainers(t.Context(), ListContainersOpts{All: true}) + require.NoError(t, err) + require.Len(t, containers, 1) + assert.Equal(t, ContainerID("abc123"), containers[0].ID) + assert.Equal(t, "mycontainer", containers[0].Name) + assert.Equal(t, "nginx:latest", containers[0].Image) +} + +// TestDockerEngineInspect verifies that Inspect decodes the container inspect +// response into a ContainerInfo. +func TestDockerEngineInspect(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /containers/abc123/json": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, containertypes.InspectResponse{ + ID: "abc123", + Name: "/mycontainer", + HostConfig: &containertypes.HostConfig{ + Privileged: false, + }, + State: &containertypes.State{ + Running: true, + }, + Config: &containertypes.Config{ + Image: "nginx:latest", + }, + }) + }, + }) + + info, err := e.Inspect(t.Context(), "abc123") + require.NoError(t, err) + assert.Equal(t, ContainerID("abc123"), info.ID) + assert.Equal(t, "mycontainer", info.Name) + assert.Equal(t, "nginx:latest", info.Image) + assert.True(t, info.State.Running) +} + +// TestDockerEngineContainerLogs covers all ContainerLogs code paths: +// the TTY raw-stream path, the non-TTY demuxed path (exercising demuxCloser), +// and the Tail option. +func TestDockerEngineContainerLogs(t *testing.T) { + t.Parallel() + + t.Run("TTY raw stream", func(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /containers/abc123/json": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, containertypes.InspectResponse{ + ID: "abc123", + Config: &containertypes.Config{Tty: true}, + }) + }, + "GET /containers/abc123/logs": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.docker.raw-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello from container\n")) //nolint:errcheck + }, + }) + + rc, err := e.ContainerLogs(t.Context(), "abc123", ContainerLogsOpts{}) + require.NoError(t, err) + defer func() { assert.NoError(t, rc.Close()) }() + + data, err := io.ReadAll(rc) + require.NoError(t, err) + assert.Equal(t, "hello from container\n", string(data)) + }) + + t.Run("no TTY demuxed stream", func(t *testing.T) { + t.Parallel() + + payload := []byte("log line from stdout\n") + var hdr [8]byte + hdr[0] = 1 // stdout stream + binary.BigEndian.PutUint32(hdr[4:], uint32(len(payload))) //nolint:gosec + muxed := append(hdr[:], payload...) + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /containers/mux1/json": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, containertypes.InspectResponse{ + ID: "mux1", + Config: &containertypes.Config{Tty: false}, + }) + }, + "GET /containers/mux1/logs": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.docker.multiplexed-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(muxed) //nolint:errcheck + }, + }) + + rc, err := e.ContainerLogs(t.Context(), "mux1", ContainerLogsOpts{}) + require.NoError(t, err) + + data, err := io.ReadAll(rc) + require.NoError(t, err) + assert.Equal(t, string(payload), string(data)) + require.NoError(t, rc.Close()) + }) + + t.Run("Tail option forwarded", func(t *testing.T) { + t.Parallel() + + var gotTail string + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /containers/tail1/json": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, containertypes.InspectResponse{ + ID: "tail1", + Config: &containertypes.Config{Tty: true}, + }) + }, + "GET /containers/tail1/logs": func(w http.ResponseWriter, r *http.Request) { + gotTail = r.URL.Query().Get("tail") + w.WriteHeader(http.StatusOK) + }, + }) + + rc, err := e.ContainerLogs(t.Context(), "tail1", ContainerLogsOpts{Tail: 50}) + require.NoError(t, err) + require.NoError(t, rc.Close()) + + assert.Equal(t, "50", gotTail) + }) +} + +// TestDockerEngineStats verifies that Stats returns a ContainerStats decoded +// from the mock daemon response. +func TestDockerEngineStats(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /containers/abc123/stats": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, containertypes.StatsResponse{ + MemoryStats: containertypes.MemoryStats{ + Usage: 1024 * 1024, + Limit: 512 * 1024 * 1024, + }, + }) + }, + }) + + stats, err := e.Stats(t.Context(), "abc123", StatsOpts{}) + require.NoError(t, err) + assert.Equal(t, uint64(1024*1024), stats.MemoryUsage) + assert.Equal(t, uint64(512*1024*1024), stats.MemoryLimit) +} + +// TestDockerEngineExec verifies that Exec creates an exec instance, attaches +// to it via the mock daemon using HTTP hijacking, and returns the output. +func TestDockerEngineExec(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/abc123/exec": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusCreated, map[string]string{"Id": "exec1"}) + }, + // ExecAttach uses HTTP Upgrade / hijacking. + "POST /exec/exec1/start": func(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + conn, bufrw, err := hj.Hijack() + if err != nil { + return + } + + // Write HTTP 101 Switching Protocols response manually. + _, _ = bufrw.WriteString("HTTP/1.1 101 UPGRADED\r\n") //nolint:errcheck + _, _ = bufrw.WriteString("Content-Type: application/vnd.docker.multiplexed-stream\r\n") //nolint:errcheck + _, _ = bufrw.WriteString("\r\n") //nolint:errcheck + + // Write mock stdout in Docker multiplexed stream format. + // Header: [stream_type(1B), 0, 0, 0, size(4B big-endian)] + payload := []byte("output\n") + var hdr [8]byte + hdr[0] = 1 // stdout + binary.BigEndian.PutUint32(hdr[4:], uint32(len(payload))) //nolint:gosec + _, _ = bufrw.Write(hdr[:]) //nolint:errcheck + _, _ = bufrw.Write(payload) //nolint:errcheck + _ = bufrw.Flush() //nolint:errcheck + + // Half-close the write side so the client gets EOF instead of + // a connection reset when stdcopy.StdCopy reads to completion. + if tc, ok2 := conn.(*net.TCPConn); ok2 { + _ = tc.CloseWrite() //nolint:errcheck + } else { + _ = conn.Close() //nolint:errcheck + } + }, + "GET /exec/exec1/json": func(w http.ResponseWriter, _ *http.Request) { + exitCode := 0 + writeJSON(w, http.StatusOK, containertypes.ExecInspectResponse{ + ExitCode: &exitCode, + }) + }, + }) + + result, err := e.Exec(t.Context(), "abc123", ExecOpts{ + Cmd: []string{"echo", "output"}, + AttachStdout: true, + AttachStderr: true, + }) + require.NoError(t, err) + assert.Equal(t, 0, result.ExitCode) + require.NotNil(t, result.Stdout) + out, readErr := io.ReadAll(result.Stdout) + require.NoError(t, readErr) + assert.Equal(t, "output\n", string(out)) +} + +// TestDockerEngineWaitContainer verifies that WaitContainer returns the exit +// status from the mock daemon via the wait endpoint. +func TestDockerEngineWaitContainer(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/abc123/wait": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, containertypes.WaitResponse{StatusCode: 0}) + }, + }) + + ch, err := e.WaitContainer(t.Context(), "abc123", WaitContainerOpts{}) + require.NoError(t, err) + select { + case res := <-ch: + assert.Equal(t, 0, res.StatusCode) + assert.Empty(t, res.Error) + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for WaitContainer result") + } +} + +// TestDockerEngineEvents verifies that Events returns a channel that yields +// events from the mock daemon's event stream. +func TestDockerEngineEvents(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /events": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(eventtypes.Message{ //nolint:errcheck + Type: eventtypes.ContainerEventType, + Action: eventtypes.ActionStart, + Actor: eventtypes.Actor{ID: "abc123"}, + }) + // Closing the response ends the event stream. + }, + }) + + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + + ch, err := e.Events(ctx) + require.NoError(t, err) + + select { + case ev, ok := <-ch: + if !ok { + // Channel closed before receiving event; may happen if EOF came first. + return + } + assert.Equal(t, string(eventtypes.ContainerEventType), ev.Type) + assert.Equal(t, string(eventtypes.ActionStart), ev.Action) + case <-ctx.Done(): + t.Fatal("timed out waiting for event") + } +} + +// TestDockerEngineListImages verifies that ListImages decodes the image list. +func TestDockerEngineListImages(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /images/json": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []imagetypes.Summary{ + {ID: "sha256:abc123", RepoTags: []string{"nginx:latest"}, Size: 50_000_000}, + }) + }, + }) + + images, err := e.ListImages(t.Context(), ListImagesOpts{}) + require.NoError(t, err) + require.Len(t, images, 1) + assert.Equal(t, "sha256:abc123", images[0].ID) + assert.Equal(t, []string{"nginx:latest"}, images[0].Tags) +} + +// TestDockerEngineRemoveImage verifies that RemoveImage deletes the image. +func TestDockerEngineRemoveImage(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "DELETE /images/nginx:latest": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []imagetypes.DeleteResponse{ + {Deleted: "sha256:abc123"}, + }) + }, + }) + + require.NoError(t, e.RemoveImage(t.Context(), "nginx:latest", RemoveImageOpts{})) +} + +// TestDockerEngineTagImage verifies that TagImage posts to /images/{src}/tag. +func TestDockerEngineTagImage(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /images/nginx:latest/tag": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + }) + + require.NoError(t, e.TagImage(t.Context(), "nginx:latest", "myrepo/nginx:v1")) +} + +// TestDockerEngineCreateNetwork verifies that CreateNetwork posts to +// /networks/create and returns the network ID. +func TestDockerEngineCreateNetwork(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /networks/create": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusCreated, networktypes.CreateResponse{ID: "net123"}) + }, + }) + + id, err := e.CreateNetwork(t.Context(), "mynet", CreateNetworkOpts{Driver: "bridge"}) + require.NoError(t, err) + assert.Equal(t, NetworkID("net123"), id) +} + +// TestDockerEngineListNetworks verifies that ListNetworks decodes the list. +func TestDockerEngineListNetworks(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /networks": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []networktypes.Summary{ + {Network: networktypes.Network{ID: "net123", Name: "mynet", Driver: "bridge"}}, + }) + }, + }) + + nets, err := e.ListNetworks(t.Context(), ListNetworksOpts{}) + require.NoError(t, err) + require.Len(t, nets, 1) + assert.Equal(t, NetworkID("net123"), nets[0].ID) + assert.Equal(t, "mynet", nets[0].Name) + assert.Equal(t, "bridge", nets[0].Driver) +} + +// TestDockerEngineRemoveNetwork verifies that RemoveNetwork deletes the network. +func TestDockerEngineRemoveNetwork(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "DELETE /networks/net123": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + }) + + require.NoError(t, e.RemoveNetwork(t.Context(), "net123", RemoveNetworkOpts{})) +} + +// TestDockerEngineConnectContainer verifies that ConnectContainer posts to +// /networks/{net}/connect. +func TestDockerEngineConnectContainer(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /networks/net123/connect": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + }) + + require.NoError(t, e.ConnectContainer(t.Context(), "net123", "abc123", ConnectOpts{Aliases: []string{"alias"}})) +} + +// TestDockerEngineDisconnectContainer verifies that DisconnectContainer posts +// to /networks/{net}/disconnect, with and without the Force option. +func TestDockerEngineDisconnectContainer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + network NetworkID + opts DisconnectOpts + }{ + { + name: "default opts", + network: "net123", + opts: DisconnectOpts{}, + }, + { + name: "force flag", + network: "net1", + opts: DisconnectOpts{Force: true}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /networks/" + string(tc.network) + "/disconnect": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + }) + require.NoError(t, e.DisconnectContainer(t.Context(), tc.network, "abc123", tc.opts)) + }) + } +} + +// TestDockerEngineCreateVolume verifies that CreateVolume posts to /volumes/create +// and returns the volume ID. +func TestDockerEngineCreateVolume(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /volumes/create": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusCreated, volumetypes.Volume{Name: "myvol", Driver: "local"}) + }, + }) + + id, err := e.CreateVolume(t.Context(), "myvol", CreateVolumeOpts{Driver: "local"}) + require.NoError(t, err) + assert.Equal(t, VolumeID("myvol"), id) +} + +// TestDockerEngineListVolumes verifies that ListVolumes decodes the volume list. +func TestDockerEngineListVolumes(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /volumes": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, volumetypes.ListResponse{ + Volumes: []volumetypes.Volume{ + {Name: "myvol", Driver: "local", Mountpoint: "/var/lib/docker/volumes/myvol"}, + }, + }) + }, + }) + + vols, err := e.ListVolumes(t.Context(), ListVolumesOpts{}) + require.NoError(t, err) + require.Len(t, vols, 1) + assert.Equal(t, VolumeID("myvol"), vols[0].ID) + assert.Equal(t, "local", vols[0].Driver) +} + +// TestDockerEngineRemoveVolume verifies that RemoveVolume deletes the volume. +func TestDockerEngineRemoveVolume(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "DELETE /volumes/myvol": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + }) + + require.NoError(t, e.RemoveVolume(t.Context(), "myvol", RemoveVolumeOpts{})) +} + +// TestDockerEngineCopyToContainer verifies that CopyToContainer issues a PUT +// to /containers/{id}/archive with the tar content. +func TestDockerEngineCopyToContainer(t *testing.T) { + t.Parallel() + + var received []byte + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "PUT /containers/abc123/archive": func(w http.ResponseWriter, r *http.Request) { + received, _ = io.ReadAll(r.Body) //nolint:errcheck + w.WriteHeader(http.StatusOK) + }, + }) + + // Build a small tar archive to send. + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "hello.txt", + Size: 5, + Mode: 0o644, + })) + _, err := tw.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + + err = e.CopyToContainer(t.Context(), "abc123", CopyToContainerOpts{ + DestPath: "/tmp", + Content: bytes.NewReader(buf.Bytes()), + }) + require.NoError(t, err) + assert.NotEmpty(t, received) +} + +// TestDockerEngineCopyFromContainer verifies that CopyFromContainer issues a +// GET to /containers/{id}/archive and returns the response body. +func TestDockerEngineCopyFromContainer(t *testing.T) { + t.Parallel() + + // Build a minimal tar archive to return. + var tarBuf bytes.Buffer + tw := tar.NewWriter(&tarBuf) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "hello.txt", Size: 5, Mode: 0o644})) + _, err := tw.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + + // CopyFromContainer requires an X-Docker-Container-Path-Stat header + // containing a base64-encoded JSON PathStat. + pathStatJSON, err := json.Marshal(map[string]any{ + "name": "hello.txt", + "size": 5, + "mode": 0o644, + }) + require.NoError(t, err) + pathStatHeader := base64.StdEncoding.EncodeToString(pathStatJSON) + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /containers/abc123/archive": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-tar") + w.Header().Set("X-Docker-Container-Path-Stat", pathStatHeader) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(tarBuf.Bytes()) //nolint:errcheck + }, + }) + + rc, err := e.CopyFromContainer(t.Context(), "abc123", CopyFromContainerOpts{SrcPath: "/tmp/hello.txt"}) + require.NoError(t, err) + defer func() { assert.NoError(t, rc.Close()) }() + + data, err := io.ReadAll(rc) + require.NoError(t, err) + assert.NotEmpty(t, data) +} + +// TestDockerEngineErrorPaths verifies that each engine method wraps a +// non-2xx daemon response as a non-nil error. Adding a new method's error +// path only requires a new row in the table. +func TestDockerEngineErrorPaths(t *testing.T) { + t.Parallel() + + notFound := func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusNotFound, map[string]string{"message": "not found"}) + } + serverErr := func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "server error"}) + } + + tests := []struct { + name string + handlers map[string]http.HandlerFunc + run func(t *testing.T, e *dockerEngine) error + }{ + { + name: "PullImage", + handlers: map[string]http.HandlerFunc{"POST /images/create": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + return e.PullImage(t.Context(), "no-such-image:latest", PullImageOpts{}) + }, + }, + { + name: "StartContainer", + handlers: map[string]http.HandlerFunc{"POST /containers/c/start": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + return e.StartContainer(t.Context(), "c") + }, + }, + { + name: "RemoveContainer", + handlers: map[string]http.HandlerFunc{"DELETE /containers/c": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + return e.RemoveContainer(t.Context(), "c", RemoveContainerOpts{}) + }, + }, + { + name: "Stats", + handlers: map[string]http.HandlerFunc{"GET /containers/c/stats": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + _, err := e.Stats(t.Context(), "c", StatsOpts{}) + + return err + }, + }, + { + name: "ExecCreate", + handlers: map[string]http.HandlerFunc{"POST /containers/c/exec": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + _, err := e.Exec(t.Context(), "c", ExecOpts{Cmd: []string{"ls"}}) + + return err + }, + }, + { + name: "RemoveImage", + handlers: map[string]http.HandlerFunc{"DELETE /images/img": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + return e.RemoveImage(t.Context(), "img", RemoveImageOpts{}) + }, + }, + { + name: "TagImage", + handlers: map[string]http.HandlerFunc{"POST /images/img/tag": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + return e.TagImage(t.Context(), "img", "dst:latest") + }, + }, + { + name: "CreateNetwork", + handlers: map[string]http.HandlerFunc{"POST /networks/create": serverErr}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + _, err := e.CreateNetwork(t.Context(), "net", CreateNetworkOpts{}) + + return err + }, + }, + { + name: "RemoveNetwork", + handlers: map[string]http.HandlerFunc{"DELETE /networks/net": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + return e.RemoveNetwork(t.Context(), "net", RemoveNetworkOpts{}) + }, + }, + { + name: "CreateVolume", + handlers: map[string]http.HandlerFunc{"POST /volumes/create": serverErr}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + _, err := e.CreateVolume(t.Context(), "vol", CreateVolumeOpts{}) + + return err + }, + }, + { + name: "RemoveVolume", + handlers: map[string]http.HandlerFunc{"DELETE /volumes/vol": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + return e.RemoveVolume(t.Context(), "vol", RemoveVolumeOpts{}) + }, + }, + { + name: "CopyToContainer", + handlers: map[string]http.HandlerFunc{"PUT /containers/c/archive": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + var buf bytes.Buffer + + return e.CopyToContainer(t.Context(), "c", CopyToContainerOpts{DestPath: "/tmp", Content: &buf}) + }, + }, + { + name: "CopyFromContainer", + handlers: map[string]http.HandlerFunc{"GET /containers/c/archive": notFound}, + run: func(t *testing.T, e *dockerEngine) error { + t.Helper() + _, err := e.CopyFromContainer(t.Context(), "c", CopyFromContainerOpts{SrcPath: "/tmp"}) + + return err + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newMockDockerDaemon(t, tc.handlers) + require.Error(t, tc.run(t, e)) + }) + } +} + +// TestDockerEngineEventsClosedStream verifies Events returns a closed channel +// when the server closes the event stream immediately. +func TestDockerEngineEventsClosedStream(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /events": func(w http.ResponseWriter, _ *http.Request) { + // Respond with no body — immediate EOF. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + }, + }) + + ch, err := e.Events(t.Context()) + require.NoError(t, err) + + select { + case _, ok := <-ch: + assert.Falsef(t, ok, "channel should be closed on empty stream") + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for Events channel to close") + } +} + +// TestDockerEngineExecCreateError verifies that Exec returns an error when +// ExecCreate fails. +func TestDockerEngineExecCreateError(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/nocontainer/exec": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusNotFound, map[string]string{"message": "No such container"}) + }, + }) + + _, err := e.Exec(t.Context(), "nocontainer", ExecOpts{Cmd: []string{"ls"}}) + require.Error(t, err) +} + +// TestDockerEngineEventsError verifies that Events closes the channel after +// the daemon returns an error response. This exercises the case err := <-raw.Err +// branch with a non-nil error. +func TestDockerEngineEventsError(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "GET /events": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "message": "events stream unavailable", + }) + }, + }) + + ch, err := e.Events(t.Context()) + require.NoError(t, err) + + // The error from the server is propagated via raw.Err; the goroutine + // logs it and closes out. We should see the channel close. + select { + case _, ok := <-ch: + assert.Falsef(t, ok, "channel should close after stream error") + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for Events channel to close on error") + } +} + +// TestDockerEngineCredentials verifies that Credentials dispatches to the +// appropriate credential chain for each engine kind. It uses environment-only +// credentials so no binary helpers need to be on PATH. +func TestDockerEngineCredentials(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind EngineKind + }{ + {"docker", Docker}, + {"podman", Podman}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newMockDockerDaemon(t, nil) + e.kind = tc.kind + // Credentials reads the file system / env; we just verify it + // returns without an unexpected error. An empty map is valid + // when no credential store is configured in the test environment. + _, err := e.Credentials(t.Context()) + assert.NoError(t, err) + }) + } +} + +// TestDockerEnginePullImageWithPlatform verifies PullImage sends the platform +// constraint when o.Platform is set. +func TestDockerEnginePullImageWithPlatform(t *testing.T) { + t.Parallel() + + var requestedRef string + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /images/create": func(w http.ResponseWriter, r *http.Request) { + requestedRef = r.URL.Query().Get("fromImage") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"Pull complete"}`)) //nolint:errcheck + }, + }) + + err := e.PullImage(t.Context(), "nginx:latest", PullImageOpts{Platform: "linux/amd64"}) + require.NoError(t, err) + assert.Contains(t, requestedRef, "nginx") +} + +// TestDockerEngineCreateContainerNetworkConnectError verifies that +// CreateContainer returns an error when connecting a second network fails. +func TestDockerEngineCreateContainerNetworkConnectError(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/create": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusCreated, containertypes.CreateResponse{ID: "errnet1"}) + }, + "POST /networks/net2/connect": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "network error"}) + }, + }) + + spec := ContainerSpec{ + Image: "nginx:latest", + Networks: []NetworkAttachment{ + {Name: "net1"}, + {Name: "net2"}, + }, + } + _, err := e.CreateContainer(t.Context(), spec) + require.Error(t, err) +} + +// TestDockerEngineCreateContainerMultipleNetworks verifies that CreateContainer +// calls NetworkConnect for each extra network beyond the first. +func TestDockerEngineCreateContainerMultipleNetworks(t *testing.T) { + t.Parallel() + + networkConnectCalls := 0 + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/create": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusCreated, containertypes.CreateResponse{ID: "multi1"}) + }, + "POST /networks/net2/connect": func(w http.ResponseWriter, _ *http.Request) { + networkConnectCalls++ + w.WriteHeader(http.StatusOK) + }, + "POST /networks/net3/connect": func(w http.ResponseWriter, _ *http.Request) { + networkConnectCalls++ + w.WriteHeader(http.StatusOK) + }, + }) + + spec := ContainerSpec{ + Image: "nginx:latest", + Networks: []NetworkAttachment{ + {Name: "net1"}, + {Name: "net2"}, + {Name: "net3"}, + }, + } + id, err := e.CreateContainer(t.Context(), spec) + require.NoError(t, err) + assert.Equal(t, ContainerID("multi1"), id) + assert.Equalf(t, 2, networkConnectCalls, "should call NetworkConnect for each extra network") +} + +// TestDockerEngineWaitContainerConditions verifies all WaitCondition variants +// and the daemon-error-message path in a single table. +func TestDockerEngineWaitContainerConditions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + containerID string + opts WaitContainerOpts + response containertypes.WaitResponse + wantCode int + wantErrMsg string + }{ + { + name: "WaitConditionNextExit", + containerID: "c1", + opts: WaitContainerOpts{Condition: WaitConditionNextExit}, + response: containertypes.WaitResponse{StatusCode: 42}, + wantCode: 42, + }, + { + name: "WaitConditionRemoved", + containerID: "c2", + opts: WaitContainerOpts{Condition: WaitConditionRemoved}, + response: containertypes.WaitResponse{StatusCode: 0}, + wantCode: 0, + }, + { + name: "daemon error message forwarded", + containerID: "c3", + opts: WaitContainerOpts{}, + response: containertypes.WaitResponse{ + StatusCode: 1, + Error: &containertypes.WaitExitError{Message: "container failed to exit cleanly"}, + }, + wantCode: 1, + wantErrMsg: "container failed to exit cleanly", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/" + tc.containerID + "/wait": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, tc.response) + }, + }) + ch, err := e.WaitContainer(t.Context(), ContainerID(tc.containerID), tc.opts) + require.NoError(t, err) + select { + case res := <-ch: + assert.Equal(t, tc.wantCode, res.StatusCode) + assert.Equal(t, tc.wantErrMsg, res.Error) + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for WaitContainer result") + } + }) + } +} + +// TestDockerEnginePingError verifies Ping wraps the error from a failed ping. +func TestDockerEnginePingError(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "HEAD /_ping": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + "GET /_ping": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + }) + + err := e.Ping(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "docker: ping") +} + +// TestDockerEngineCreateContainerWithInitAndPorts verifies CreateContainer +// sets the Init flag and port bindings correctly. +func TestDockerEngineCreateContainerWithInitAndPorts(t *testing.T) { + t.Parallel() + + e := newMockDockerDaemon(t, map[string]http.HandlerFunc{ + "POST /containers/create": func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusCreated, containertypes.CreateResponse{ID: "init1"}) + }, + }) + + spec := ContainerSpec{ + Image: "nginx:latest", + Init: true, + Ports: []Port{ + {Container: 80, Protocol: "tcp"}, + }, + } + id, err := e.CreateContainer(t.Context(), spec) + require.NoError(t, err) + assert.Equal(t, ContainerID("init1"), id) +} diff --git a/driver_docker_test.go b/driver_docker_test.go index 091219b..4a196e8 100644 --- a/driver_docker_test.go +++ b/driver_docker_test.go @@ -17,6 +17,7 @@ package currus import ( "errors" "fmt" + "net/netip" "testing" "github.com/moby/moby/api/types/container" @@ -477,3 +478,218 @@ func TestDockerNetInputOutput(t *testing.T) { assert.Equal(t, uint64(275), dockerNetOutput(s)) }) } + +// TestCmpPort verifies that cmpPort sorts by container port first, then +// protocol, then host port. This is the sort key used by dockerInspectPorts. +func TestCmpPort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b Port + want int // sign only: negative / zero / positive + }{ + { + name: "same port same protocol same host returns zero", + a: Port{Container: 80, Protocol: "tcp", Host: 8080}, + b: Port{Container: 80, Protocol: "tcp", Host: 8080}, + want: 0, + }, + { + name: "lower container port sorts first", + a: Port{Container: 80, Protocol: "tcp"}, + b: Port{Container: 443, Protocol: "tcp"}, + want: -1, + }, + { + name: "higher container port sorts last", + a: Port{Container: 443, Protocol: "tcp"}, + b: Port{Container: 80, Protocol: "tcp"}, + want: 1, + }, + { + name: "same container port: tcp before udp", + a: Port{Container: 53, Protocol: "tcp"}, + b: Port{Container: 53, Protocol: "udp"}, + want: -1, + }, + { + name: "same container port: udp after tcp", + a: Port{Container: 53, Protocol: "udp"}, + b: Port{Container: 53, Protocol: "tcp"}, + want: 1, + }, + { + name: "same container port and protocol: lower host port first", + a: Port{Container: 80, Protocol: "tcp", Host: 8080}, + b: Port{Container: 80, Protocol: "tcp", Host: 9090}, + want: -1, + }, + { + name: "same container port and protocol: higher host port last", + a: Port{Container: 80, Protocol: "tcp", Host: 9090}, + b: Port{Container: 80, Protocol: "tcp", Host: 8080}, + want: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := cmpPort(tc.a, tc.b) + switch { + case tc.want < 0: + assert.Negative(t, got) + case tc.want > 0: + assert.Positive(t, got) + default: + assert.Zero(t, got) + } + }) + } +} + +// TestDockerInspectPorts verifies dockerInspectPorts covers all branches: +// running container (NetworkSettings), stopped container (HostConfig fallback), +// ephemeral port skip, HostIP handling, nil inputs, and sort order. +func TestDockerInspectPorts(t *testing.T) { + t.Parallel() + + mustParseIP := func(s string) netip.Addr { + t.Helper() + addr, err := netip.ParseAddr(s) + require.NoError(t, err) + + return addr + } + + makePortKey := func(portProto string) network.Port { + t.Helper() + p, err := network.ParsePort(portProto) + require.NoError(t, err) + + return p + } + + t.Run("nil network settings and nil host config returns nil", func(t *testing.T) { + t.Parallel() + assert.Nil(t, dockerInspectPorts(nil, nil)) + }) + + t.Run("empty port map returns nil", func(t *testing.T) { + t.Parallel() + ns := &container.NetworkSettings{Ports: network.PortMap{}} + assert.Nil(t, dockerInspectPorts(ns, nil)) + }) + + t.Run("running container: uses NetworkSettings.Ports", func(t *testing.T) { + t.Parallel() + ns := &container.NetworkSettings{ + Ports: network.PortMap{ + makePortKey("80/tcp"): []network.PortBinding{ + {HostPort: "8080"}, + }, + }, + } + hc := &container.HostConfig{ + PortBindings: network.PortMap{ + makePortKey("80/tcp"): []network.PortBinding{ + {HostPort: "9999"}, + }, + }, + } + ports := dockerInspectPorts(ns, hc) + require.Len(t, ports, 1) + assert.Equal(t, uint16(80), ports[0].Container) + assert.Equal(t, uint16(8080), ports[0].Host) + assert.Equal(t, "tcp", ports[0].Protocol) + }) + + t.Run("stopped container: falls back to HostConfig.PortBindings", func(t *testing.T) { + t.Parallel() + hc := &container.HostConfig{ + PortBindings: network.PortMap{ + makePortKey("443/tcp"): []network.PortBinding{ + {HostPort: "8443"}, + }, + }, + } + ports := dockerInspectPorts(nil, hc) + require.Len(t, ports, 1) + assert.Equal(t, uint16(443), ports[0].Container) + assert.Equal(t, uint16(8443), ports[0].Host) + }) + + t.Run("empty HostPort entries are skipped", func(t *testing.T) { + t.Parallel() + hc := &container.HostConfig{ + PortBindings: network.PortMap{ + makePortKey("80/tcp"): []network.PortBinding{ + {HostPort: ""}, // ephemeral — skipped + {HostPort: "8080"}, // explicit — included + }, + }, + } + ports := dockerInspectPorts(nil, hc) + require.Len(t, ports, 1) + assert.Equal(t, uint16(8080), ports[0].Host) + }) + + t.Run("all empty HostPorts returns nil", func(t *testing.T) { + t.Parallel() + hc := &container.HostConfig{ + PortBindings: network.PortMap{ + makePortKey("80/tcp"): []network.PortBinding{ + {HostPort: ""}, + }, + }, + } + assert.Nil(t, dockerInspectPorts(nil, hc)) + }) + + t.Run("HostIP is included when valid", func(t *testing.T) { + t.Parallel() + hc := &container.HostConfig{ + PortBindings: network.PortMap{ + makePortKey("80/tcp"): []network.PortBinding{ + {HostPort: "8080", HostIP: mustParseIP("127.0.0.1")}, + }, + }, + } + ports := dockerInspectPorts(nil, hc) + require.Len(t, ports, 1) + assert.Equal(t, "127.0.0.1", ports[0].HostIP) + }) + + t.Run("ports are sorted by container port then protocol then host port", func(t *testing.T) { + t.Parallel() + hc := &container.HostConfig{ + PortBindings: network.PortMap{ + makePortKey("443/tcp"): []network.PortBinding{{HostPort: "8443"}}, + makePortKey("80/tcp"): []network.PortBinding{{HostPort: "8080"}}, + makePortKey("53/udp"): []network.PortBinding{{HostPort: "5353"}}, + makePortKey("53/tcp"): []network.PortBinding{{HostPort: "5354"}}, + }, + } + ports := dockerInspectPorts(nil, hc) + require.Len(t, ports, 4) + assert.Equal(t, uint16(53), ports[0].Container) + assert.Equal(t, "tcp", ports[0].Protocol) + assert.Equal(t, uint16(53), ports[1].Container) + assert.Equal(t, "udp", ports[1].Protocol) + assert.Equal(t, uint16(80), ports[2].Container) + assert.Equal(t, uint16(443), ports[3].Container) + }) + + t.Run("UDP protocol preserved", func(t *testing.T) { + t.Parallel() + hc := &container.HostConfig{ + PortBindings: network.PortMap{ + makePortKey("53/udp"): []network.PortBinding{{HostPort: "5353"}}, + }, + } + ports := dockerInspectPorts(nil, hc) + require.Len(t, ports, 1) + assert.Equal(t, "udp", ports[0].Protocol) + }) +}