diff --git a/docker/docker_client.go b/docker/docker_client.go index f5b3184126..05f937de9b 100644 --- a/docker/docker_client.go +++ b/docker/docker_client.go @@ -42,17 +42,17 @@ type dockerClient struct { wwwAuthenticate string // Cache of a value set by ping() if scheme is not empty scheme string // Cache of a value returned by a successful ping() if not empty client *http.Client + signatureBase signatureStorageBase } // newDockerClient returns a new dockerClient instance for refHostname (a host a specified in the Docker image reference, not canonicalized to dockerRegistry) -func newDockerClient(ctx *types.SystemContext, refHostname string) (*dockerClient, error) { - var registry string - if refHostname == dockerHostname { +// “write” specifies whether the client will be used for "write" access (in particular passed to lookaside.go:toplevelFromSection) +func newDockerClient(ctx *types.SystemContext, ref dockerReference, write bool) (*dockerClient, error) { + registry := ref.ref.Hostname() + if registry == dockerHostname { registry = dockerRegistry - } else { - registry = refHostname } - username, password, err := getAuth(refHostname) + username, password, err := getAuth(ref.ref.Hostname()) if err != nil { return nil, err } @@ -78,11 +78,18 @@ func newDockerClient(ctx *types.SystemContext, refHostname string) (*dockerClien if tr != nil { client.Transport = tr } + + sigBase, err := configuredSignatureStorageBase(ctx, ref, write) + if err != nil { + return nil, err + } + return &dockerClient{ - registry: registry, - username: username, - password: password, - client: client, + registry: registry, + username: username, + password: password, + client: client, + signatureBase: sigBase, }, nil } diff --git a/docker/docker_image_dest.go b/docker/docker_image_dest.go index b0fd5f6878..a16cf3fb32 100644 --- a/docker/docker_image_dest.go +++ b/docker/docker_image_dest.go @@ -8,6 +8,9 @@ import ( "io" "io/ioutil" "net/http" + "net/url" + "os" + "path/filepath" "strconv" "github.com/Sirupsen/logrus" @@ -18,11 +21,13 @@ import ( type dockerImageDestination struct { ref dockerReference c *dockerClient + // State + manifestDigest string // or "" if not yet known. } // newImageDestination creates a new ImageDestination for the specified image reference. func newImageDestination(ctx *types.SystemContext, ref dockerReference) (types.ImageDestination, error) { - c, err := newDockerClient(ctx, ref.ref.Hostname()) + c, err := newDockerClient(ctx, ref, true) if err != nil { return nil, err } @@ -144,6 +149,7 @@ func (d *dockerImageDestination) PutManifest(m []byte) error { if err != nil { return err } + d.manifestDigest = digest url := fmt.Sprintf(manifestURL, d.ref.ref.RemoteName(), digest) headers := map[string][]string{} @@ -168,12 +174,91 @@ func (d *dockerImageDestination) PutManifest(m []byte) error { } func (d *dockerImageDestination) PutSignatures(signatures [][]byte) error { - if len(signatures) != 0 { - return fmt.Errorf("Pushing signatures to a Docker Registry is not supported") + // FIXME? This overwrites files one at a time, definitely not atomic. + // A failure when updating signatures with a reordered copy could lose some of them. + + // Skip dealing with the manifest digest if not necessary. + if len(signatures) == 0 { + return nil + } + if d.c.signatureBase == nil { + return fmt.Errorf("Pushing signatures to a Docker Registry is not supported, and there is no applicable signature storage configured") + } + + // FIXME: This assumption that signatures are stored after the manifest rather breaks the model. + if d.manifestDigest == "" { + return fmt.Errorf("Unknown manifest digest, can't add signatures") + } + + for i, signature := range signatures { + url := signatureStorageURL(d.c.signatureBase, d.manifestDigest, i) + if url == nil { + return fmt.Errorf("Internal error: signatureStorageURL with non-nil base returned nil") + } + err := d.putOneSignature(url, signature) + if err != nil { + return err + } + } + // Remove any other signatures, if present. + // We stop at the first missing signature; if a previous deleting loop aborted + // prematurely, this may not clean up all of them, but one missing signature + // is enough for dockerImageSource to stop looking for other signatures, so that + // is sufficient. + for i := len(signatures); ; i++ { + url := signatureStorageURL(d.c.signatureBase, d.manifestDigest, i) + if url == nil { + return fmt.Errorf("Internal error: signatureStorageURL with non-nil base returned nil") + } + missing, err := d.c.deleteOneSignature(url) + if err != nil { + return err + } + if missing { + break + } } + return nil } +// putOneSignature stores one signature to url. +func (d *dockerImageDestination) putOneSignature(url *url.URL, signature []byte) error { + switch url.Scheme { + case "file": + logrus.Debugf("Writing to %s", url.Path) + err := os.MkdirAll(filepath.Dir(url.Path), 0755) + if err != nil { + return err + } + err = ioutil.WriteFile(url.Path, signature, 0644) + if err != nil { + return err + } + return nil + + default: + return fmt.Errorf("Unsupported scheme when writing signature to %s", url.String()) + } +} + +// deleteOneSignature deletes a signature from url, if it exists. +// If it successfully determines that the signature does not exist, returns (true, nil) +func (c *dockerClient) deleteOneSignature(url *url.URL) (missing bool, err error) { + switch url.Scheme { + case "file": + logrus.Debugf("Deleting %s", url.Path) + err := os.Remove(url.Path) + if err != nil && os.IsNotExist(err) { + return true, nil + } + return false, err + + default: + return false, fmt.Errorf("Unsupported scheme when deleting signature from %s", url.String()) + } +} + // Commit marks the process of storing the image as successful and asks for the image to be persisted. // WARNING: This does not have any transactional semantics: // - Uploaded data MAY be visible to others before Commit() is called diff --git a/docker/docker_image_src.go b/docker/docker_image_src.go index f0867e05f6..47d47f7266 100644 --- a/docker/docker_image_src.go +++ b/docker/docker_image_src.go @@ -6,6 +6,8 @@ import ( "io/ioutil" "mime" "net/http" + "net/url" + "os" "strconv" "github.com/Sirupsen/logrus" @@ -26,6 +28,9 @@ type dockerImageSource struct { ref dockerReference requestedManifestMIMETypes []string c *dockerClient + // State + cachedManifest []byte // nil if not loaded yet + cachedManifestMIMEType string // Only valid if cachedManifest != nil } // newImageSource creates a new ImageSource for the specified image reference, @@ -33,7 +38,7 @@ type dockerImageSource struct { // nil requestedManifestMIMETypes means manifest.DefaultRequestedManifestMIMETypes. // The caller must call .Close() on the returned ImageSource. func newImageSource(ctx *types.SystemContext, ref dockerReference, requestedManifestMIMETypes []string) (*dockerImageSource, error) { - c, err := newDockerClient(ctx, ref.ref.Hostname()) + c, err := newDockerClient(ctx, ref, false) if err != nil { return nil, err } @@ -71,10 +76,29 @@ func simplifyContentType(contentType string) string { } func (s *dockerImageSource) GetManifest() ([]byte, string, error) { - reference, err := s.ref.tagOrDigest() + err := s.ensureManifestIsLoaded() if err != nil { return nil, "", err } + return s.cachedManifest, s.cachedManifestMIMEType, nil +} + +// ensureManifestIsLoaded sets s.cachedManifest and s.cachedManifestMIMEType +// +// ImageSource implementations are not required or expected to do any caching, +// but because our signatures are “attached” to the manifest digest, +// we need to ensure that the digest of the manifest returned by GetManifest +// and used by GetSignatures are consistent, otherwise we would get spurious +// signature verification failures when pulling while a tag is being updated. +func (s *dockerImageSource) ensureManifestIsLoaded() error { + if s.cachedManifest != nil { + return nil + } + + reference, err := s.ref.tagOrDigest() + if err != nil { + return err + } url := fmt.Sprintf(manifestURL, s.ref.ref.RemoteName(), reference) // TODO(runcom) set manifest version header! schema1 for now - then schema2 etc etc and v1 // TODO(runcom) NO, switch on the resulter manifest like Docker is doing @@ -82,18 +106,20 @@ func (s *dockerImageSource) GetManifest() ([]byte, string, error) { headers["Accept"] = s.requestedManifestMIMETypes res, err := s.c.makeRequest("GET", url, headers, nil) if err != nil { - return nil, "", err + return err } defer res.Body.Close() manblob, err := ioutil.ReadAll(res.Body) if err != nil { - return nil, "", err + return err } if res.StatusCode != http.StatusOK { - return nil, "", errFetchManifest{res.StatusCode, manblob} + return errFetchManifest{res.StatusCode, manblob} } // We might validate manblob against the Docker-Content-Digest header here to protect against transport errors. - return manblob, simplifyContentType(res.Header.Get("Content-Type")), nil + s.cachedManifest = manblob + s.cachedManifestMIMEType = simplifyContentType(res.Header.Get("Content-Type")) + return nil } // GetBlob returns a stream for the specified blob, and the blob’s size (or -1 if unknown). @@ -116,12 +142,77 @@ func (s *dockerImageSource) GetBlob(digest string) (io.ReadCloser, int64, error) } func (s *dockerImageSource) GetSignatures() ([][]byte, error) { - return [][]byte{}, nil + if s.c.signatureBase == nil { // Skip dealing with the manifest digest if not necessary. + return [][]byte{}, nil + } + + if err := s.ensureManifestIsLoaded(); err != nil { + return nil, err + } + manifestDigest, err := manifest.Digest(s.cachedManifest) + if err != nil { + return nil, err + } + + signatures := [][]byte{} + for i := 0; ; i++ { + url := signatureStorageURL(s.c.signatureBase, manifestDigest, i) + if url == nil { + return nil, fmt.Errorf("Internal error: signatureStorageURL with non-nil base returned nil") + } + signature, missing, err := s.getOneSignature(url) + if err != nil { + return nil, err + } + if missing { + break + } + signatures = append(signatures, signature) + } + return signatures, nil +} + +// getOneSignature downloads one signature from url. +// If it successfully determines that the signature does not exist, returns with missing set to true and error set to nil. +func (s *dockerImageSource) getOneSignature(url *url.URL) (signature []byte, missing bool, err error) { + switch url.Scheme { + case "file": + logrus.Debugf("Reading %s", url.Path) + sig, err := ioutil.ReadFile(url.Path) + if err != nil { + if os.IsNotExist(err) { + return nil, true, nil + } + return nil, false, err + } + return sig, false, nil + + case "http", "https": + logrus.Debugf("GET %s", url) + res, err := s.c.client.Get(url.String()) + if err != nil { + return nil, false, err + } + defer res.Body.Close() + if res.StatusCode == http.StatusNotFound { + return nil, true, nil + } else if res.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("Error reading signature from %s: status %d", url.String(), res.StatusCode) + } + sig, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, false, err + } + return sig, false, nil + + default: + return nil, false, fmt.Errorf("Unsupported scheme when reading signature from %s", url.String()) + } } // deleteImage deletes the named image from the registry, if supported. func deleteImage(ctx *types.SystemContext, ref dockerReference) error { - c, err := newDockerClient(ctx, ref.ref.Hostname()) + c, err := newDockerClient(ctx, ref, true) if err != nil { return err } @@ -141,7 +232,7 @@ func deleteImage(ctx *types.SystemContext, ref dockerReference) error { return err } defer get.Body.Close() - body, err := ioutil.ReadAll(get.Body) + manifestBody, err := ioutil.ReadAll(get.Body) if err != nil { return err } @@ -150,7 +241,7 @@ func deleteImage(ctx *types.SystemContext, ref dockerReference) error { case http.StatusNotFound: return fmt.Errorf("Unable to delete %v. Image may not exist or is not stored with a v2 Schema in a v2 registry.", ref.ref) default: - return fmt.Errorf("Failed to delete %v: %s (%v)", ref.ref, string(body), get.Status) + return fmt.Errorf("Failed to delete %v: %s (%v)", ref.ref, manifestBody, get.Status) } digest := get.Header.Get("Docker-Content-Digest") @@ -164,7 +255,7 @@ func deleteImage(ctx *types.SystemContext, ref dockerReference) error { } defer delete.Body.Close() - body, err = ioutil.ReadAll(delete.Body) + body, err := ioutil.ReadAll(delete.Body) if err != nil { return err } @@ -172,5 +263,26 @@ func deleteImage(ctx *types.SystemContext, ref dockerReference) error { return fmt.Errorf("Failed to delete %v: %s (%v)", deleteURL, string(body), delete.Status) } + if c.signatureBase != nil { + manifestDigest, err := manifest.Digest(manifestBody) + if err != nil { + return err + } + + for i := 0; ; i++ { + url := signatureStorageURL(c.signatureBase, manifestDigest, i) + if url == nil { + return fmt.Errorf("Internal error: signatureStorageURL with non-nil base returned nil") + } + missing, err := c.deleteOneSignature(url) + if err != nil { + return err + } + if missing { + break + } + } + } + return nil } diff --git a/docker/docker_transport_test.go b/docker/docker_transport_test.go index f9cc5928a4..eb71f620cd 100644 --- a/docker/docker_transport_test.go +++ b/docker/docker_transport_test.go @@ -160,7 +160,7 @@ func TestReferencePolicyConfigurationNamespaces(t *testing.T) { func TestReferenceNewImage(t *testing.T) { ref, err := ParseReference("//busybox") require.NoError(t, err) - img, err := ref.NewImage(nil) + img, err := ref.NewImage(&types.SystemContext{RegistriesDirPath: "/this/doesnt/exist"}) assert.NoError(t, err) defer img.Close() } @@ -168,7 +168,7 @@ func TestReferenceNewImage(t *testing.T) { func TestReferenceNewImageSource(t *testing.T) { ref, err := ParseReference("//busybox") require.NoError(t, err) - src, err := ref.NewImageSource(nil, nil) + src, err := ref.NewImageSource(&types.SystemContext{RegistriesDirPath: "/this/doesnt/exist"}, nil) assert.NoError(t, err) defer src.Close() } @@ -176,7 +176,7 @@ func TestReferenceNewImageSource(t *testing.T) { func TestReferenceNewImageDestination(t *testing.T) { ref, err := ParseReference("//busybox") require.NoError(t, err) - dest, err := ref.NewImageDestination(nil) + dest, err := ref.NewImageDestination(&types.SystemContext{RegistriesDirPath: "/this/doesnt/exist"}) assert.NoError(t, err) defer dest.Close() } diff --git a/docker/fixtures/registries.d/emptyConfig.yaml b/docker/fixtures/registries.d/emptyConfig.yaml new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/docker/fixtures/registries.d/emptyConfig.yaml @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/docker/fixtures/registries.d/internal-example.com.yaml b/docker/fixtures/registries.d/internal-example.com.yaml new file mode 100644 index 0000000000..67a0b17265 --- /dev/null +++ b/docker/fixtures/registries.d/internal-example.com.yaml @@ -0,0 +1,14 @@ +docker: + example.com: + sigstore: https://sigstore.example.com + registry.test.example.com: + sigstore: http://registry.test.example.com/sigstore + registry.test.example.com:8888: + sigstore: http://registry.test.example.com:8889/sigstore + sigstore-write: https://registry.test.example.com:8889/sigstore/specialAPIserverWhichDoesntExist + localhost: + sigstore: file:///home/mitr/mydevelopment1 + localhost:8080: + sigstore: file:///home/mitr/mydevelopment2 + localhost/invalid/url/test: + sigstore: ":emptyscheme" diff --git a/docker/fixtures/registries.d/internet-user.yaml b/docker/fixtures/registries.d/internet-user.yaml new file mode 100644 index 0000000000..9a40e85f4b --- /dev/null +++ b/docker/fixtures/registries.d/internet-user.yaml @@ -0,0 +1,12 @@ +default-docker: + sigstore: file:///mnt/companywide/signatures/for/other/repositories +docker: + docker.io/contoso: + sigstore: https://sigstore.contoso.com/fordocker + docker.io/centos: + sigstore: https://sigstore.centos.org/ + docker.io/centos/mybetaprooduct: + sigstore: http://localhost:9999/mybetaWIP/sigstore + sigstore-write: file:///srv/mybetaWIP/sigstore + docker.io/centos/mybetaproduct:latest: + sigstore: https://sigstore.centos.org/ diff --git a/docker/fixtures/registries.d/invalid-but.notyaml b/docker/fixtures/registries.d/invalid-but.notyaml new file mode 100644 index 0000000000..5c34318c21 --- /dev/null +++ b/docker/fixtures/registries.d/invalid-but.notyaml @@ -0,0 +1 @@ +} diff --git a/docker/lookaside.go b/docker/lookaside.go new file mode 100644 index 0000000000..989fc13f27 --- /dev/null +++ b/docker/lookaside.go @@ -0,0 +1,198 @@ +package docker + +import ( + "fmt" + "io/ioutil" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/ghodss/yaml" + + "github.com/Sirupsen/logrus" + "github.com/containers/image/types" +) + +// systemRegistriesDirPath is the path to registries.d, used for locating lookaside Docker signature storage. +// You can override this at build time with +// -ldflags '-X github.com/containers/image/docker.systemRegistriesDirPath=$your_path' +var systemRegistriesDirPath = builtinRegistriesDirPath + +// builtinRegistriesDirPath is the path to registries.d. +// DO NOT change this, instead see systemRegistriesDirPath above. +const builtinRegistriesDirPath = "/etc/containers/registries.d" + +// registryConfiguration is one of the files in registriesDirPath configuring lookaside locations, or the result of merging them all. +// NOTE: Keep this in sync with docs/registries.d.md! +type registryConfiguration struct { + DefaultDocker *registryNamespace `json:"default-docker"` + // The key is a namespace, using fully-expanded Docker reference format or parent namespaces (per dockerReference.PolicyConfiguration*), + Docker map[string]registryNamespace `json:"docker"` +} + +// registryNamespace defines lookaside locations for a single namespace. +type registryNamespace struct { + SigStore string `json:"sigstore"` // For reading, and if SigStoreWrite is not present, for writing. + SigStoreWrite string `json:"sigstore-write"` // For writing only. +} + +// signatureStorageBase is an "opaque" type representing a lookaside Docker signature storage. +// Users outside of this file should use configuredSignatureStorageBase and signatureStorageURL below. +type signatureStorageBase *url.URL // The only documented value is nil, meaning storage is not supported. + +// configuredSignatureStorageBase reads configuration to find an appropriate signature storage URL for ref, for write access if “write”. +func configuredSignatureStorageBase(ctx *types.SystemContext, ref dockerReference, write bool) (signatureStorageBase, error) { + // FIXME? Loading and parsing the config could be cached across calls. + dirPath := registriesDirPath(ctx) + logrus.Debugf(`Using registries.d directory %s for sigstore configuration`, dirPath) + config, err := loadAndMergeConfig(dirPath) + if err != nil { + return nil, err + } + + topLevel := config.signatureTopLevel(ref, write) + if topLevel == "" { + return nil, nil + } + + url, err := url.Parse(topLevel) + if err != nil { + return nil, fmt.Errorf("Invalid signature storage URL %s: %v", topLevel, err) + } + // FIXME? Restrict to explicitly supported schemes? + repo := ref.ref.FullName() // Note that this is without a tag or digest. + if path.Clean(repo) != repo { // Coverage: This should not be reachable because /./ and /../ components are not valid in docker references + return nil, fmt.Errorf("Unexpected path elements in Docker reference %s for signature storage", ref.ref.String()) + } + url.Path = url.Path + "/" + repo + return url, nil +} + +// registriesDirPath returns a path to registries.d +func registriesDirPath(ctx *types.SystemContext) string { + if ctx != nil { + if ctx.RegistriesDirPath != "" { + return ctx.RegistriesDirPath + } + if ctx.RootForImplicitAbsolutePaths != "" { + return filepath.Join(ctx.RootForImplicitAbsolutePaths, systemRegistriesDirPath) + } + } + return systemRegistriesDirPath +} + +// loadAndMergeConfig loads configuration files in dirPath +func loadAndMergeConfig(dirPath string) (*registryConfiguration, error) { + mergedConfig := registryConfiguration{Docker: map[string]registryNamespace{}} + dockerDefaultMergedFrom := "" + nsMergedFrom := map[string]string{} + + dir, err := os.Open(dirPath) + if err != nil { + if os.IsNotExist(err) { + return &mergedConfig, nil + } + return nil, err + } + configNames, err := dir.Readdirnames(0) + if err != nil { + return nil, err + } + for _, configName := range configNames { + if !strings.HasSuffix(configName, ".yaml") { + continue + } + configPath := filepath.Join(dirPath, configName) + configBytes, err := ioutil.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config registryConfiguration + err = yaml.Unmarshal(configBytes, &config) + if err != nil { + return nil, fmt.Errorf("Error parsing %s: %v", configPath, err) + } + + if config.DefaultDocker != nil { + if mergedConfig.DefaultDocker != nil { + return nil, fmt.Errorf(`Error parsing signature storage configuration: "default-docker" defined both in "%s" and "%s"`, + dockerDefaultMergedFrom, configPath) + } + mergedConfig.DefaultDocker = config.DefaultDocker + dockerDefaultMergedFrom = configPath + } + + for nsName, nsConfig := range config.Docker { // includes config.Docker == nil + if _, ok := mergedConfig.Docker[nsName]; ok { + return nil, fmt.Errorf(`Error parsing signature storage configuration: "docker" namespace "%s" defined both in "%s" and "%s"`, + nsName, nsMergedFrom[nsName], configPath) + } + mergedConfig.Docker[nsName] = nsConfig + nsMergedFrom[nsName] = configPath + } + } + + return &mergedConfig, nil +} + +// config.signatureTopLevel returns an URL string configured in config for ref, for write access if “write”. +// (the top level of the storage, namespaced by repo.FullName etc.), or "" if no signature storage should be used. +func (config *registryConfiguration) signatureTopLevel(ref dockerReference, write bool) string { + if config.Docker != nil { + // Look for a full match. + identity := ref.PolicyConfigurationIdentity() + if ns, ok := config.Docker[identity]; ok { + logrus.Debugf(` Using "docker" namespace %s`, identity) + if url := ns.signatureTopLevel(write); url != "" { + return url + } + } + + // Look for a match of the possible parent namespaces. + for _, name := range ref.PolicyConfigurationNamespaces() { + if ns, ok := config.Docker[name]; ok { + logrus.Debugf(` Using "docker" namespace %s`, name) + if url := ns.signatureTopLevel(write); url != "" { + return url + } + } + } + } + // Look for a default location + if config.DefaultDocker != nil { + logrus.Debugf(` Using "default-docker" configuration`) + if url := config.DefaultDocker.signatureTopLevel(write); url != "" { + return url + } + } + logrus.Debugf(" No signature storage configuration found for %s", ref.PolicyConfigurationIdentity()) + return "" +} + +// ns.signatureTopLevel returns an URL string configured in ns for ref, for write access if “write”. +// or "" if nothing has been configured. +func (ns registryNamespace) signatureTopLevel(write bool) string { + if write && ns.SigStoreWrite != "" { + logrus.Debugf(` Using %s`, ns.SigStoreWrite) + return ns.SigStoreWrite + } + if ns.SigStore != "" { + logrus.Debugf(` Using %s`, ns.SigStore) + return ns.SigStore + } + return "" +} + +// signatureStorageURL returns an URL usable for acessing signature index in base with known manifestDigest, or nil if not applicable. +// Returns nil iff base == nil. +func signatureStorageURL(base signatureStorageBase, manifestDigest string, index int) *url.URL { + if base == nil { + return nil + } + url := *base + url.Path = fmt.Sprintf("%s@%s/signature-%d", url.Path, manifestDigest, index+1) + return &url +} diff --git a/docker/lookaside_test.go b/docker/lookaside_test.go new file mode 100644 index 0000000000..19997d04c0 --- /dev/null +++ b/docker/lookaside_test.go @@ -0,0 +1,277 @@ +package docker + +import ( + "fmt" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/containers/image/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func dockerRefFromString(t *testing.T, s string) dockerReference { + ref, err := ParseReference(s) + require.NoError(t, err, s) + dockerRef, ok := ref.(dockerReference) + require.True(t, ok, s) + return dockerRef +} + +func TestConfiguredSignatureStorageBase(t *testing.T) { + // Error reading configuration directory (/dev/null is not a directory) + _, err := configuredSignatureStorageBase(&types.SystemContext{RegistriesDirPath: "/dev/null"}, + dockerRefFromString(t, "//busybox"), false) + assert.Error(t, err) + + // No match found + emptyDir, err := ioutil.TempDir("", "empty-dir") + require.NoError(t, err) + defer os.RemoveAll(emptyDir) + base, err := configuredSignatureStorageBase(&types.SystemContext{RegistriesDirPath: emptyDir}, + dockerRefFromString(t, "//this/is/not/in/the:configuration"), false) + assert.NoError(t, err) + assert.Nil(t, base) + + // Invalid URL + _, err = configuredSignatureStorageBase(&types.SystemContext{RegistriesDirPath: "fixtures/registries.d"}, + dockerRefFromString(t, "//localhost/invalid/url/test"), false) + assert.Error(t, err) + + // Success + base, err = configuredSignatureStorageBase(&types.SystemContext{RegistriesDirPath: "fixtures/registries.d"}, + dockerRefFromString(t, "//example.com/my/project"), false) + assert.NoError(t, err) + require.NotNil(t, base) + assert.Equal(t, "https://sigstore.example.com/example.com/my/project", (*url.URL)(base).String()) +} + +func TestRegistriesDirPath(t *testing.T) { + const nondefaultPath = "/this/is/not/the/default/registries.d" + const variableReference = "$HOME" + const rootPrefix = "/root/prefix" + + for _, c := range []struct { + ctx *types.SystemContext + expected string + }{ + // The common case + {nil, systemRegistriesDirPath}, + // There is a context, but it does not override the path. + {&types.SystemContext{}, systemRegistriesDirPath}, + // Path overridden + {&types.SystemContext{RegistriesDirPath: nondefaultPath}, nondefaultPath}, + // Root overridden + { + &types.SystemContext{RootForImplicitAbsolutePaths: rootPrefix}, + filepath.Join(rootPrefix, systemRegistriesDirPath), + }, + // Root and path overrides present simultaneously, + { + &types.SystemContext{ + RootForImplicitAbsolutePaths: rootPrefix, + RegistriesDirPath: nondefaultPath, + }, + nondefaultPath, + }, + // No environment expansion happens in the overridden paths + {&types.SystemContext{RegistriesDirPath: variableReference}, variableReference}, + } { + path := registriesDirPath(c.ctx) + assert.Equal(t, c.expected, path) + } +} + +func TestLoadAndMergeConfig(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "merge-config") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // No registries.d exists + config, err := loadAndMergeConfig(filepath.Join(tmpDir, "thisdoesnotexist")) + require.NoError(t, err) + assert.Equal(t, ®istryConfiguration{Docker: map[string]registryNamespace{}}, config) + + // Empty registries.d directory + emptyDir := filepath.Join(tmpDir, "empty") + err = os.Mkdir(emptyDir, 0755) + require.NoError(t, err) + config, err = loadAndMergeConfig(emptyDir) + require.NoError(t, err) + assert.Equal(t, ®istryConfiguration{Docker: map[string]registryNamespace{}}, config) + + // Unreadable registries.d directory + unreadableDir := filepath.Join(tmpDir, "unreadable") + err = os.Mkdir(unreadableDir, 0000) + require.NoError(t, err) + config, err = loadAndMergeConfig(unreadableDir) + assert.Error(t, err) + + // An unreadable file in a registries.d directory + unreadableFileDir := filepath.Join(tmpDir, "unreadableFile") + err = os.Mkdir(unreadableFileDir, 0755) + require.NoError(t, err) + err = ioutil.WriteFile(filepath.Join(unreadableFileDir, "0.yaml"), []byte("{}"), 0644) + require.NoError(t, err) + err = ioutil.WriteFile(filepath.Join(unreadableFileDir, "1.yaml"), nil, 0000) + require.NoError(t, err) + config, err = loadAndMergeConfig(unreadableFileDir) + assert.Error(t, err) + + // Invalid YAML + invalidYAMLDir := filepath.Join(tmpDir, "invalidYAML") + err = os.Mkdir(invalidYAMLDir, 0755) + require.NoError(t, err) + err = ioutil.WriteFile(filepath.Join(invalidYAMLDir, "0.yaml"), []byte("}"), 0644) + require.NoError(t, err) + config, err = loadAndMergeConfig(invalidYAMLDir) + assert.Error(t, err) + + // Duplicate DefaultDocker + duplicateDefault := filepath.Join(tmpDir, "duplicateDefault") + err = os.Mkdir(duplicateDefault, 0755) + require.NoError(t, err) + err = ioutil.WriteFile(filepath.Join(duplicateDefault, "0.yaml"), + []byte("default-docker:\n sigstore: file:////tmp/something"), 0644) + require.NoError(t, err) + err = ioutil.WriteFile(filepath.Join(duplicateDefault, "1.yaml"), + []byte("default-docker:\n sigstore: file:////tmp/different"), 0644) + require.NoError(t, err) + config, err = loadAndMergeConfig(duplicateDefault) + require.Error(t, err) + assert.Contains(t, err.Error(), "0.yaml") + assert.Contains(t, err.Error(), "1.yaml") + + // Duplicate DefaultDocker + duplicateNS := filepath.Join(tmpDir, "duplicateNS") + err = os.Mkdir(duplicateNS, 0755) + require.NoError(t, err) + err = ioutil.WriteFile(filepath.Join(duplicateNS, "0.yaml"), + []byte("docker:\n example.com:\n sigstore: file:////tmp/something"), 0644) + require.NoError(t, err) + err = ioutil.WriteFile(filepath.Join(duplicateNS, "1.yaml"), + []byte("docker:\n example.com:\n sigstore: file:////tmp/different"), 0644) + require.NoError(t, err) + config, err = loadAndMergeConfig(duplicateNS) + assert.Error(t, err) + assert.Contains(t, err.Error(), "0.yaml") + assert.Contains(t, err.Error(), "1.yaml") + + // A fully worked example, including an empty-dictionary file and a non-.yaml file + config, err = loadAndMergeConfig("fixtures/registries.d") + require.NoError(t, err) + assert.Equal(t, ®istryConfiguration{ + DefaultDocker: ®istryNamespace{SigStore: "file:///mnt/companywide/signatures/for/other/repositories"}, + Docker: map[string]registryNamespace{ + "example.com": {SigStore: "https://sigstore.example.com"}, + "registry.test.example.com": {SigStore: "http://registry.test.example.com/sigstore"}, + "registry.test.example.com:8888": {SigStore: "http://registry.test.example.com:8889/sigstore", SigStoreWrite: "https://registry.test.example.com:8889/sigstore/specialAPIserverWhichDoesntExist"}, + "localhost": {SigStore: "file:///home/mitr/mydevelopment1"}, + "localhost:8080": {SigStore: "file:///home/mitr/mydevelopment2"}, + "localhost/invalid/url/test": {SigStore: ":emptyscheme"}, + "docker.io/contoso": {SigStore: "https://sigstore.contoso.com/fordocker"}, + "docker.io/centos": {SigStore: "https://sigstore.centos.org/"}, + "docker.io/centos/mybetaprooduct": { + SigStore: "http://localhost:9999/mybetaWIP/sigstore", + SigStoreWrite: "file:///srv/mybetaWIP/sigstore", + }, + "docker.io/centos/mybetaproduct:latest": {SigStore: "https://sigstore.centos.org/"}, + }, + }, config) +} + +func TestRegistryConfigurationSignaureTopLevel(t *testing.T) { + config := registryConfiguration{ + DefaultDocker: ®istryNamespace{SigStore: "=default", SigStoreWrite: "=default+w"}, + Docker: map[string]registryNamespace{}, + } + for _, ns := range []string{ + "localhost", + "localhost:5000", + "example.com", + "example.com/ns1", + "example.com/ns1/ns2", + "example.com/ns1/ns2/repo", + "example.com/ns1/ns2/repo:notlatest", + } { + config.Docker[ns] = registryNamespace{SigStore: ns, SigStoreWrite: ns + "+w"} + } + + for _, c := range []struct{ input, expected string }{ + {"example.com/ns1/ns2/repo:notlatest", "example.com/ns1/ns2/repo:notlatest"}, + {"example.com/ns1/ns2/repo:unmatched", "example.com/ns1/ns2/repo"}, + {"example.com/ns1/ns2/notrepo:notlatest", "example.com/ns1/ns2"}, + {"example.com/ns1/notns2/repo:notlatest", "example.com/ns1"}, + {"example.com/notns1/ns2/repo:notlatest", "example.com"}, + {"unknown.example.com/busybox", "=default"}, + {"localhost:5000/busybox", "localhost:5000"}, + {"localhost/busybox", "localhost"}, + {"localhost:9999/busybox", "=default"}, + } { + dr := dockerRefFromString(t, "//"+c.input) + + res := config.signatureTopLevel(dr, false) + assert.Equal(t, c.expected, res, c.input) + res = config.signatureTopLevel(dr, true) // test that forWriting is correctly propagated + assert.Equal(t, c.expected+"+w", res, c.input) + } + + config = registryConfiguration{ + Docker: map[string]registryNamespace{ + "unmatched": {SigStore: "a", SigStoreWrite: "b"}, + }, + } + dr := dockerRefFromString(t, "//thisisnotmatched") + res := config.signatureTopLevel(dr, false) + assert.Equal(t, "", res) + res = config.signatureTopLevel(dr, true) + assert.Equal(t, "", res) +} + +func TestRegistryNamespaceSignatureTopLevel(t *testing.T) { + for _, c := range []struct { + ns registryNamespace + forWriting bool + expected string + }{ + {registryNamespace{SigStoreWrite: "a", SigStore: "b"}, true, "a"}, + {registryNamespace{SigStoreWrite: "a", SigStore: "b"}, false, "b"}, + {registryNamespace{SigStore: "b"}, true, "b"}, + {registryNamespace{SigStore: "b"}, false, "b"}, + {registryNamespace{SigStoreWrite: "a"}, true, "a"}, + {registryNamespace{SigStoreWrite: "a"}, false, ""}, + {registryNamespace{}, true, ""}, + {registryNamespace{}, false, ""}, + } { + res := c.ns.signatureTopLevel(c.forWriting) + assert.Equal(t, c.expected, res, fmt.Sprintf("%#v %v", c.ns, c.forWriting)) + } +} + +func TestSignatureStorageBaseSignatureStorageURL(t *testing.T) { + const md = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + assert.True(t, signatureStorageURL(nil, md, 0) == nil) + for _, c := range []struct { + base string + index int + expected string + }{ + {"file:///tmp", 0, "file:///tmp@" + md + "/signature-1"}, + {"file:///tmp", 1, "file:///tmp@" + md + "/signature-2"}, + {"https://localhost:5555/root", 0, "https://localhost:5555/root@" + md + "/signature-1"}, + {"https://localhost:5555/root", 1, "https://localhost:5555/root@" + md + "/signature-2"}, + {"http://localhost:5555/root", 0, "http://localhost:5555/root@" + md + "/signature-1"}, + {"http://localhost:5555/root", 1, "http://localhost:5555/root@" + md + "/signature-2"}, + } { + url, err := url.Parse(c.base) + require.NoError(t, err) + expectedURL, err := url.Parse(c.expected) + require.NoError(t, err) + res := signatureStorageURL(url, md, c.index) + assert.Equal(t, expectedURL, res, c.expected) + } +} diff --git a/docs/registries.d.md b/docs/registries.d.md new file mode 100644 index 0000000000..a03f8aef68 --- /dev/null +++ b/docs/registries.d.md @@ -0,0 +1,119 @@ + + +# Registries Configuration Directory + +The registries configuration directory contains configuration for various registries +(servers storing remote container images), and for content stored in them, +so that the configuration does not have to be provided in command-line options over and over for every command, +and so that it can be shared by all users of containers/image. + +By default (unless overridden at compile-time), the registries configuration directory is `/etc/containers/registries.d`; +applications may allow using a different directory instead. + +## Directory Structure + +The directory may contain any number of files with the extension `.yaml`, +each using the YAML format. Other than the mandatory extension, names of the files +don’t matter. + +The contents of these files are merged together; to have a well-defined and easy to understand +behavior, there can be only one configuration section describing a single namespace within a registry +(in particular there can be at most one one `default-docker` section across all files, +and there can be at most one instance of any key under the the `docker` section; +these sections are documented later). + +Thus, it is forbidden to have two conflicting configurations for a single registry or scope, +and it is also forbidden to split a configuration for a single registry or scope across +more than one file (even if they are not semantically in conflict). + +## Registries, Scopes and Search Order + +Each YAML file must contain a “YAML mapping” (key-value pairs). Two top-level keys are defined: + +- `default-docker` is the _configuration section_ (as documented below) + for registries implementing "Docker Registry HTTP API V2". + + This key is optional. + +- `docker` is a mapping, using individual registries implementing "Docker Registry HTTP API V2", + or namespaces and individual images within these registries, as keys; + the value assigned to any such key is a _configuration section_. + + This key is optional. + + Scopes matching individual images are named Docker references *in the fully expanded form*, either + using a tag or digest. For example, `docker.io/library/busybox:latest` (*not* `busybox:latest`). + + More general scopes are prefixes of individual-image scopes, and specify a repository (by omitting the tag or digest), + a repository namespace, or a registry host (and a port if it differs from the default). + + Note that if a registry is accessed using a hostname+port configuration, the port-less hostname + is _not_ used as parent scope. + +When searching for a configuration to apply for an individual container image, only +the configuration for the most-precisely matching scope is used; configuration using +more general scopes is ignored. For example, if _any_ configuration exists for +`docker.io/library/busybox`, the configuration for `docker.io` is ignored +(even if some element of the configuration is defined for `docker.io` and not for `docker.io/library/busybox`). + +## Individual Configuration Sections + +A single configuration section is selected for a container image using the process +described above. The configuration section is a YAML mapping, with the following keys: + +- `sigstore-write` defines an URL of of the signature storage, used for editing it (adding or deleting signatures). + + This key is optional; if it is missing, `sigstore` below is used. + +- `sigstore` defines an URL of the signature storage. + This URL is used for reading existing signatures, + and if `sigstore-write` does not exist, also for adding or removing them. + + This key is optional; if it is missing, no signature storage is defined (no signatures + are download along with images, adding new signatures is impossible). + +## Examples + +### Using Containers from Various Origins + +The following demonstrates how to to consume and run images from various registries and namespaces: + +```yaml +docker: + registry.database-supplier.com: + sigstore: https://sigstore.database-supplier.com + distribution.great-middleware.org: + sigstore: https://security-team.great-middleware.org/sigstore + docker.io/web-framework: + sigstore: https://sigstore.web-framework.io:8080 +``` + +### Developing and Signing Containers, Staging Signatures + +For developers in `example.com`: + +- Consume most container images using the public servers also used by clients. +- Use a separate sigure storage for an container images in a namespace corresponding to the developers' department, with a staging storage used before publishing signatures. +- Craft an individual exception for a single branch a specific developer is working on locally. + +```yaml +docker: + registry.example.com: + sigstore: https://registry-sigstore.example.com + registry.example.com/mydepartment: + sigstore: https://sigstore.mydepartment.example.com + sigstore-write: file:///mnt/mydepartment/sigstore-staging + registry.example.com/mydepartment/myproject:mybranch: + sigstore: http://localhost:4242/sigstore + sigstore-write: file:///home/useraccount/webroot/sigstore +``` + +### A Global Default + +If a company publishes its products using a different domain, and different registry hostname for each of them, it is still possible to use a single signature storage server +without listing each domain individually. This is expected to rarely happen, usually only for staging new signatures. + +```yaml +default-docker: + sigstore-write: file:///mnt/company/common-sigstore-staging +``` diff --git a/types/types.go b/types/types.go index a2cdb6fa10..1ef4e67d2c 100644 --- a/types/types.go +++ b/types/types.go @@ -188,12 +188,16 @@ type SystemContext struct { // If not "", prefixed to any absolute paths used by default by the library (e.g. in /etc/). // Not used for any of the more specific path overrides available in this struct. // Not used for any paths specified by users in config files (even if the location of the config file _was_ affected by it). + // NOTE: If this is set, environment-variable overrides of paths are ignored (to keep the semantics simple: to create an /etc replacement, just set RootForImplicitAbsolutePaths . + // and there is no need to worry about the environment.) // NOTE: This does NOT affect paths starting by $HOME. RootForImplicitAbsolutePaths string // === Global configuration overrides === // If not "", overrides the system's default path for signature.Policy configuration. SignaturePolicyPath string + // If not "", overrides the system's default path for registries.d (Docker signature storage configuration) + RegistriesDirPath string // === docker.Transport overrides === DockerCertPath string // If not "", a directory containing "cert.pem" and "key.pem" used when talking to a Docker Registry