mirror of
https://github.com/moby/moby.git
synced 2026-08-05 07:30:57 +00:00
Merge pull request #47380 from dmcgowan/registry-http-fallback
Registry host configuration cleanup
This commit is contained in:
@@ -2,8 +2,6 @@ package containerd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/containerd/containerd/remotes"
|
||||
@@ -33,11 +31,12 @@ func (i *ImageService) newResolverFromAuthConfig(ctx context.Context, authConfig
|
||||
}
|
||||
|
||||
func hostsWrapper(hostsFn docker.RegistryHosts, optAuthConfig *registrytypes.AuthConfig, ref reference.Named, regService registryResolver) docker.RegistryHosts {
|
||||
var authorizer docker.Authorizer
|
||||
if optAuthConfig != nil {
|
||||
authorizer = authorizerFromAuthConfig(*optAuthConfig, ref)
|
||||
if optAuthConfig == nil {
|
||||
return hostsFn
|
||||
}
|
||||
|
||||
authorizer := authorizerFromAuthConfig(*optAuthConfig, ref)
|
||||
|
||||
return func(n string) ([]docker.RegistryHost, error) {
|
||||
hosts, err := hostsFn(n)
|
||||
if err != nil {
|
||||
@@ -45,13 +44,7 @@ func hostsWrapper(hostsFn docker.RegistryHosts, optAuthConfig *registrytypes.Aut
|
||||
}
|
||||
|
||||
for i := range hosts {
|
||||
if hosts[i].Authorizer == nil {
|
||||
hosts[i].Authorizer = authorizer
|
||||
isInsecure := regService.IsInsecureRegistry(hosts[i].Host)
|
||||
if hosts[i].Client.Transport != nil && isInsecure {
|
||||
hosts[i].Client.Transport = httpFallback{super: hosts[i].Client.Transport}
|
||||
}
|
||||
}
|
||||
hosts[i].Authorizer = authorizer
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
@@ -111,24 +104,3 @@ func (a *bearerAuthorizer) AddResponses(context.Context, []*http.Response) error
|
||||
// Return not implemented to prevent retry of the request when bearer did not succeed
|
||||
return cerrdefs.ErrNotImplemented
|
||||
}
|
||||
|
||||
type httpFallback struct {
|
||||
super http.RoundTripper
|
||||
}
|
||||
|
||||
func (f httpFallback) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
resp, err := f.super.RoundTrip(r)
|
||||
var tlsErr tls.RecordHeaderError
|
||||
if errors.As(err, &tlsErr) && string(tlsErr.RecordHeader[:]) == "HTTP/" {
|
||||
// server gave HTTP response to HTTPS client
|
||||
plainHttpUrl := *r.URL
|
||||
plainHttpUrl.Scheme = "http"
|
||||
|
||||
plainHttpRequest := *r
|
||||
plainHttpRequest.URL = &plainHttpUrl
|
||||
|
||||
return http.DefaultTransport.RoundTrip(&plainHttpRequest)
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
"github.com/containerd/containerd"
|
||||
"github.com/containerd/containerd/defaults"
|
||||
"github.com/containerd/containerd/pkg/dialer"
|
||||
"github.com/containerd/containerd/remotes/docker"
|
||||
"github.com/containerd/log"
|
||||
"github.com/distribution/reference"
|
||||
dist "github.com/docker/distribution"
|
||||
@@ -75,8 +74,6 @@ import (
|
||||
"github.com/docker/docker/registry"
|
||||
volumesservice "github.com/docker/docker/volume/service"
|
||||
"github.com/moby/buildkit/util/grpcerrors"
|
||||
"github.com/moby/buildkit/util/resolver"
|
||||
resolverconfig "github.com/moby/buildkit/util/resolver/config"
|
||||
"github.com/moby/buildkit/util/tracing"
|
||||
"github.com/moby/locker"
|
||||
"github.com/moby/sys/userns"
|
||||
@@ -207,48 +204,6 @@ func (daemon *Daemon) UsesSnapshotter() bool {
|
||||
return daemon.usesSnapshotter
|
||||
}
|
||||
|
||||
// RegistryHosts returns the registry hosts configuration for the host component
|
||||
// of a distribution image reference.
|
||||
func (daemon *Daemon) RegistryHosts(host string) ([]docker.RegistryHost, error) {
|
||||
m := map[string]resolverconfig.RegistryConfig{
|
||||
"docker.io": {Mirrors: daemon.registryService.ServiceConfig().Mirrors},
|
||||
}
|
||||
conf := daemon.registryService.ServiceConfig().IndexConfigs
|
||||
for k, v := range conf {
|
||||
c := m[k]
|
||||
if !v.Secure {
|
||||
t := true
|
||||
c.PlainHTTP = &t
|
||||
c.Insecure = &t
|
||||
}
|
||||
m[k] = c
|
||||
}
|
||||
if c, ok := m[host]; !ok && daemon.registryService.IsInsecureRegistry(host) {
|
||||
t := true
|
||||
c.PlainHTTP = &t
|
||||
c.Insecure = &t
|
||||
m[host] = c
|
||||
}
|
||||
|
||||
for k, v := range m {
|
||||
v.TLSConfigDir = []string{registry.HostCertsDir(k)}
|
||||
m[k] = v
|
||||
}
|
||||
|
||||
certsDir := registry.CertsDir()
|
||||
if fis, err := os.ReadDir(certsDir); err == nil {
|
||||
for _, fi := range fis {
|
||||
if _, ok := m[fi.Name()]; !ok {
|
||||
m[fi.Name()] = resolverconfig.RegistryConfig{
|
||||
TLSConfigDir: []string{filepath.Join(certsDir, fi.Name())},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolver.NewRegistryConfig(m)(host)
|
||||
}
|
||||
|
||||
// layerAccessor may be implemented by ImageService
|
||||
type layerAccessor interface {
|
||||
GetLayerByID(cid string) (layer.RWLayer, error)
|
||||
|
||||
174
daemon/hosts.go
Normal file
174
daemon/hosts.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package daemon // import "github.com/docker/docker/daemon"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/containerd/containerd/remotes/docker"
|
||||
hostconfig "github.com/containerd/containerd/remotes/docker/config"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/registry"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPath = "/v2"
|
||||
)
|
||||
|
||||
// RegistryHosts returns the registry hosts configuration for the host component
|
||||
// of a distribution image reference.
|
||||
func (daemon *Daemon) RegistryHosts(host string) ([]docker.RegistryHost, error) {
|
||||
hosts, err := hostconfig.ConfigureHosts(context.Background(), hostconfig.HostOptions{
|
||||
// TODO: Also check containerd path when updating containerd to use multiple host directories
|
||||
HostDir: hostconfig.HostDirFromRoot(registry.CertsDir()),
|
||||
})(host)
|
||||
if err == nil {
|
||||
// Merge in legacy configuration if provided
|
||||
if cfg := daemon.Config(); len(cfg.Mirrors) > 0 || len(cfg.InsecureRegistries) > 0 {
|
||||
hosts, err = daemon.mergeLegacyConfig(host, hosts)
|
||||
}
|
||||
}
|
||||
|
||||
return hosts, err
|
||||
}
|
||||
|
||||
func (daemon *Daemon) mergeLegacyConfig(host string, hosts []docker.RegistryHost) ([]docker.RegistryHost, error) {
|
||||
// If no hosts provided, nothing to do.
|
||||
// If multiple hosts provided, then a mirror configuration is already provided and
|
||||
// should not overwrite with legacy config.
|
||||
if len(hosts) == 0 || len(hosts) > 1 {
|
||||
return hosts, nil
|
||||
}
|
||||
sc := daemon.registryService.ServiceConfig()
|
||||
if host == "docker.io" && len(sc.Mirrors) > 0 {
|
||||
hosts = mirrorsToRegistryHosts(sc.Mirrors, hosts[0])
|
||||
}
|
||||
hostDir := hostconfig.HostDirFromRoot(registry.CertsDir())
|
||||
for i := range hosts {
|
||||
t, ok := hosts[i].Client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if t.TLSClientConfig == nil {
|
||||
certsDir, err := hostDir(host)
|
||||
if err != nil && !cerrdefs.IsNotFound(err) {
|
||||
return nil, err
|
||||
} else if err == nil {
|
||||
c, err := loadTLSConfig(certsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.TLSClientConfig = c
|
||||
}
|
||||
}
|
||||
if daemon.registryService.IsInsecureRegistry(hosts[i].Host) {
|
||||
if t.TLSClientConfig == nil {
|
||||
t.TLSClientConfig = &tls.Config{} //nolint: gosec // G402: TLS MinVersion too low.
|
||||
}
|
||||
t.TLSClientConfig.InsecureSkipVerify = true
|
||||
|
||||
hosts[i].Client.Transport = docker.NewHTTPFallback(hosts[i].Client.Transport)
|
||||
}
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func mirrorsToRegistryHosts(mirrors []string, dHost docker.RegistryHost) []docker.RegistryHost {
|
||||
var mirrorHosts []docker.RegistryHost
|
||||
for _, mirror := range mirrors {
|
||||
h := dHost
|
||||
h.Capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve
|
||||
|
||||
u, err := url.Parse(mirror)
|
||||
if err != nil || u.Host == "" {
|
||||
u, err = url.Parse(fmt.Sprintf("dummy://%s", mirror))
|
||||
}
|
||||
if err == nil && u.Host != "" {
|
||||
h.Host = u.Host
|
||||
h.Path = strings.TrimSuffix(u.Path, "/")
|
||||
|
||||
// For compatibility with legacy mirrors, ensure ends with /v2
|
||||
// NOTE: Use newer configuration to completely override the path
|
||||
if !strings.HasSuffix(h.Path, defaultPath) {
|
||||
h.Path = path.Join(h.Path, defaultPath)
|
||||
}
|
||||
if u.Scheme != "dummy" {
|
||||
h.Scheme = u.Scheme
|
||||
}
|
||||
} else {
|
||||
h.Host = mirror
|
||||
h.Path = defaultPath
|
||||
}
|
||||
|
||||
mirrorHosts = append(mirrorHosts, h)
|
||||
}
|
||||
return append(mirrorHosts, dHost)
|
||||
|
||||
}
|
||||
|
||||
func loadTLSConfig(d string) (*tls.Config, error) {
|
||||
fs, err := os.ReadDir(d)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
type keyPair struct {
|
||||
Certificate string
|
||||
Key string
|
||||
}
|
||||
var (
|
||||
rootCAs []string
|
||||
keyPairs []keyPair
|
||||
)
|
||||
for _, f := range fs {
|
||||
switch filepath.Ext(f.Name()) {
|
||||
case ".crt":
|
||||
rootCAs = append(rootCAs, filepath.Join(d, f.Name()))
|
||||
case ".cert":
|
||||
keyPairs = append(keyPairs, keyPair{
|
||||
Certificate: filepath.Join(d, f.Name()),
|
||||
Key: filepath.Join(d, strings.TrimSuffix(f.Name(), ".cert")+".key"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tc := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if len(rootCAs) > 0 {
|
||||
systemPool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
systemPool = x509.NewCertPool()
|
||||
} else {
|
||||
return nil, errors.Wrapf(err, "unable to get system cert pool")
|
||||
}
|
||||
}
|
||||
tc.RootCAs = systemPool
|
||||
}
|
||||
|
||||
for _, p := range rootCAs {
|
||||
dt, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc.RootCAs.AppendCertsFromPEM(dt)
|
||||
}
|
||||
|
||||
for _, kp := range keyPairs {
|
||||
cert, err := tls.LoadX509KeyPair(kp.Certificate, kp.Key)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to load keypair for %s", kp.Certificate)
|
||||
}
|
||||
tc.Certificates = append(tc.Certificates, cert)
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
107
daemon/hosts_test.go
Normal file
107
daemon/hosts_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package daemon // import "github.com/docker/docker/daemon"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/containerd/containerd/remotes/docker"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
)
|
||||
|
||||
func TestMirrorsToHosts(t *testing.T) {
|
||||
pullCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve
|
||||
allCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush
|
||||
defaultRegistry := testRegistryHost("https", "registry-1.docker.com", "/v2", allCaps)
|
||||
for _, tc := range []struct {
|
||||
mirrors []string
|
||||
dhost docker.RegistryHost
|
||||
expected []docker.RegistryHost
|
||||
}{
|
||||
{
|
||||
mirrors: []string{"https://localhost:5000"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("https", "localhost:5000", "/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
{
|
||||
mirrors: []string{"http://localhost:5000"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("http", "localhost:5000", "/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
{
|
||||
mirrors: []string{"http://localhost:5000/v2"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("http", "localhost:5000", "/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
{
|
||||
mirrors: []string{"localhost:5000"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("https", "localhost:5000", "/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
{
|
||||
mirrors: []string{"localhost:5000/trailingslash/"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("https", "localhost:5000", "/trailingslash/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
{
|
||||
mirrors: []string{"localhost:5000/2trailingslash//"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("https", "localhost:5000", "/2trailingslash/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
{
|
||||
mirrors: []string{"localhost:5000/v2/"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("https", "localhost:5000", "/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
{
|
||||
mirrors: []string{"localhost:5000/base"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("https", "localhost:5000", "/base/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Legacy mirror configuration always appended /v2, keep functionality the same
|
||||
mirrors: []string{"localhost:5000/v2/base"},
|
||||
dhost: defaultRegistry,
|
||||
expected: []docker.RegistryHost{
|
||||
testRegistryHost("https", "localhost:5000", "/v2/base/v2", pullCaps),
|
||||
defaultRegistry,
|
||||
},
|
||||
},
|
||||
} {
|
||||
actual := mirrorsToRegistryHosts(tc.mirrors, tc.dhost)
|
||||
|
||||
assert.Check(t, is.DeepEqual(actual, tc.expected))
|
||||
}
|
||||
}
|
||||
|
||||
func testRegistryHost(scheme, host, path string, caps docker.HostCapabilities) docker.RegistryHost {
|
||||
return docker.RegistryHost{
|
||||
Host: host,
|
||||
Scheme: scheme,
|
||||
Path: path,
|
||||
Capabilities: caps,
|
||||
}
|
||||
}
|
||||
42
vendor/github.com/containerd/containerd/remotes/docker/config/config_unix.go
generated
vendored
Normal file
42
vendor/github.com/containerd/containerd/remotes/docker/config/config_unix.go
generated
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
//go:build !windows
|
||||
|
||||
/*
|
||||
Copyright The containerd 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 config
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func hostPaths(root, host string) (hosts []string) {
|
||||
ch := hostDirectory(host)
|
||||
if ch != host {
|
||||
hosts = append(hosts, filepath.Join(root, ch))
|
||||
}
|
||||
|
||||
hosts = append(hosts,
|
||||
filepath.Join(root, host),
|
||||
filepath.Join(root, "_default"),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func rootSystemPool() (*x509.CertPool, error) {
|
||||
return x509.SystemCertPool()
|
||||
}
|
||||
41
vendor/github.com/containerd/containerd/remotes/docker/config/config_windows.go
generated
vendored
Normal file
41
vendor/github.com/containerd/containerd/remotes/docker/config/config_windows.go
generated
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright The containerd 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 config
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func hostPaths(root, host string) (hosts []string) {
|
||||
ch := hostDirectory(host)
|
||||
if ch != host {
|
||||
hosts = append(hosts, filepath.Join(root, strings.Replace(ch, ":", "", -1)))
|
||||
}
|
||||
|
||||
hosts = append(hosts,
|
||||
filepath.Join(root, strings.Replace(host, ":", "", -1)),
|
||||
filepath.Join(root, "_default"),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func rootSystemPool() (*x509.CertPool, error) {
|
||||
return x509.NewCertPool(), nil
|
||||
}
|
||||
44
vendor/github.com/containerd/containerd/remotes/docker/config/docker_fuzzer_internal.go
generated
vendored
Normal file
44
vendor/github.com/containerd/containerd/remotes/docker/config/docker_fuzzer_internal.go
generated
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build gofuzz
|
||||
|
||||
/*
|
||||
Copyright The containerd 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 config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
fuzz "github.com/AdaLogics/go-fuzz-headers"
|
||||
)
|
||||
|
||||
func FuzzParseHostsFile(data []byte) int {
|
||||
f := fuzz.NewConsumer(data)
|
||||
dir, err := os.MkdirTemp("", "fuzz-")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
err = f.CreateFiles(dir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
b, err := f.GetBytes()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
_, _ = parseHostsFile(dir, b)
|
||||
return 1
|
||||
}
|
||||
612
vendor/github.com/containerd/containerd/remotes/docker/config/hosts.go
generated
vendored
Normal file
612
vendor/github.com/containerd/containerd/remotes/docker/config/hosts.go
generated
vendored
Normal file
@@ -0,0 +1,612 @@
|
||||
/*
|
||||
Copyright The containerd 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 config contains utilities for helping configure the Docker resolver
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/containerd/remotes/docker"
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/containerd/log"
|
||||
"github.com/pelletier/go-toml"
|
||||
)
|
||||
|
||||
// UpdateClientFunc is a function that lets you to amend http Client behavior used by registry clients.
|
||||
type UpdateClientFunc func(client *http.Client) error
|
||||
|
||||
type hostConfig struct {
|
||||
scheme string
|
||||
host string
|
||||
path string
|
||||
|
||||
capabilities docker.HostCapabilities
|
||||
|
||||
caCerts []string
|
||||
clientPairs [][2]string
|
||||
skipVerify *bool
|
||||
|
||||
header http.Header
|
||||
|
||||
// TODO: Add credential configuration (domain alias, username)
|
||||
}
|
||||
|
||||
// HostOptions is used to configure registry hosts
|
||||
type HostOptions struct {
|
||||
HostDir func(string) (string, error)
|
||||
Credentials func(host string) (string, string, error)
|
||||
DefaultTLS *tls.Config
|
||||
DefaultScheme string
|
||||
// UpdateClient will be called after creating http.Client object, so clients can provide extra configuration
|
||||
UpdateClient UpdateClientFunc
|
||||
AuthorizerOpts []docker.AuthorizerOpt
|
||||
}
|
||||
|
||||
// ConfigureHosts creates a registry hosts function from the provided
|
||||
// host creation options. The host directory can read hosts.toml or
|
||||
// certificate files laid out in the Docker specific layout.
|
||||
// If a `HostDir` function is not required, defaults are used.
|
||||
func ConfigureHosts(ctx context.Context, options HostOptions) docker.RegistryHosts {
|
||||
return func(host string) ([]docker.RegistryHost, error) {
|
||||
var hosts []hostConfig
|
||||
if options.HostDir != nil {
|
||||
dir, err := options.HostDir(host)
|
||||
if err != nil && !errdefs.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if dir != "" {
|
||||
log.G(ctx).WithField("dir", dir).Debug("loading host directory")
|
||||
hosts, err = loadHostDir(ctx, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If hosts was not set, add a default host
|
||||
// NOTE: Check nil here and not empty, the host may be
|
||||
// intentionally configured to not have any endpoints
|
||||
if hosts == nil {
|
||||
hosts = make([]hostConfig, 1)
|
||||
}
|
||||
if len(hosts) > 0 && hosts[len(hosts)-1].host == "" {
|
||||
if host == "docker.io" {
|
||||
hosts[len(hosts)-1].scheme = "https"
|
||||
hosts[len(hosts)-1].host = "registry-1.docker.io"
|
||||
} else if docker.IsLocalhost(host) {
|
||||
hosts[len(hosts)-1].host = host
|
||||
if options.DefaultScheme == "" {
|
||||
_, port, _ := net.SplitHostPort(host)
|
||||
if port == "" || port == "443" {
|
||||
// If port is default or 443, only use https
|
||||
hosts[len(hosts)-1].scheme = "https"
|
||||
} else {
|
||||
// HTTP fallback logic will be used when protocol is ambiguous
|
||||
hosts[len(hosts)-1].scheme = "http"
|
||||
}
|
||||
|
||||
// When port is 80, protocol is not ambiguous
|
||||
if port != "80" {
|
||||
// Skipping TLS verification for localhost
|
||||
var skipVerify = true
|
||||
hosts[len(hosts)-1].skipVerify = &skipVerify
|
||||
}
|
||||
} else {
|
||||
hosts[len(hosts)-1].scheme = options.DefaultScheme
|
||||
}
|
||||
} else {
|
||||
hosts[len(hosts)-1].host = host
|
||||
if options.DefaultScheme != "" {
|
||||
hosts[len(hosts)-1].scheme = options.DefaultScheme
|
||||
} else {
|
||||
hosts[len(hosts)-1].scheme = "https"
|
||||
}
|
||||
}
|
||||
hosts[len(hosts)-1].path = "/v2"
|
||||
hosts[len(hosts)-1].capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush
|
||||
}
|
||||
|
||||
// tlsConfigured indicates that TLS was configured and HTTP endpoints should
|
||||
// attempt to use the TLS configuration before falling back to HTTP
|
||||
var tlsConfigured bool
|
||||
|
||||
var defaultTLSConfig *tls.Config
|
||||
if options.DefaultTLS != nil {
|
||||
tlsConfigured = true
|
||||
defaultTLSConfig = options.DefaultTLS
|
||||
} else {
|
||||
defaultTLSConfig = &tls.Config{}
|
||||
}
|
||||
|
||||
defaultTransport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
FallbackDelay: 300 * time.Millisecond,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
TLSClientConfig: defaultTLSConfig,
|
||||
ExpectContinueTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: defaultTransport,
|
||||
}
|
||||
if options.UpdateClient != nil {
|
||||
if err := options.UpdateClient(client); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
authOpts := []docker.AuthorizerOpt{docker.WithAuthClient(client)}
|
||||
if options.Credentials != nil {
|
||||
authOpts = append(authOpts, docker.WithAuthCreds(options.Credentials))
|
||||
}
|
||||
authOpts = append(authOpts, options.AuthorizerOpts...)
|
||||
authorizer := docker.NewDockerAuthorizer(authOpts...)
|
||||
|
||||
rhosts := make([]docker.RegistryHost, len(hosts))
|
||||
for i, host := range hosts {
|
||||
// Allow setting for each host as well
|
||||
explicitTLS := tlsConfigured
|
||||
|
||||
if host.caCerts != nil || host.clientPairs != nil || host.skipVerify != nil {
|
||||
explicitTLS = true
|
||||
tr := defaultTransport.Clone()
|
||||
tlsConfig := tr.TLSClientConfig
|
||||
if host.skipVerify != nil {
|
||||
tlsConfig.InsecureSkipVerify = *host.skipVerify
|
||||
}
|
||||
if host.caCerts != nil {
|
||||
if tlsConfig.RootCAs == nil {
|
||||
rootPool, err := rootSystemPool()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to initialize cert pool: %w", err)
|
||||
}
|
||||
tlsConfig.RootCAs = rootPool
|
||||
}
|
||||
for _, f := range host.caCerts {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read CA cert %q: %w", f, err)
|
||||
}
|
||||
if !tlsConfig.RootCAs.AppendCertsFromPEM(data) {
|
||||
return nil, fmt.Errorf("unable to load CA cert %q", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if host.clientPairs != nil {
|
||||
for _, pair := range host.clientPairs {
|
||||
certPEMBlock, err := os.ReadFile(pair[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read CERT file %q: %w", pair[0], err)
|
||||
}
|
||||
var keyPEMBlock []byte
|
||||
if pair[1] != "" {
|
||||
keyPEMBlock, err = os.ReadFile(pair[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read CERT file %q: %w", pair[1], err)
|
||||
}
|
||||
} else {
|
||||
// Load key block from same PEM file
|
||||
keyPEMBlock = certPEMBlock
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load X509 key pair: %w", err)
|
||||
}
|
||||
|
||||
tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
|
||||
}
|
||||
}
|
||||
|
||||
c := *client
|
||||
c.Transport = tr
|
||||
if options.UpdateClient != nil {
|
||||
if err := options.UpdateClient(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rhosts[i].Client = &c
|
||||
rhosts[i].Authorizer = docker.NewDockerAuthorizer(append(authOpts, docker.WithAuthClient(&c))...)
|
||||
} else {
|
||||
rhosts[i].Client = client
|
||||
rhosts[i].Authorizer = authorizer
|
||||
}
|
||||
|
||||
// When TLS has been configured for the operation or host and
|
||||
// the protocol from the port number is ambiguous, use the
|
||||
// docker.NewHTTPFallback roundtripper to catch TLS errors and re-attempt the
|
||||
// request as http. This allows preference for https when configured but
|
||||
// also catches TLS errors early enough in the request to avoid sending
|
||||
// the request twice or consuming the request body.
|
||||
if host.scheme == "http" && explicitTLS {
|
||||
_, port, _ := net.SplitHostPort(host.host)
|
||||
if port != "" && port != "80" {
|
||||
log.G(ctx).WithField("host", host.host).Info("host will try HTTPS first since it is configured for HTTP with a TLS configuration, consider changing host to HTTPS or removing unused TLS configuration")
|
||||
host.scheme = "https"
|
||||
rhosts[i].Client.Transport = docker.NewHTTPFallback(rhosts[i].Client.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
rhosts[i].Scheme = host.scheme
|
||||
rhosts[i].Host = host.host
|
||||
rhosts[i].Path = host.path
|
||||
rhosts[i].Capabilities = host.capabilities
|
||||
rhosts[i].Header = host.header
|
||||
}
|
||||
|
||||
return rhosts, nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// HostDirFromRoot returns a function which finds a host directory
|
||||
// based at the given root.
|
||||
func HostDirFromRoot(root string) func(string) (string, error) {
|
||||
return func(host string) (string, error) {
|
||||
for _, p := range hostPaths(root, host) {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", errdefs.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
// hostDirectory converts ":port" to "_port_" in directory names
|
||||
func hostDirectory(host string) string {
|
||||
idx := strings.LastIndex(host, ":")
|
||||
if idx > 0 {
|
||||
return host[:idx] + "_" + host[idx+1:] + "_"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func loadHostDir(ctx context.Context, hostsDir string) ([]hostConfig, error) {
|
||||
b, err := os.ReadFile(filepath.Join(hostsDir, "hosts.toml"))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(b) == 0 {
|
||||
// If hosts.toml does not exist, fallback to checking for
|
||||
// certificate files based on Docker's certificate file
|
||||
// pattern (".crt", ".cert", ".key" files)
|
||||
return loadCertFiles(ctx, hostsDir)
|
||||
}
|
||||
|
||||
hosts, err := parseHostsFile(hostsDir, b)
|
||||
if err != nil {
|
||||
log.G(ctx).WithError(err).Error("failed to decode hosts.toml")
|
||||
// Fallback to checking certificate files
|
||||
return loadCertFiles(ctx, hostsDir)
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
type hostFileConfig struct {
|
||||
// Capabilities determine what operations a host is
|
||||
// capable of performing. Allowed values
|
||||
// - pull
|
||||
// - resolve
|
||||
// - push
|
||||
Capabilities []string `toml:"capabilities"`
|
||||
|
||||
// CACert are the public key certificates for TLS
|
||||
// Accepted types
|
||||
// - string - Single file with certificate(s)
|
||||
// - []string - Multiple files with certificates
|
||||
CACert interface{} `toml:"ca"`
|
||||
|
||||
// Client keypair(s) for TLS with client authentication
|
||||
// Accepted types
|
||||
// - string - Single file with public and private keys
|
||||
// - []string - Multiple files with public and private keys
|
||||
// - [][2]string - Multiple keypairs with public and private keys in separate files
|
||||
Client interface{} `toml:"client"`
|
||||
|
||||
// SkipVerify skips verification of the server's certificate chain
|
||||
// and host name. This should only be used for testing or in
|
||||
// combination with other methods of verifying connections.
|
||||
SkipVerify *bool `toml:"skip_verify"`
|
||||
|
||||
// Header are additional header files to send to the server
|
||||
Header map[string]interface{} `toml:"header"`
|
||||
|
||||
// OverridePath indicates the API root endpoint is defined in the URL
|
||||
// path rather than by the API specification.
|
||||
// This may be used with non-compliant OCI registries to override the
|
||||
// API root endpoint.
|
||||
OverridePath bool `toml:"override_path"`
|
||||
|
||||
// TODO: Credentials: helper? name? username? alternate domain? token?
|
||||
}
|
||||
|
||||
func parseHostsFile(baseDir string, b []byte) ([]hostConfig, error) {
|
||||
tree, err := toml.LoadBytes(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse TOML: %w", err)
|
||||
}
|
||||
|
||||
// HACK: we want to keep toml parsing structures private in this package, however go-toml ignores private embedded types.
|
||||
// so we remap it to a public type within the func body, so technically it's public, but not possible to import elsewhere.
|
||||
type HostFileConfig = hostFileConfig
|
||||
|
||||
c := struct {
|
||||
HostFileConfig
|
||||
// Server specifies the default server. When `host` is
|
||||
// also specified, those hosts are tried first.
|
||||
Server string `toml:"server"`
|
||||
// HostConfigs store the per-host configuration
|
||||
HostConfigs map[string]hostFileConfig `toml:"host"`
|
||||
}{}
|
||||
|
||||
orderedHosts, err := getSortedHosts(tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
hosts []hostConfig
|
||||
)
|
||||
|
||||
if err := tree.Unmarshal(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse hosts array
|
||||
for _, host := range orderedHosts {
|
||||
config := c.HostConfigs[host]
|
||||
|
||||
parsed, err := parseHostConfig(host, baseDir, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts = append(hosts, parsed)
|
||||
}
|
||||
|
||||
// Parse root host config and append it as the last element
|
||||
parsed, err := parseHostConfig(c.Server, baseDir, c.HostFileConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts = append(hosts, parsed)
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func parseHostConfig(server string, baseDir string, config hostFileConfig) (hostConfig, error) {
|
||||
var (
|
||||
result = hostConfig{}
|
||||
err error
|
||||
)
|
||||
|
||||
if server != "" {
|
||||
if !strings.HasPrefix(server, "http") {
|
||||
server = "https://" + server
|
||||
}
|
||||
u, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return hostConfig{}, fmt.Errorf("unable to parse server %v: %w", server, err)
|
||||
}
|
||||
result.scheme = u.Scheme
|
||||
result.host = u.Host
|
||||
if len(u.Path) > 0 {
|
||||
u.Path = path.Clean(u.Path)
|
||||
if !strings.HasSuffix(u.Path, "/v2") && !config.OverridePath {
|
||||
u.Path = u.Path + "/v2"
|
||||
}
|
||||
} else if !config.OverridePath {
|
||||
u.Path = "/v2"
|
||||
}
|
||||
result.path = u.Path
|
||||
}
|
||||
|
||||
result.skipVerify = config.SkipVerify
|
||||
|
||||
if len(config.Capabilities) > 0 {
|
||||
for _, c := range config.Capabilities {
|
||||
switch strings.ToLower(c) {
|
||||
case "pull":
|
||||
result.capabilities |= docker.HostCapabilityPull
|
||||
case "resolve":
|
||||
result.capabilities |= docker.HostCapabilityResolve
|
||||
case "push":
|
||||
result.capabilities |= docker.HostCapabilityPush
|
||||
default:
|
||||
return hostConfig{}, fmt.Errorf("unknown capability %v", c)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush
|
||||
}
|
||||
|
||||
if config.CACert != nil {
|
||||
switch cert := config.CACert.(type) {
|
||||
case string:
|
||||
result.caCerts = []string{makeAbsPath(cert, baseDir)}
|
||||
case []interface{}:
|
||||
result.caCerts, err = makeStringSlice(cert, func(p string) string {
|
||||
return makeAbsPath(p, baseDir)
|
||||
})
|
||||
if err != nil {
|
||||
return hostConfig{}, err
|
||||
}
|
||||
default:
|
||||
return hostConfig{}, fmt.Errorf("invalid type %v for \"ca\"", cert)
|
||||
}
|
||||
}
|
||||
|
||||
if config.Client != nil {
|
||||
switch client := config.Client.(type) {
|
||||
case string:
|
||||
result.clientPairs = [][2]string{{makeAbsPath(client, baseDir), ""}}
|
||||
case []interface{}:
|
||||
// []string or [][2]string
|
||||
for _, pairs := range client {
|
||||
switch p := pairs.(type) {
|
||||
case string:
|
||||
result.clientPairs = append(result.clientPairs, [2]string{makeAbsPath(p, baseDir), ""})
|
||||
case []interface{}:
|
||||
slice, err := makeStringSlice(p, func(s string) string {
|
||||
return makeAbsPath(s, baseDir)
|
||||
})
|
||||
if err != nil {
|
||||
return hostConfig{}, err
|
||||
}
|
||||
if len(slice) != 2 {
|
||||
return hostConfig{}, fmt.Errorf("invalid pair %v for \"client\"", p)
|
||||
}
|
||||
|
||||
var pair [2]string
|
||||
copy(pair[:], slice)
|
||||
result.clientPairs = append(result.clientPairs, pair)
|
||||
default:
|
||||
return hostConfig{}, fmt.Errorf("invalid type %T for \"client\"", p)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return hostConfig{}, fmt.Errorf("invalid type %v for \"client\"", client)
|
||||
}
|
||||
}
|
||||
|
||||
if config.Header != nil {
|
||||
header := http.Header{}
|
||||
for key, ty := range config.Header {
|
||||
switch value := ty.(type) {
|
||||
case string:
|
||||
header[key] = []string{value}
|
||||
case []interface{}:
|
||||
header[key], err = makeStringSlice(value, nil)
|
||||
if err != nil {
|
||||
return hostConfig{}, err
|
||||
}
|
||||
default:
|
||||
return hostConfig{}, fmt.Errorf("invalid type %v for header %q", ty, key)
|
||||
}
|
||||
}
|
||||
result.header = header
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getSortedHosts returns the list of hosts as they defined in the file.
|
||||
func getSortedHosts(root *toml.Tree) ([]string, error) {
|
||||
iter, ok := root.Get("host").(*toml.Tree)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid `host` tree")
|
||||
}
|
||||
|
||||
list := append([]string{}, iter.Keys()...)
|
||||
|
||||
// go-toml stores TOML sections in the map object, so no order guaranteed.
|
||||
// We retrieve line number for each key and sort the keys by position.
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
h1 := iter.GetPath([]string{list[i]}).(*toml.Tree)
|
||||
h2 := iter.GetPath([]string{list[j]}).(*toml.Tree)
|
||||
return h1.Position().Line < h2.Position().Line
|
||||
})
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// makeStringSlice is a helper func to convert from []interface{} to []string.
|
||||
// Additionally an optional cb func may be passed to perform string mapping.
|
||||
func makeStringSlice(slice []interface{}, cb func(string) string) ([]string, error) {
|
||||
out := make([]string, len(slice))
|
||||
for i, value := range slice {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unable to cast %v to string", value)
|
||||
}
|
||||
|
||||
if cb != nil {
|
||||
out[i] = cb(str)
|
||||
} else {
|
||||
out[i] = str
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func makeAbsPath(p string, base string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(base, p)
|
||||
}
|
||||
|
||||
// loadCertsDir loads certs from certsDir like "/etc/docker/certs.d" .
|
||||
// Compatible with Docker file layout
|
||||
// - files ending with ".crt" are treated as CA certificate files
|
||||
// - files ending with ".cert" are treated as client certificates, and
|
||||
// files with the same name but ending with ".key" are treated as the
|
||||
// corresponding private key.
|
||||
// NOTE: If a ".key" file is missing, this function will just return
|
||||
// the ".cert", which may contain the private key. If the ".cert" file
|
||||
// does not contain the private key, the caller should detect and error.
|
||||
func loadCertFiles(ctx context.Context, certsDir string) ([]hostConfig, error) {
|
||||
fs, err := os.ReadDir(certsDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
hosts := make([]hostConfig, 1)
|
||||
for _, f := range fs {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(f.Name(), ".crt") {
|
||||
hosts[0].caCerts = append(hosts[0].caCerts, filepath.Join(certsDir, f.Name()))
|
||||
}
|
||||
if strings.HasSuffix(f.Name(), ".cert") {
|
||||
var pair [2]string
|
||||
certFile := f.Name()
|
||||
pair[0] = filepath.Join(certsDir, certFile)
|
||||
// Check if key also exists
|
||||
keyFile := filepath.Join(certsDir, certFile[:len(certFile)-5]+".key")
|
||||
if _, err := os.Stat(keyFile); err == nil {
|
||||
pair[1] = keyFile
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
hosts[0].clientPairs = append(hosts[0].clientPairs, pair)
|
||||
}
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
1
vendor/modules.txt
vendored
1
vendor/modules.txt
vendored
@@ -313,6 +313,7 @@ github.com/containerd/containerd/reference
|
||||
github.com/containerd/containerd/remotes
|
||||
github.com/containerd/containerd/remotes/docker
|
||||
github.com/containerd/containerd/remotes/docker/auth
|
||||
github.com/containerd/containerd/remotes/docker/config
|
||||
github.com/containerd/containerd/remotes/docker/schema1
|
||||
github.com/containerd/containerd/remotes/errors
|
||||
github.com/containerd/containerd/rootfs
|
||||
|
||||
Reference in New Issue
Block a user