From f6b90bc2535042dfcdbca4b3167e2533f78a51a0 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 24 Jul 2025 01:06:19 +0200 Subject: [PATCH] add internal fork of docker/docker/registry This adds an internal fork of [github.com/docker/docker/registry], taken at commit [moby@f651a5d]. Git history was not preserved in this fork, but can be found using the URLs provided. This fork was created to remove the dependency on the "Moby" codebase, and because the CLI only needs a subset of its features. The original package was written specifically for use in the daemon code, and includes functionality that cannot be used in the CLI. [github.com/docker/docker/registry]: https://pkg.go.dev/github.com/docker/docker@v28.3.2+incompatible/registry [moby@49306c6]: https://github.com/moby/moby/tree/49306c607b72c5bf0a8e426f5a9760fa5ef96ea0/registry Signed-off-by: Sebastiaan van Stijn --- cli/command/image/push.go | 2 +- cli/command/image/trust.go | 2 +- cli/command/plugin/install.go | 2 +- cli/command/plugin/push.go | 2 +- cli/command/registry/login.go | 2 +- cli/command/registry/login_test.go | 2 +- cli/command/registry/logout.go | 2 +- cli/command/service/trust.go | 2 +- cli/command/system/info.go | 2 +- cli/registry/client/endpoint.go | 2 +- cli/registry/client/fetcher.go | 2 +- cli/trust/trust.go | 2 +- cli/trust/trust_push.go | 2 +- internal/oauth/manager/manager.go | 2 +- .../docker => internal}/registry/auth.go | 0 internal/registry/auth_test.go | 106 +++ .../docker => internal}/registry/config.go | 0 internal/registry/config_test.go | 340 ++++++++++ internal/registry/doc.go | 12 + .../docker => internal}/registry/errors.go | 0 .../docker => internal}/registry/registry.go | 0 internal/registry/registry_mock_test.go | 120 ++++ internal/registry/registry_test.go | 637 ++++++++++++++++++ .../docker => internal}/registry/search.go | 0 .../registry/search_endpoint_v1.go | 0 internal/registry/search_endpoint_v1_test.go | 237 +++++++ .../registry/search_session.go | 0 internal/registry/search_test.go | 418 ++++++++++++ .../docker => internal}/registry/service.go | 0 .../registry/service_v2.go | 0 .../docker => internal}/registry/types.go | 0 vendor.mod | 4 +- vendor/modules.txt | 1 - 33 files changed, 1886 insertions(+), 17 deletions(-) rename {vendor/github.com/docker/docker => internal}/registry/auth.go (100%) create mode 100644 internal/registry/auth_test.go rename {vendor/github.com/docker/docker => internal}/registry/config.go (100%) create mode 100644 internal/registry/config_test.go create mode 100644 internal/registry/doc.go rename {vendor/github.com/docker/docker => internal}/registry/errors.go (100%) rename {vendor/github.com/docker/docker => internal}/registry/registry.go (100%) create mode 100644 internal/registry/registry_mock_test.go create mode 100644 internal/registry/registry_test.go rename {vendor/github.com/docker/docker => internal}/registry/search.go (100%) rename {vendor/github.com/docker/docker => internal}/registry/search_endpoint_v1.go (100%) create mode 100644 internal/registry/search_endpoint_v1_test.go rename {vendor/github.com/docker/docker => internal}/registry/search_session.go (100%) create mode 100644 internal/registry/search_test.go rename {vendor/github.com/docker/docker => internal}/registry/service.go (100%) rename {vendor/github.com/docker/docker => internal}/registry/service_v2.go (100%) rename {vendor/github.com/docker/docker => internal}/registry/types.go (100%) diff --git a/cli/command/image/push.go b/cli/command/image/push.go index 8b78ef0e11..c3fd7611d9 100644 --- a/cli/command/image/push.go +++ b/cli/command/image/push.go @@ -16,8 +16,8 @@ import ( "github.com/docker/cli/cli/command/completion" "github.com/docker/cli/cli/streams" "github.com/docker/cli/internal/jsonstream" + "github.com/docker/cli/internal/registry" "github.com/docker/cli/internal/tui" - "github.com/docker/docker/registry" "github.com/moby/moby/api/types/auxprogress" "github.com/moby/moby/api/types/image" registrytypes "github.com/moby/moby/api/types/registry" diff --git a/cli/command/image/trust.go b/cli/command/image/trust.go index b15d537d62..5381c1b55b 100644 --- a/cli/command/image/trust.go +++ b/cli/command/image/trust.go @@ -11,7 +11,7 @@ import ( "github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/trust" "github.com/docker/cli/internal/jsonstream" - "github.com/docker/docker/registry" + "github.com/docker/cli/internal/registry" "github.com/moby/moby/api/types/image" registrytypes "github.com/moby/moby/api/types/registry" "github.com/opencontainers/go-digest" diff --git a/cli/command/plugin/install.go b/cli/command/plugin/install.go index f48f940d5c..b17c23ba5b 100644 --- a/cli/command/plugin/install.go +++ b/cli/command/plugin/install.go @@ -11,7 +11,7 @@ import ( "github.com/docker/cli/cli/command/image" "github.com/docker/cli/internal/jsonstream" "github.com/docker/cli/internal/prompt" - "github.com/docker/docker/registry" + "github.com/docker/cli/internal/registry" "github.com/moby/moby/api/types" registrytypes "github.com/moby/moby/api/types/registry" "github.com/pkg/errors" diff --git a/cli/command/plugin/push.go b/cli/command/plugin/push.go index 78d9313666..f7fe2cdee9 100644 --- a/cli/command/plugin/push.go +++ b/cli/command/plugin/push.go @@ -8,7 +8,7 @@ import ( "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/trust" "github.com/docker/cli/internal/jsonstream" - "github.com/docker/docker/registry" + "github.com/docker/cli/internal/registry" registrytypes "github.com/moby/moby/api/types/registry" "github.com/pkg/errors" "github.com/spf13/cobra" diff --git a/cli/command/registry/login.go b/cli/command/registry/login.go index dd1b99514a..f5853fe6a3 100644 --- a/cli/command/registry/login.go +++ b/cli/command/registry/login.go @@ -15,8 +15,8 @@ import ( "github.com/docker/cli/cli/config/configfile" configtypes "github.com/docker/cli/cli/config/types" "github.com/docker/cli/internal/oauth/manager" + "github.com/docker/cli/internal/registry" "github.com/docker/cli/internal/tui" - "github.com/docker/docker/registry" registrytypes "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" "github.com/pkg/errors" diff --git a/cli/command/registry/login_test.go b/cli/command/registry/login_test.go index 6d24011ec0..20ca3d9299 100644 --- a/cli/command/registry/login_test.go +++ b/cli/command/registry/login_test.go @@ -13,8 +13,8 @@ import ( configtypes "github.com/docker/cli/cli/config/types" "github.com/docker/cli/cli/streams" "github.com/docker/cli/internal/prompt" + "github.com/docker/cli/internal/registry" "github.com/docker/cli/internal/test" - "github.com/docker/docker/registry" registrytypes "github.com/moby/moby/api/types/registry" "github.com/moby/moby/api/types/system" "github.com/moby/moby/client" diff --git a/cli/command/registry/logout.go b/cli/command/registry/logout.go index 2af2cdad3f..34498871a5 100644 --- a/cli/command/registry/logout.go +++ b/cli/command/registry/logout.go @@ -8,7 +8,7 @@ import ( "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config/credentials" "github.com/docker/cli/internal/oauth/manager" - "github.com/docker/docker/registry" + "github.com/docker/cli/internal/registry" "github.com/spf13/cobra" ) diff --git a/cli/command/service/trust.go b/cli/command/service/trust.go index 3bf81845f4..f9c8bae33c 100644 --- a/cli/command/service/trust.go +++ b/cli/command/service/trust.go @@ -6,7 +6,7 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/trust" - "github.com/docker/docker/registry" + "github.com/docker/cli/internal/registry" "github.com/moby/moby/api/types/swarm" "github.com/opencontainers/go-digest" "github.com/pkg/errors" diff --git a/cli/command/system/info.go b/cli/command/system/info.go index 07306160c8..1016221572 100644 --- a/cli/command/system/info.go +++ b/cli/command/system/info.go @@ -19,8 +19,8 @@ import ( "github.com/docker/cli/cli/debug" flagsHelper "github.com/docker/cli/cli/flags" "github.com/docker/cli/internal/lazyregexp" + "github.com/docker/cli/internal/registry" "github.com/docker/cli/templates" - "github.com/docker/docker/registry" "github.com/docker/go-units" "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/api/types/system" diff --git a/cli/registry/client/endpoint.go b/cli/registry/client/endpoint.go index 9690e7d716..f08de5d14a 100644 --- a/cli/registry/client/endpoint.go +++ b/cli/registry/client/endpoint.go @@ -6,9 +6,9 @@ import ( "time" "github.com/distribution/reference" + "github.com/docker/cli/internal/registry" "github.com/docker/distribution/registry/client/auth" "github.com/docker/distribution/registry/client/transport" - "github.com/docker/docker/registry" registrytypes "github.com/moby/moby/api/types/registry" "github.com/pkg/errors" ) diff --git a/cli/registry/client/fetcher.go b/cli/registry/client/fetcher.go index f270d49432..e169179d16 100644 --- a/cli/registry/client/fetcher.go +++ b/cli/registry/client/fetcher.go @@ -6,6 +6,7 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/manifest/types" + "github.com/docker/cli/internal/registry" "github.com/docker/distribution" "github.com/docker/distribution/manifest/manifestlist" "github.com/docker/distribution/manifest/ocischema" @@ -13,7 +14,6 @@ import ( "github.com/docker/distribution/registry/api/errcode" v2 "github.com/docker/distribution/registry/api/v2" distclient "github.com/docker/distribution/registry/client" - "github.com/docker/docker/registry" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" diff --git a/cli/trust/trust.go b/cli/trust/trust.go index b64bf25e9a..b8525f0513 100644 --- a/cli/trust/trust.go +++ b/cli/trust/trust.go @@ -14,10 +14,10 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/config" + "github.com/docker/cli/internal/registry" "github.com/docker/distribution/registry/client/auth" "github.com/docker/distribution/registry/client/auth/challenge" "github.com/docker/distribution/registry/client/transport" - "github.com/docker/docker/registry" "github.com/docker/go-connections/tlsconfig" registrytypes "github.com/moby/moby/api/types/registry" "github.com/opencontainers/go-digest" diff --git a/cli/trust/trust_push.go b/cli/trust/trust_push.go index 02432383f4..b7b7b5ec89 100644 --- a/cli/trust/trust_push.go +++ b/cli/trust/trust_push.go @@ -11,7 +11,7 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/streams" "github.com/docker/cli/internal/jsonstream" - "github.com/docker/docker/registry" + "github.com/docker/cli/internal/registry" "github.com/moby/moby/api/types" registrytypes "github.com/moby/moby/api/types/registry" "github.com/opencontainers/go-digest" diff --git a/internal/oauth/manager/manager.go b/internal/oauth/manager/manager.go index 1150064364..96deb61b13 100644 --- a/internal/oauth/manager/manager.go +++ b/internal/oauth/manager/manager.go @@ -14,8 +14,8 @@ import ( "github.com/docker/cli/cli/streams" "github.com/docker/cli/internal/oauth" "github.com/docker/cli/internal/oauth/api" + "github.com/docker/cli/internal/registry" "github.com/docker/cli/internal/tui" - "github.com/docker/docker/registry" "github.com/morikuni/aec" "github.com/sirupsen/logrus" diff --git a/vendor/github.com/docker/docker/registry/auth.go b/internal/registry/auth.go similarity index 100% rename from vendor/github.com/docker/docker/registry/auth.go rename to internal/registry/auth.go diff --git a/internal/registry/auth_test.go b/internal/registry/auth_test.go new file mode 100644 index 0000000000..39a29d9080 --- /dev/null +++ b/internal/registry/auth_test.go @@ -0,0 +1,106 @@ +package registry + +import ( + "testing" + + "github.com/moby/moby/api/types/registry" + "gotest.tools/v3/assert" +) + +func buildAuthConfigs() map[string]registry.AuthConfig { + authConfigs := map[string]registry.AuthConfig{} + + for _, reg := range []string{"testIndex", IndexServer} { + authConfigs[reg] = registry.AuthConfig{ + Username: "docker-user", + Password: "docker-pass", + } + } + + return authConfigs +} + +func TestResolveAuthConfigIndexServer(t *testing.T) { + authConfigs := buildAuthConfigs() + indexConfig := authConfigs[IndexServer] + + officialIndex := ®istry.IndexInfo{ + Official: true, + } + privateIndex := ®istry.IndexInfo{ + Official: false, + } + + resolved := ResolveAuthConfig(authConfigs, officialIndex) + assert.Equal(t, resolved, indexConfig, "Expected ResolveAuthConfig to return IndexServer") + + resolved = ResolveAuthConfig(authConfigs, privateIndex) + assert.Check(t, resolved != indexConfig, "Expected ResolveAuthConfig to not return IndexServer") +} + +func TestResolveAuthConfigFullURL(t *testing.T) { + authConfigs := buildAuthConfigs() + + registryAuth := registry.AuthConfig{ + Username: "foo-user", + Password: "foo-pass", + } + localAuth := registry.AuthConfig{ + Username: "bar-user", + Password: "bar-pass", + } + officialAuth := registry.AuthConfig{ + Username: "baz-user", + Password: "baz-pass", + } + authConfigs[IndexServer] = officialAuth + + expectedAuths := map[string]registry.AuthConfig{ + "registry.example.com": registryAuth, + "localhost:8000": localAuth, + "example.com": localAuth, + } + + validRegistries := map[string][]string{ + "registry.example.com": { + "https://registry.example.com/v1/", + "http://registry.example.com/v1/", + "registry.example.com", + "registry.example.com/v1/", + }, + "localhost:8000": { + "https://localhost:8000/v1/", + "http://localhost:8000/v1/", + "localhost:8000", + "localhost:8000/v1/", + }, + "example.com": { + "https://example.com/v1/", + "http://example.com/v1/", + "example.com", + "example.com/v1/", + }, + } + + for configKey, registries := range validRegistries { + configured, ok := expectedAuths[configKey] + if !ok { + t.Fail() + } + index := ®istry.IndexInfo{ + Name: configKey, + } + for _, reg := range registries { + authConfigs[reg] = configured + resolved := ResolveAuthConfig(authConfigs, index) + if resolved.Username != configured.Username || resolved.Password != configured.Password { + t.Errorf("%s -> %v != %v\n", reg, resolved, configured) + } + delete(authConfigs, reg) + resolved = ResolveAuthConfig(authConfigs, index) + if resolved.Username == configured.Username || resolved.Password == configured.Password { + t.Errorf("%s -> %v == %v\n", reg, resolved, configured) + } + } + } +} diff --git a/vendor/github.com/docker/docker/registry/config.go b/internal/registry/config.go similarity index 100% rename from vendor/github.com/docker/docker/registry/config.go rename to internal/registry/config.go diff --git a/internal/registry/config_test.go b/internal/registry/config_test.go new file mode 100644 index 0000000000..699e763be6 --- /dev/null +++ b/internal/registry/config_test.go @@ -0,0 +1,340 @@ +package registry + +import ( + "testing" + + cerrdefs "github.com/containerd/errdefs" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestValidateMirror(t *testing.T) { + tests := []struct { + input string + output string + expectedErr string + }{ + // Valid cases + { + input: "http://mirror-1.example.com", + output: "http://mirror-1.example.com/", + }, + { + input: "http://mirror-1.example.com/", + output: "http://mirror-1.example.com/", + }, + { + input: "https://mirror-1.example.com", + output: "https://mirror-1.example.com/", + }, + { + input: "https://mirror-1.example.com/", + output: "https://mirror-1.example.com/", + }, + { + input: "http://localhost", + output: "http://localhost/", + }, + { + input: "https://localhost", + output: "https://localhost/", + }, + { + input: "http://localhost:5000", + output: "http://localhost:5000/", + }, + { + input: "https://localhost:5000", + output: "https://localhost:5000/", + }, + { + input: "http://127.0.0.1", + output: "http://127.0.0.1/", + }, + { + input: "https://127.0.0.1", + output: "https://127.0.0.1/", + }, + { + input: "http://127.0.0.1:5000", + output: "http://127.0.0.1:5000/", + }, + { + input: "https://127.0.0.1:5000", + output: "https://127.0.0.1:5000/", + }, + { + input: "http://mirror-1.example.com/v1/", + output: "http://mirror-1.example.com/v1/", + }, + { + input: "https://mirror-1.example.com/v1/", + output: "https://mirror-1.example.com/v1/", + }, + + // Invalid cases + { + input: "!invalid!://%as%", + expectedErr: `invalid mirror: "!invalid!://%as%" is not a valid URI: parse "!invalid!://%as%": first path segment in URL cannot contain colon`, + }, + { + input: "mirror-1.example.com", + expectedErr: `invalid mirror: no scheme specified for "mirror-1.example.com": must use either 'https://' or 'http://'`, + }, + { + input: "mirror-1.example.com:5000", + expectedErr: `invalid mirror: no scheme specified for "mirror-1.example.com:5000": must use either 'https://' or 'http://'`, + }, + { + input: "ftp://mirror-1.example.com", + expectedErr: `invalid mirror: unsupported scheme "ftp" in "ftp://mirror-1.example.com": must use either 'https://' or 'http://'`, + }, + { + input: "http://mirror-1.example.com/?q=foo", + expectedErr: `invalid mirror: query or fragment at end of the URI "http://mirror-1.example.com/?q=foo"`, + }, + { + input: "http://mirror-1.example.com/v1/?q=foo", + expectedErr: `invalid mirror: query or fragment at end of the URI "http://mirror-1.example.com/v1/?q=foo"`, + }, + { + input: "http://mirror-1.example.com/v1/?q=foo#frag", + expectedErr: `invalid mirror: query or fragment at end of the URI "http://mirror-1.example.com/v1/?q=foo#frag"`, + }, + { + input: "http://mirror-1.example.com?q=foo", + expectedErr: `invalid mirror: query or fragment at end of the URI "http://mirror-1.example.com?q=foo"`, + }, + { + input: "https://mirror-1.example.com#frag", + expectedErr: `invalid mirror: query or fragment at end of the URI "https://mirror-1.example.com#frag"`, + }, + { + input: "https://mirror-1.example.com/#frag", + expectedErr: `invalid mirror: query or fragment at end of the URI "https://mirror-1.example.com/#frag"`, + }, + { + input: "http://foo:bar@mirror-1.example.com/", + expectedErr: `invalid mirror: username/password not allowed in URI "http://foo:xxxxx@mirror-1.example.com/"`, + }, + { + input: "https://mirror-1.example.com/v1/#frag", + expectedErr: `invalid mirror: query or fragment at end of the URI "https://mirror-1.example.com/v1/#frag"`, + }, + { + input: "https://mirror-1.example.com?q", + expectedErr: `invalid mirror: query or fragment at end of the URI "https://mirror-1.example.com?q"`, + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + out, err := ValidateMirror(tc.input) + if tc.expectedErr != "" { + assert.Error(t, err, tc.expectedErr) + } else { + assert.NilError(t, err) + } + assert.Check(t, is.Equal(out, tc.output)) + }) + } +} + +func TestLoadInsecureRegistries(t *testing.T) { + testCases := []struct { + registries []string + index string + err string + }{ + { + registries: []string{"127.0.0.1"}, + index: "127.0.0.1", + }, + { + registries: []string{"127.0.0.1:8080"}, + index: "127.0.0.1:8080", + }, + { + registries: []string{"2001:db8::1"}, + index: "2001:db8::1", + }, + { + registries: []string{"[2001:db8::1]:80"}, + index: "[2001:db8::1]:80", + }, + { + registries: []string{"http://myregistry.example.com"}, + index: "myregistry.example.com", + }, + { + registries: []string{"https://myregistry.example.com"}, + index: "myregistry.example.com", + }, + { + registries: []string{"HTTP://myregistry.example.com"}, + index: "myregistry.example.com", + }, + { + registries: []string{"svn://myregistry.example.com"}, + err: "insecure registry svn://myregistry.example.com should not contain '://'", + }, + { + registries: []string{"-invalid-registry"}, + err: "Cannot begin or end with a hyphen", + }, + { + registries: []string{`mytest-.com`}, + err: `insecure registry mytest-.com is not valid: invalid host "mytest-.com"`, + }, + { + registries: []string{`1200:0000:AB00:1234:0000:2552:7777:1313:8080`}, + err: `insecure registry 1200:0000:AB00:1234:0000:2552:7777:1313:8080 is not valid: invalid host "1200:0000:AB00:1234:0000:2552:7777:1313:8080"`, + }, + { + registries: []string{`myregistry.example.com:500000`}, + err: `insecure registry myregistry.example.com:500000 is not valid: invalid port "500000"`, + }, + { + registries: []string{`"myregistry.example.com"`}, + err: `insecure registry "myregistry.example.com" is not valid: invalid host "\"myregistry.example.com\""`, + }, + { + registries: []string{`"myregistry.example.com:5000"`}, + err: `insecure registry "myregistry.example.com:5000" is not valid: invalid host "\"myregistry.example.com"`, + }, + } + for _, testCase := range testCases { + config := &serviceConfig{} + err := config.loadInsecureRegistries(testCase.registries) + if testCase.err == "" { + if err != nil { + t.Fatalf("expect no error, got '%s'", err) + } + match := false + for index := range config.IndexConfigs { + if index == testCase.index { + match = true + } + } + if !match { + t.Fatalf("expect index configs to contain '%s', got %+v", testCase.index, config.IndexConfigs) + } + } else { + if err == nil { + t.Fatalf("expect error '%s', got no error", testCase.err) + } + assert.ErrorContains(t, err, testCase.err) + assert.Check(t, cerrdefs.IsInvalidArgument(err)) + } + } +} + +func TestNewServiceConfig(t *testing.T) { + tests := []struct { + doc string + opts ServiceOptions + errStr string + }{ + { + doc: "empty config", + }, + { + doc: "invalid mirror", + opts: ServiceOptions{ + Mirrors: []string{"example.com:5000"}, + }, + errStr: `invalid mirror: no scheme specified for "example.com:5000": must use either 'https://' or 'http://'`, + }, + { + doc: "valid mirror", + opts: ServiceOptions{ + Mirrors: []string{"https://example.com:5000"}, + }, + }, + { + doc: "invalid insecure registry", + opts: ServiceOptions{ + InsecureRegistries: []string{"[fe80::]/64"}, + }, + errStr: `insecure registry [fe80::]/64 is not valid: invalid host "[fe80::]/64"`, + }, + { + doc: "valid insecure registry", + opts: ServiceOptions{ + InsecureRegistries: []string{"102.10.8.1/24"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + _, err := newServiceConfig(tc.opts) + if tc.errStr != "" { + assert.Check(t, is.Error(err, tc.errStr)) + assert.Check(t, cerrdefs.IsInvalidArgument(err)) + } else { + assert.Check(t, err) + } + }) + } +} + +func TestValidateIndexName(t *testing.T) { + valid := []struct { + index string + expect string + }{ + { + index: "index.docker.io", + expect: "docker.io", + }, + { + index: "example.com", + expect: "example.com", + }, + { + index: "127.0.0.1:8080", + expect: "127.0.0.1:8080", + }, + { + index: "mytest-1.com", + expect: "mytest-1.com", + }, + { + index: "mirror-1.example.com/v1/?q=foo", + expect: "mirror-1.example.com/v1/?q=foo", + }, + } + + for _, testCase := range valid { + result, err := ValidateIndexName(testCase.index) + if assert.Check(t, err) { + assert.Check(t, is.Equal(testCase.expect, result)) + } + } +} + +func TestValidateIndexNameWithError(t *testing.T) { + invalid := []struct { + index string + err string + }{ + { + index: "docker.io-", + err: "invalid index name (docker.io-). Cannot begin or end with a hyphen", + }, + { + index: "-example.com", + err: "invalid index name (-example.com). Cannot begin or end with a hyphen", + }, + { + index: "mirror-1.example.com/v1/?q=foo-", + err: "invalid index name (mirror-1.example.com/v1/?q=foo-). Cannot begin or end with a hyphen", + }, + } + for _, testCase := range invalid { + _, err := ValidateIndexName(testCase.index) + assert.Check(t, is.Error(err, testCase.err)) + assert.Check(t, cerrdefs.IsInvalidArgument(err)) + } +} diff --git a/internal/registry/doc.go b/internal/registry/doc.go new file mode 100644 index 0000000000..0b6a24767c --- /dev/null +++ b/internal/registry/doc.go @@ -0,0 +1,12 @@ +// Package registry is a fork of [github.com/docker/docker/registry], taken +// at commit [moby@49306c6]. Git history was not preserved in this fork, +// but can be found using the URLs provided. +// +// This fork was created to remove the dependency on the "Moby" codebase, +// and because the CLI only needs a subset of its features. The original +// package was written specifically for use in the daemon code, and includes +// functionality that cannot be used in the CLI. +// +// [github.com/docker/docker/registry]: https://pkg.go.dev/github.com/docker/docker@v28.3.2+incompatible/registry +// [moby@49306c6]: https://github.com/moby/moby/tree/49306c607b72c5bf0a8e426f5a9760fa5ef96ea0/registry +package registry diff --git a/vendor/github.com/docker/docker/registry/errors.go b/internal/registry/errors.go similarity index 100% rename from vendor/github.com/docker/docker/registry/errors.go rename to internal/registry/errors.go diff --git a/vendor/github.com/docker/docker/registry/registry.go b/internal/registry/registry.go similarity index 100% rename from vendor/github.com/docker/docker/registry/registry.go rename to internal/registry/registry.go diff --git a/internal/registry/registry_mock_test.go b/internal/registry/registry_mock_test.go new file mode 100644 index 0000000000..986e7ee649 --- /dev/null +++ b/internal/registry/registry_mock_test.go @@ -0,0 +1,120 @@ +package registry + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/log" + "github.com/moby/moby/api/types/registry" + "gotest.tools/v3/assert" +) + +var ( + testHTTPServer *httptest.Server + testHTTPSServer *httptest.Server +) + +func init() { + r := http.NewServeMux() + + // /v1/ + r.HandleFunc("/v1/_ping", handlerGetPing) + r.HandleFunc("/v1/search", handlerSearch) + + // /v2/ + r.HandleFunc("/v2/version", handlerGetPing) + + testHTTPServer = httptest.NewServer(handlerAccessLog(r)) + testHTTPSServer = httptest.NewTLSServer(handlerAccessLog(r)) +} + +func handlerAccessLog(handler http.Handler) http.Handler { + logHandler := func(w http.ResponseWriter, r *http.Request) { + log.G(context.TODO()).Debugf(`%s "%s %s"`, r.RemoteAddr, r.Method, r.URL) + handler.ServeHTTP(w, r) + } + return http.HandlerFunc(logHandler) +} + +func makeURL(req string) string { + return testHTTPServer.URL + req +} + +func makeHTTPSURL(req string) string { + return testHTTPSServer.URL + req +} + +func makeIndex(req string) *registry.IndexInfo { + return ®istry.IndexInfo{ + Name: makeURL(req), + } +} + +func makeHTTPSIndex(req string) *registry.IndexInfo { + return ®istry.IndexInfo{ + Name: makeHTTPSURL(req), + } +} + +func makePublicIndex() *registry.IndexInfo { + return ®istry.IndexInfo{ + Name: IndexServer, + Secure: true, + Official: true, + } +} + +func writeHeaders(w http.ResponseWriter) { + h := w.Header() + h.Add("Server", "docker-tests/mock") + h.Add("Expires", "-1") + h.Add("Content-Type", "application/json") + h.Add("Pragma", "no-cache") + h.Add("Cache-Control", "no-cache") +} + +func writeResponse(w http.ResponseWriter, message interface{}, code int) { + writeHeaders(w) + w.WriteHeader(code) + body, err := json.Marshal(message) + if err != nil { + _, _ = io.WriteString(w, err.Error()) + return + } + _, _ = w.Write(body) +} + +func handlerGetPing(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeResponse(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + writeResponse(w, true, http.StatusOK) +} + +func handlerSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeResponse(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + result := ®istry.SearchResults{ + Query: "fakequery", + NumResults: 1, + Results: []registry.SearchResult{{Name: "fakeimage", StarCount: 42}}, + } + writeResponse(w, result, http.StatusOK) +} + +func TestPing(t *testing.T) { + res, err := http.Get(makeURL("/v1/_ping")) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, res.StatusCode, http.StatusOK, "") + assert.Equal(t, res.Header.Get("Server"), "docker-tests/mock") + _ = res.Body.Close() +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000000..79c17fa366 --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,637 @@ +package registry + +import ( + "errors" + "net" + "testing" + + "github.com/distribution/reference" + "github.com/moby/moby/api/types/registry" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +// overrideLookupIP overrides net.LookupIP for testing. +func overrideLookupIP(t *testing.T) { + t.Helper() + restoreLookup := lookupIP + + // override net.LookupIP + lookupIP = func(host string) ([]net.IP, error) { + mockHosts := map[string][]net.IP{ + "": {net.ParseIP("0.0.0.0")}, + "localhost": {net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + "example.com": {net.ParseIP("42.42.42.42")}, + "other.com": {net.ParseIP("43.43.43.43")}, + } + if addrs, ok := mockHosts[host]; ok { + return addrs, nil + } + return nil, errors.New("lookup: no such host") + } + t.Cleanup(func() { + lookupIP = restoreLookup + }) +} + +func TestParseRepositoryInfo(t *testing.T) { + type staticRepositoryInfo struct { + Index *registry.IndexInfo + RemoteName string + CanonicalName string + LocalName string + } + + tests := map[string]staticRepositoryInfo{ + "fooo/bar": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "fooo/bar", + LocalName: "fooo/bar", + CanonicalName: "docker.io/fooo/bar", + }, + "library/ubuntu": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu", + LocalName: "ubuntu", + CanonicalName: "docker.io/library/ubuntu", + }, + "nonlibrary/ubuntu": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "nonlibrary/ubuntu", + LocalName: "nonlibrary/ubuntu", + CanonicalName: "docker.io/nonlibrary/ubuntu", + }, + "ubuntu": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu", + LocalName: "ubuntu", + CanonicalName: "docker.io/library/ubuntu", + }, + "other/library": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "other/library", + LocalName: "other/library", + CanonicalName: "docker.io/other/library", + }, + "127.0.0.1:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "127.0.0.1:8000", + Mirrors: []string{}, + Official: false, + Secure: false, + }, + RemoteName: "private/moonbase", + LocalName: "127.0.0.1:8000/private/moonbase", + CanonicalName: "127.0.0.1:8000/private/moonbase", + }, + "127.0.0.1:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "127.0.0.1:8000", + Mirrors: []string{}, + Official: false, + Secure: false, + }, + RemoteName: "privatebase", + LocalName: "127.0.0.1:8000/privatebase", + CanonicalName: "127.0.0.1:8000/privatebase", + }, + "[::1]:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "[::1]:8000", + Mirrors: []string{}, + Official: false, + Secure: false, + }, + RemoteName: "private/moonbase", + LocalName: "[::1]:8000/private/moonbase", + CanonicalName: "[::1]:8000/private/moonbase", + }, + "[::1]:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "[::1]:8000", + Mirrors: []string{}, + Official: false, + Secure: false, + }, + RemoteName: "privatebase", + LocalName: "[::1]:8000/privatebase", + CanonicalName: "[::1]:8000/privatebase", + }, + // IPv6 only has a single loopback address, so ::2 is not a loopback, + // hence not marked "insecure". + "[::2]:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "[::2]:8000", + Mirrors: []string{}, + Official: false, + Secure: true, + }, + RemoteName: "private/moonbase", + LocalName: "[::2]:8000/private/moonbase", + CanonicalName: "[::2]:8000/private/moonbase", + }, + // IPv6 only has a single loopback address, so ::2 is not a loopback, + // hence not marked "insecure". + "[::2]:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "[::2]:8000", + Mirrors: []string{}, + Official: false, + Secure: true, + }, + RemoteName: "privatebase", + LocalName: "[::2]:8000/privatebase", + CanonicalName: "[::2]:8000/privatebase", + }, + "localhost:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "localhost:8000", + Mirrors: []string{}, + Official: false, + Secure: false, + }, + RemoteName: "private/moonbase", + LocalName: "localhost:8000/private/moonbase", + CanonicalName: "localhost:8000/private/moonbase", + }, + "localhost:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "localhost:8000", + Mirrors: []string{}, + Official: false, + Secure: false, + }, + RemoteName: "privatebase", + LocalName: "localhost:8000/privatebase", + CanonicalName: "localhost:8000/privatebase", + }, + "example.com/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "example.com", + Mirrors: []string{}, + Official: false, + Secure: true, + }, + RemoteName: "private/moonbase", + LocalName: "example.com/private/moonbase", + CanonicalName: "example.com/private/moonbase", + }, + "example.com/privatebase": { + Index: ®istry.IndexInfo{ + Name: "example.com", + Mirrors: []string{}, + Official: false, + Secure: true, + }, + RemoteName: "privatebase", + LocalName: "example.com/privatebase", + CanonicalName: "example.com/privatebase", + }, + "example.com:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "example.com:8000", + Mirrors: []string{}, + Official: false, + Secure: true, + }, + RemoteName: "private/moonbase", + LocalName: "example.com:8000/private/moonbase", + CanonicalName: "example.com:8000/private/moonbase", + }, + "example.com:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "example.com:8000", + Mirrors: []string{}, + Official: false, + Secure: true, + }, + RemoteName: "privatebase", + LocalName: "example.com:8000/privatebase", + CanonicalName: "example.com:8000/privatebase", + }, + "localhost/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "localhost", + Mirrors: []string{}, + Official: false, + Secure: false, + }, + RemoteName: "private/moonbase", + LocalName: "localhost/private/moonbase", + CanonicalName: "localhost/private/moonbase", + }, + "localhost/privatebase": { + Index: ®istry.IndexInfo{ + Name: "localhost", + Mirrors: []string{}, + Official: false, + Secure: false, + }, + RemoteName: "privatebase", + LocalName: "localhost/privatebase", + CanonicalName: "localhost/privatebase", + }, + IndexName + "/public/moonbase": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "public/moonbase", + LocalName: "public/moonbase", + CanonicalName: "docker.io/public/moonbase", + }, + "index." + IndexName + "/public/moonbase": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "public/moonbase", + LocalName: "public/moonbase", + CanonicalName: "docker.io/public/moonbase", + }, + "ubuntu-12.04-base": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu-12.04-base", + LocalName: "ubuntu-12.04-base", + CanonicalName: "docker.io/library/ubuntu-12.04-base", + }, + IndexName + "/ubuntu-12.04-base": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu-12.04-base", + LocalName: "ubuntu-12.04-base", + CanonicalName: "docker.io/library/ubuntu-12.04-base", + }, + "index." + IndexName + "/ubuntu-12.04-base": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Mirrors: []string{}, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu-12.04-base", + LocalName: "ubuntu-12.04-base", + CanonicalName: "docker.io/library/ubuntu-12.04-base", + }, + } + + for reposName, expected := range tests { + t.Run(reposName, func(t *testing.T) { + named, err := reference.ParseNormalizedNamed(reposName) + assert.NilError(t, err) + + repoInfo, err := ParseRepositoryInfo(named) + assert.NilError(t, err) + + assert.Check(t, is.DeepEqual(repoInfo.Index, expected.Index)) + assert.Check(t, is.Equal(reference.Path(repoInfo.Name), expected.RemoteName)) + assert.Check(t, is.Equal(reference.FamiliarName(repoInfo.Name), expected.LocalName)) + assert.Check(t, is.Equal(repoInfo.Name.Name(), expected.CanonicalName)) + }) + } +} + +func TestNewIndexInfo(t *testing.T) { + overrideLookupIP(t) + + // ipv6Loopback is the CIDR for the IPv6 loopback address ("::1"); "::1/128" + ipv6Loopback := &net.IPNet{ + IP: net.IPv6loopback, + Mask: net.CIDRMask(128, 128), + } + + // ipv4Loopback is the CIDR for IPv4 loopback addresses ("127.0.0.0/8") + ipv4Loopback := &net.IPNet{ + IP: net.IPv4(127, 0, 0, 0), + Mask: net.CIDRMask(8, 32), + } + + // emptyServiceConfig is a default service-config for situations where + // no config-file is available (e.g. when used in the CLI). It won't + // have mirrors configured, but does have the default insecure registry + // CIDRs for loopback interfaces configured. + emptyServiceConfig := &serviceConfig{ + IndexConfigs: map[string]*registry.IndexInfo{ + IndexName: { + Name: IndexName, + Mirrors: []string{}, + Secure: true, + Official: true, + }, + }, + InsecureRegistryCIDRs: []*registry.NetIPNet{ + (*registry.NetIPNet)(ipv6Loopback), + (*registry.NetIPNet)(ipv4Loopback), + }, + } + + expectedIndexInfos := map[string]*registry.IndexInfo{ + IndexName: { + Name: IndexName, + Official: true, + Secure: true, + Mirrors: []string{}, + }, + "index." + IndexName: { + Name: IndexName, + Official: true, + Secure: true, + Mirrors: []string{}, + }, + "example.com": { + Name: "example.com", + Official: false, + Secure: true, + Mirrors: []string{}, + }, + "127.0.0.1:5000": { + Name: "127.0.0.1:5000", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + } + t.Run("no mirrors", func(t *testing.T) { + for indexName, expected := range expectedIndexInfos { + t.Run(indexName, func(t *testing.T) { + actual := newIndexInfo(emptyServiceConfig, indexName) + assert.Check(t, is.DeepEqual(actual, expected)) + }) + } + }) + + expectedIndexInfos = map[string]*registry.IndexInfo{ + IndexName: { + Name: IndexName, + Official: true, + Secure: true, + Mirrors: []string{"http://mirror1.local/", "http://mirror2.local/"}, + }, + "index." + IndexName: { + Name: IndexName, + Official: true, + Secure: true, + Mirrors: []string{"http://mirror1.local/", "http://mirror2.local/"}, + }, + "example.com": { + Name: "example.com", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "example.com:5000": { + Name: "example.com:5000", + Official: false, + Secure: true, + Mirrors: []string{}, + }, + "127.0.0.1": { + Name: "127.0.0.1", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "127.0.0.1:5000": { + Name: "127.0.0.1:5000", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "127.255.255.255": { + Name: "127.255.255.255", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "127.255.255.255:5000": { + Name: "127.255.255.255:5000", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "::1": { + Name: "::1", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "[::1]:5000": { + Name: "[::1]:5000", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + // IPv6 only has a single loopback address, so ::2 is not a loopback, + // hence not marked "insecure". + "::2": { + Name: "::2", + Official: false, + Secure: true, + Mirrors: []string{}, + }, + // IPv6 only has a single loopback address, so ::2 is not a loopback, + // hence not marked "insecure". + "[::2]:5000": { + Name: "[::2]:5000", + Official: false, + Secure: true, + Mirrors: []string{}, + }, + "other.com": { + Name: "other.com", + Official: false, + Secure: true, + Mirrors: []string{}, + }, + } + t.Run("mirrors", func(t *testing.T) { + // Note that newServiceConfig calls ValidateMirror internally, which normalizes + // mirror-URLs to have a trailing slash. + config, err := newServiceConfig(ServiceOptions{ + Mirrors: []string{"http://mirror1.local", "http://mirror2.local"}, + InsecureRegistries: []string{"example.com"}, + }) + assert.NilError(t, err) + for indexName, expected := range expectedIndexInfos { + t.Run(indexName, func(t *testing.T) { + actual := newIndexInfo(config, indexName) + assert.Check(t, is.DeepEqual(actual, expected)) + }) + } + }) + + expectedIndexInfos = map[string]*registry.IndexInfo{ + "example.com": { + Name: "example.com", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "example.com:5000": { + Name: "example.com:5000", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "127.0.0.1": { + Name: "127.0.0.1", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "127.0.0.1:5000": { + Name: "127.0.0.1:5000", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "42.42.0.1:5000": { + Name: "42.42.0.1:5000", + Official: false, + Secure: false, + Mirrors: []string{}, + }, + "42.43.0.1:5000": { + Name: "42.43.0.1:5000", + Official: false, + Secure: true, + Mirrors: []string{}, + }, + "other.com": { + Name: "other.com", + Official: false, + Secure: true, + Mirrors: []string{}, + }, + } + t.Run("custom insecure", func(t *testing.T) { + config, err := newServiceConfig(ServiceOptions{ + InsecureRegistries: []string{"42.42.0.0/16"}, + }) + assert.NilError(t, err) + for indexName, expected := range expectedIndexInfos { + t.Run(indexName, func(t *testing.T) { + actual := newIndexInfo(config, indexName) + assert.Check(t, is.DeepEqual(actual, expected)) + }) + } + }) +} + +func TestMirrorEndpointLookup(t *testing.T) { + containsMirror := func(endpoints []APIEndpoint) bool { + for _, pe := range endpoints { + if pe.URL.Host == "my.mirror" { + return true + } + } + return false + } + cfg, err := newServiceConfig(ServiceOptions{ + Mirrors: []string{"https://my.mirror"}, + }) + assert.NilError(t, err) + s := Service{config: cfg} + + imageName, err := reference.WithName(IndexName + "/test/image") + if err != nil { + t.Error(err) + } + pushAPIEndpoints, err := s.LookupPushEndpoints(reference.Domain(imageName)) + if err != nil { + t.Fatal(err) + } + if containsMirror(pushAPIEndpoints) { + t.Fatal("Push endpoint should not contain mirror") + } + + pullAPIEndpoints, err := s.LookupPullEndpoints(reference.Domain(imageName)) + if err != nil { + t.Fatal(err) + } + if !containsMirror(pullAPIEndpoints) { + t.Fatal("Pull endpoint should contain mirror") + } +} + +func TestIsSecureIndex(t *testing.T) { + overrideLookupIP(t) + tests := []struct { + addr string + insecureRegistries []string + expected bool + }{ + {IndexName, nil, true}, + {"example.com", []string{}, true}, + {"example.com", []string{"example.com"}, false}, + {"localhost", []string{"localhost:5000"}, false}, + {"localhost:5000", []string{"localhost:5000"}, false}, + {"localhost", []string{"example.com"}, false}, + {"127.0.0.1:5000", []string{"127.0.0.1:5000"}, false}, + {"localhost", nil, false}, + {"localhost:5000", nil, false}, + {"127.0.0.1", nil, false}, + {"localhost", []string{"example.com"}, false}, + {"127.0.0.1", []string{"example.com"}, false}, + {"example.com", nil, true}, + {"example.com", []string{"example.com"}, false}, + {"127.0.0.1", []string{"example.com"}, false}, + {"127.0.0.1:5000", []string{"example.com"}, false}, + {"example.com:5000", []string{"42.42.0.0/16"}, false}, + {"example.com", []string{"42.42.0.0/16"}, false}, + {"example.com:5000", []string{"42.42.42.42/8"}, false}, + {"127.0.0.1:5000", []string{"127.0.0.0/8"}, false}, + {"42.42.42.42:5000", []string{"42.1.1.1/8"}, false}, + {"invalid.example.com", []string{"42.42.0.0/16"}, true}, + {"invalid.example.com", []string{"invalid.example.com"}, false}, + {"invalid.example.com:5000", []string{"invalid.example.com"}, true}, + {"invalid.example.com:5000", []string{"invalid.example.com:5000"}, false}, + } + for _, tc := range tests { + config, err := newServiceConfig(ServiceOptions{ + InsecureRegistries: tc.insecureRegistries, + }) + assert.NilError(t, err) + + sec := config.isSecureIndex(tc.addr) + assert.Equal(t, sec, tc.expected, "isSecureIndex failed for %q %v, expected %v got %v", tc.addr, tc.insecureRegistries, tc.expected, sec) + } +} diff --git a/vendor/github.com/docker/docker/registry/search.go b/internal/registry/search.go similarity index 100% rename from vendor/github.com/docker/docker/registry/search.go rename to internal/registry/search.go diff --git a/vendor/github.com/docker/docker/registry/search_endpoint_v1.go b/internal/registry/search_endpoint_v1.go similarity index 100% rename from vendor/github.com/docker/docker/registry/search_endpoint_v1.go rename to internal/registry/search_endpoint_v1.go diff --git a/internal/registry/search_endpoint_v1_test.go b/internal/registry/search_endpoint_v1_test.go new file mode 100644 index 0000000000..137e2a60fc --- /dev/null +++ b/internal/registry/search_endpoint_v1_test.go @@ -0,0 +1,237 @@ +package registry + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/moby/moby/api/types/registry" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestV1EndpointPing(t *testing.T) { + testPing := func(index *registry.IndexInfo, expectedStandalone bool, assertMessage string) { + ep, err := newV1Endpoint(context.Background(), index, nil) + if err != nil { + t.Fatal(err) + } + regInfo, err := ep.ping(context.Background()) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, regInfo.Standalone, expectedStandalone, assertMessage) + } + + testPing(makeIndex("/v1/"), true, "Expected standalone to be true (default)") + testPing(makeHTTPSIndex("/v1/"), true, "Expected standalone to be true (default)") + testPing(makePublicIndex(), false, "Expected standalone to be false for public index") +} + +func TestV1Endpoint(t *testing.T) { + // Simple wrapper to fail test if err != nil + expandEndpoint := func(index *registry.IndexInfo) *v1Endpoint { + endpoint, err := newV1Endpoint(context.Background(), index, nil) + if err != nil { + t.Fatal(err) + } + return endpoint + } + + assertInsecureIndex := func(index *registry.IndexInfo) { + index.Secure = true + _, err := newV1Endpoint(context.Background(), index, nil) + assert.ErrorContains(t, err, "insecure-registry", index.Name+": Expected insecure-registry error for insecure index") + index.Secure = false + } + + assertSecureIndex := func(index *registry.IndexInfo) { + index.Secure = true + _, err := newV1Endpoint(context.Background(), index, nil) + assert.ErrorContains(t, err, "certificate signed by unknown authority", index.Name+": Expected cert error for secure index") + index.Secure = false + } + + index := ®istry.IndexInfo{} + index.Name = makeURL("/v1/") + endpoint := expandEndpoint(index) + assert.Equal(t, endpoint.String(), index.Name, "Expected endpoint to be "+index.Name) + assertInsecureIndex(index) + + index.Name = makeURL("") + endpoint = expandEndpoint(index) + assert.Equal(t, endpoint.String(), index.Name+"/v1/", index.Name+": Expected endpoint to be "+index.Name+"/v1/") + assertInsecureIndex(index) + + httpURL := makeURL("") + index.Name = strings.SplitN(httpURL, "://", 2)[1] + endpoint = expandEndpoint(index) + assert.Equal(t, endpoint.String(), httpURL+"/v1/", index.Name+": Expected endpoint to be "+httpURL+"/v1/") + assertInsecureIndex(index) + + index.Name = makeHTTPSURL("/v1/") + endpoint = expandEndpoint(index) + assert.Equal(t, endpoint.String(), index.Name, "Expected endpoint to be "+index.Name) + assertSecureIndex(index) + + index.Name = makeHTTPSURL("") + endpoint = expandEndpoint(index) + assert.Equal(t, endpoint.String(), index.Name+"/v1/", index.Name+": Expected endpoint to be "+index.Name+"/v1/") + assertSecureIndex(index) + + httpsURL := makeHTTPSURL("") + index.Name = strings.SplitN(httpsURL, "://", 2)[1] + endpoint = expandEndpoint(index) + assert.Equal(t, endpoint.String(), httpsURL+"/v1/", index.Name+": Expected endpoint to be "+httpsURL+"/v1/") + assertSecureIndex(index) + + badEndpoints := []string{ + "http://127.0.0.1/v1/", + "https://127.0.0.1/v1/", + "http://127.0.0.1", + "https://127.0.0.1", + "127.0.0.1", + } + for _, address := range badEndpoints { + index.Name = address + _, err := newV1Endpoint(context.Background(), index, nil) + assert.Check(t, err != nil, "Expected error while expanding bad endpoint: %s", address) + } +} + +func TestV1EndpointParse(t *testing.T) { + tests := []struct { + address string + expected string + expectedErr string + }{ + { + address: IndexServer, + expected: IndexServer, + }, + { + address: "https://0.0.0.0:5000/v1/", + expected: "https://0.0.0.0:5000/v1/", + }, + { + address: "https://0.0.0.0:5000", + expected: "https://0.0.0.0:5000/v1/", + }, + { + address: "0.0.0.0:5000", + expected: "https://0.0.0.0:5000/v1/", + }, + { + address: "https://0.0.0.0:5000/nonversion/", + expected: "https://0.0.0.0:5000/nonversion/v1/", + }, + { + address: "https://0.0.0.0:5000/v0/", + expected: "https://0.0.0.0:5000/v0/v1/", + }, + { + address: "https://0.0.0.0:5000/v2/", + expectedErr: "search is not supported on v2 endpoints: https://0.0.0.0:5000/v2/", + }, + } + for _, tc := range tests { + t.Run(tc.address, func(t *testing.T) { + ep, err := newV1EndpointFromStr(tc.address, nil, nil) + if tc.expectedErr != "" { + assert.Check(t, is.Error(err, tc.expectedErr)) + assert.Check(t, is.Nil(ep)) + } else { + assert.NilError(t, err) + assert.Check(t, is.Equal(ep.String(), tc.expected)) + } + }) + } +} + +// Ensure that a registry endpoint that responds with a 401 only is determined +// to be a valid v1 registry endpoint +func TestV1EndpointValidate(t *testing.T) { + requireBasicAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("WWW-Authenticate", `Basic realm="localhost"`) + w.WriteHeader(http.StatusUnauthorized) + }) + + // Make a test server which should validate as a v1 server. + testServer := httptest.NewServer(requireBasicAuthHandler) + defer testServer.Close() + + testEndpoint, err := newV1Endpoint(context.Background(), ®istry.IndexInfo{Name: testServer.URL}, nil) + if err != nil { + t.Fatal(err) + } + + if testEndpoint.URL.Scheme != "http" { + t.Fatalf("expecting to validate endpoint as http, got url %s", testEndpoint.String()) + } +} + +func TestTrustedLocation(t *testing.T) { + for _, u := range []string{"http://example.com", "https://example.com:7777", "http://docker.io", "http://test.docker.com", "https://fakedocker.com"} { + req, _ := http.NewRequest(http.MethodGet, u, http.NoBody) + assert.Check(t, !trustedLocation(req)) + } + + for _, u := range []string{"https://docker.io", "https://test.docker.com:80"} { + req, _ := http.NewRequest(http.MethodGet, u, http.NoBody) + assert.Check(t, trustedLocation(req)) + } +} + +func TestAddRequiredHeadersToRedirectedRequests(t *testing.T) { + for _, urls := range [][]string{ + {"http://docker.io", "https://docker.com"}, + {"https://foo.docker.io:7777", "http://bar.docker.com"}, + {"https://foo.docker.io", "https://example.com"}, + } { + reqFrom, _ := http.NewRequest(http.MethodGet, urls[0], http.NoBody) + reqFrom.Header.Add("Content-Type", "application/json") + reqFrom.Header.Add("Authorization", "super_secret") + reqTo, _ := http.NewRequest(http.MethodGet, urls[1], http.NoBody) + + _ = addRequiredHeadersToRedirectedRequests(reqTo, []*http.Request{reqFrom}) + + if len(reqTo.Header) != 1 { + t.Fatalf("Expected 1 headers, got %d", len(reqTo.Header)) + } + + if reqTo.Header.Get("Content-Type") != "application/json" { + t.Fatal("'Content-Type' should be 'application/json'") + } + + if reqTo.Header.Get("Authorization") != "" { + t.Fatal("'Authorization' should be empty") + } + } + + for _, urls := range [][]string{ + {"https://docker.io", "https://docker.com"}, + {"https://foo.docker.io:7777", "https://bar.docker.com"}, + } { + reqFrom, _ := http.NewRequest(http.MethodGet, urls[0], http.NoBody) + reqFrom.Header.Add("Content-Type", "application/json") + reqFrom.Header.Add("Authorization", "super_secret") + reqTo, _ := http.NewRequest(http.MethodGet, urls[1], http.NoBody) + + _ = addRequiredHeadersToRedirectedRequests(reqTo, []*http.Request{reqFrom}) + + if len(reqTo.Header) != 2 { + t.Fatalf("Expected 2 headers, got %d", len(reqTo.Header)) + } + + if reqTo.Header.Get("Content-Type") != "application/json" { + t.Fatal("'Content-Type' should be 'application/json'") + } + + if reqTo.Header.Get("Authorization") != "super_secret" { + t.Fatal("'Authorization' should be 'super_secret'") + } + } +} diff --git a/vendor/github.com/docker/docker/registry/search_session.go b/internal/registry/search_session.go similarity index 100% rename from vendor/github.com/docker/docker/registry/search_session.go rename to internal/registry/search_session.go diff --git a/internal/registry/search_test.go b/internal/registry/search_test.go new file mode 100644 index 0000000000..7af875767f --- /dev/null +++ b/internal/registry/search_test.go @@ -0,0 +1,418 @@ +package registry + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/http/httputil" + "testing" + + cerrdefs "github.com/containerd/errdefs" + "github.com/docker/distribution/registry/client/transport" + "github.com/moby/moby/api/types/filters" + "github.com/moby/moby/api/types/registry" + "gotest.tools/v3/assert" +) + +func spawnTestRegistrySession(t *testing.T) *session { + t.Helper() + authConfig := ®istry.AuthConfig{} + endpoint, err := newV1Endpoint(context.Background(), makeIndex("/v1/"), nil) + if err != nil { + t.Fatal(err) + } + userAgent := "docker test client" + var tr http.RoundTripper = debugTransport{newTransport(nil), t.Log} + tr = transport.NewTransport(newAuthTransport(tr, authConfig, false), Headers(userAgent, nil)...) + client := httpClient(tr) + + if err := authorizeClient(context.Background(), client, authConfig, endpoint); err != nil { + t.Fatal(err) + } + r := newSession(client, endpoint) + + // In a normal scenario for the v1 registry, the client should send a `X-Docker-Token: true` + // header while authenticating, in order to retrieve a token that can be later used to + // perform authenticated actions. + // + // The mock v1 registry does not support that, (TODO(tiborvass): support it), instead, + // it will consider authenticated any request with the header `X-Docker-Token: fake-token`. + // + // Because we know that the client's transport is an `*authTransport` we simply cast it, + // in order to set the internal cached token to the fake token, and thus send that fake token + // upon every subsequent requests. + r.client.Transport.(*authTransport).token = []string{"fake-token"} + return r +} + +type debugTransport struct { + http.RoundTripper + log func(...interface{}) +} + +func (tr debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { + dump, err := httputil.DumpRequestOut(req, false) + if err != nil { + tr.log("could not dump request") + } + tr.log(string(dump)) + resp, err := tr.RoundTripper.RoundTrip(req) + if err != nil { + return nil, err + } + dump, err = httputil.DumpResponse(resp, false) + if err != nil { + tr.log("could not dump response") + } + tr.log(string(dump)) + return resp, err +} + +func TestSearchRepositories(t *testing.T) { + r := spawnTestRegistrySession(t) + results, err := r.searchRepositories(context.Background(), "fakequery", 25) + if err != nil { + t.Fatal(err) + } + if results == nil { + t.Fatal("Expected non-nil SearchResults object") + } + assert.Equal(t, results.NumResults, 1, "Expected 1 search results") + assert.Equal(t, results.Query, "fakequery", "Expected 'fakequery' as query") + assert.Equal(t, results.Results[0].StarCount, 42, "Expected 'fakeimage' to have 42 stars") +} + +func TestSearchErrors(t *testing.T) { + errorCases := []struct { + filtersArgs filters.Args + shouldReturnError bool + expectedError string + }{ + { + expectedError: "unexpected status code 500", + shouldReturnError: true, + }, + { + filtersArgs: filters.NewArgs(filters.Arg("type", "custom")), + expectedError: "invalid filter 'type'", + }, + { + filtersArgs: filters.NewArgs(filters.Arg("is-automated", "invalid")), + expectedError: "invalid filter 'is-automated=[invalid]'", + }, + { + filtersArgs: filters.NewArgs( + filters.Arg("is-automated", "true"), + filters.Arg("is-automated", "false"), + ), + expectedError: "invalid filter 'is-automated", + }, + { + filtersArgs: filters.NewArgs(filters.Arg("is-official", "invalid")), + expectedError: "invalid filter 'is-official=[invalid]'", + }, + { + filtersArgs: filters.NewArgs( + filters.Arg("is-official", "true"), + filters.Arg("is-official", "false"), + ), + expectedError: "invalid filter 'is-official", + }, + { + filtersArgs: filters.NewArgs(filters.Arg("stars", "invalid")), + expectedError: "invalid filter 'stars=invalid'", + }, + { + filtersArgs: filters.NewArgs( + filters.Arg("stars", "1"), + filters.Arg("stars", "invalid"), + ), + expectedError: "invalid filter 'stars=invalid'", + }, + } + for _, tc := range errorCases { + t.Run(tc.expectedError, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !tc.shouldReturnError { + t.Errorf("unexpected HTTP request") + } + http.Error(w, "no search for you", http.StatusInternalServerError) + })) + defer srv.Close() + + // Construct the search term by cutting the 'http://' prefix off srv.URL. + term := srv.URL[7:] + "/term" + + reg, err := NewService(ServiceOptions{}) + assert.NilError(t, err) + _, err = reg.Search(context.Background(), tc.filtersArgs, term, 0, nil, map[string][]string{}) + assert.ErrorContains(t, err, tc.expectedError) + if tc.shouldReturnError { + assert.Check(t, cerrdefs.IsUnknown(err), "got: %T: %v", err, err) + return + } + assert.Check(t, cerrdefs.IsInvalidArgument(err), "got: %T: %v", err, err) + }) + } +} + +func TestSearch(t *testing.T) { + const term = "term" + successCases := []struct { + name string + filtersArgs filters.Args + registryResults []registry.SearchResult + expectedResults []registry.SearchResult + }{ + { + name: "empty results", + registryResults: []registry.SearchResult{}, + expectedResults: []registry.SearchResult{}, + }, + { + name: "no filter", + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + }, + }, + expectedResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + }, + }, + }, + { + name: "is-automated=true, no results", + filtersArgs: filters.NewArgs(filters.Arg("is-automated", "true")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + }, + }, + expectedResults: []registry.SearchResult{}, + }, + { + name: "is-automated=true", + filtersArgs: filters.NewArgs(filters.Arg("is-automated", "true")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + IsAutomated: true, //nolint:staticcheck // ignore SA1019 (field is deprecated). + }, + }, + expectedResults: []registry.SearchResult{}, + }, + { + name: "is-automated=false, IsAutomated reset to false", + filtersArgs: filters.NewArgs(filters.Arg("is-automated", "false")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + IsAutomated: true, //nolint:staticcheck // ignore SA1019 (field is deprecated). + }, + }, + expectedResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + IsAutomated: false, //nolint:staticcheck // ignore SA1019 (field is deprecated). + }, + }, + }, + { + name: "is-automated=false", + filtersArgs: filters.NewArgs(filters.Arg("is-automated", "false")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + }, + }, + expectedResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + }, + }, + }, + { + name: "is-official=true, no results", + filtersArgs: filters.NewArgs(filters.Arg("is-official", "true")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + }, + }, + expectedResults: []registry.SearchResult{}, + }, + { + name: "is-official=true", + filtersArgs: filters.NewArgs(filters.Arg("is-official", "true")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + IsOfficial: true, + }, + }, + expectedResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + IsOfficial: true, + }, + }, + }, + { + name: "is-official=false, no results", + filtersArgs: filters.NewArgs(filters.Arg("is-official", "false")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + IsOfficial: true, + }, + }, + expectedResults: []registry.SearchResult{}, + }, + { + name: "is-official=false", + filtersArgs: filters.NewArgs(filters.Arg("is-official", "false")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + IsOfficial: false, + }, + }, + expectedResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + IsOfficial: false, + }, + }, + }, + { + name: "stars=0", + filtersArgs: filters.NewArgs(filters.Arg("stars", "0")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + StarCount: 0, + }, + }, + expectedResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + StarCount: 0, + }, + }, + }, + { + name: "stars=0, no results", + filtersArgs: filters.NewArgs(filters.Arg("stars", "1")), + registryResults: []registry.SearchResult{ + { + Name: "name", + Description: "description", + StarCount: 0, + }, + }, + expectedResults: []registry.SearchResult{}, + }, + { + name: "stars=1", + filtersArgs: filters.NewArgs(filters.Arg("stars", "1")), + registryResults: []registry.SearchResult{ + { + Name: "name0", + Description: "description0", + StarCount: 0, + }, + { + Name: "name1", + Description: "description1", + StarCount: 1, + }, + }, + expectedResults: []registry.SearchResult{ + { + Name: "name1", + Description: "description1", + StarCount: 1, + }, + }, + }, + { + name: "stars=1, is-official=true, is-automated=true", + filtersArgs: filters.NewArgs( + filters.Arg("stars", "1"), + filters.Arg("is-official", "true"), + filters.Arg("is-automated", "true"), + ), + registryResults: []registry.SearchResult{ + { + Name: "name0", + Description: "description0", + StarCount: 0, + IsOfficial: true, + IsAutomated: true, //nolint:staticcheck // ignore SA1019 (field is deprecated). + }, + { + Name: "name1", + Description: "description1", + StarCount: 1, + IsOfficial: false, + IsAutomated: true, //nolint:staticcheck // ignore SA1019 (field is deprecated). + }, + { + Name: "name2", + Description: "description2", + StarCount: 1, + IsOfficial: true, + }, + { + Name: "name3", + Description: "description3", + StarCount: 2, + IsOfficial: true, + IsAutomated: true, //nolint:staticcheck // ignore SA1019 (field is deprecated). + }, + }, + expectedResults: []registry.SearchResult{}, + }, + } + for _, tc := range successCases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-type", "application/json") + json.NewEncoder(w).Encode(registry.SearchResults{ + Query: term, + NumResults: len(tc.registryResults), + Results: tc.registryResults, + }) + })) + defer srv.Close() + + // Construct the search term by cutting the 'http://' prefix off srv.URL. + searchTerm := srv.URL[7:] + "/" + term + + reg, err := NewService(ServiceOptions{}) + assert.NilError(t, err) + results, err := reg.Search(context.Background(), tc.filtersArgs, searchTerm, 0, nil, map[string][]string{}) + assert.NilError(t, err) + assert.DeepEqual(t, results, tc.expectedResults) + }) + } +} diff --git a/vendor/github.com/docker/docker/registry/service.go b/internal/registry/service.go similarity index 100% rename from vendor/github.com/docker/docker/registry/service.go rename to internal/registry/service.go diff --git a/vendor/github.com/docker/docker/registry/service_v2.go b/internal/registry/service_v2.go similarity index 100% rename from vendor/github.com/docker/docker/registry/service_v2.go rename to internal/registry/service_v2.go diff --git a/vendor/github.com/docker/docker/registry/types.go b/internal/registry/types.go similarity index 100% rename from vendor/github.com/docker/docker/registry/types.go rename to internal/registry/types.go diff --git a/vendor.mod b/vendor.mod index 87396832c4..5a1e7cbeb3 100644 --- a/vendor.mod +++ b/vendor.mod @@ -15,6 +15,7 @@ replace ( require ( dario.cat/mergo v1.0.1 github.com/containerd/errdefs v1.0.0 + github.com/containerd/log v0.1.0 github.com/containerd/platforms v1.0.0-rc.1 github.com/cpuguy83/go-md2man/v2 v2.0.7 github.com/creack/pty v1.1.24 @@ -55,6 +56,7 @@ require ( github.com/theupdateframework/notary v0.7.1-0.20210315103452-bf96a202a09a github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346 github.com/xeipuuv/gojsonschema v1.2.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 go.opentelemetry.io/otel v1.35.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 @@ -78,7 +80,6 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect github.com/docker/go-metrics v0.0.1 // indirect @@ -105,7 +106,6 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect go.etcd.io/etcd/raft/v3 v3.5.16 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect golang.org/x/crypto v0.37.0 // indirect diff --git a/vendor/modules.txt b/vendor/modules.txt index 2177350f88..f4115c073f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -71,7 +71,6 @@ github.com/docker/docker/pkg/jsonmessage github.com/docker/docker/pkg/process github.com/docker/docker/pkg/progress github.com/docker/docker/pkg/streamformatter -github.com/docker/docker/registry # github.com/docker/docker-credential-helpers v0.9.3 ## explicit; go 1.21 github.com/docker/docker-credential-helpers/client