The store's pinned image is registry 2.8.3, whose DeleteManifest cannot delete a tag alone: by a tag it deletes the empty digest and answers 400 DIGEST_INVALID. The fakes accepted a DELETE by tag, a fixture agreeing with the mistake, so every merge of a reproducible build would have been a refused remove and a retry finding live. RemoveTag now answers ErrNoTagAlone for that answer; remove then keeps the tag, whose bytes stay under the other tag anyway, and says removed with that reason, so the builder lets it go. Any other store failure is still refused; a digest shared with nothing is still deleted by digest. The fakes answer as 2.8.3 does. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
299 lines
9.8 KiB
Go
299 lines
9.8 KiB
Go
package holder
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// OCI is the artifact store as the OCI distribution protocol answers it on its node's loopback, written by
|
|
// distribution's own process alone: the Store and the Catalog the verbs use. It answers a reference's path,
|
|
// `<name>@sha256:<digest>`; the holder puts the seat's name before it (SeatReference, ADR 0605 §9).
|
|
type OCI struct {
|
|
Base *url.URL
|
|
Client *http.Client
|
|
// Upstream reads the outside registries `mirror` copies from.
|
|
Upstream *Upstream
|
|
}
|
|
|
|
const (
|
|
mediaManifest = "application/vnd.oci.image.manifest.v1+json"
|
|
mediaEmptyConfig = "application/vnd.oci.empty.v1+json"
|
|
mediaLayer = "application/vnd.oci.image.layer.v1.tar"
|
|
emptyConfig = "{}"
|
|
)
|
|
|
|
func (o *OCI) client() *http.Client {
|
|
if o.Client != nil {
|
|
return o.Client
|
|
}
|
|
return http.DefaultClient
|
|
}
|
|
|
|
func (o *OCI) url(path string) string { return strings.TrimSuffix(o.Base.String(), "/") + path }
|
|
|
|
func (o *OCI) do(method, path, contentType string, body []byte, accept ...string) (*http.Response, []byte, error) {
|
|
req, err := http.NewRequest(method, o.url(path), bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
for _, a := range accept {
|
|
req.Header.Add("Accept", a)
|
|
}
|
|
resp, err := o.client().Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
|
return resp, b, nil
|
|
}
|
|
|
|
// Has is whether name holds a blob.
|
|
func (o *OCI) Has(name, digest string) (bool, error) {
|
|
resp, _, err := o.do(http.MethodHead, "/v2/"+name+"/blobs/"+digest, "", nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return resp.StatusCode == http.StatusOK, nil
|
|
}
|
|
|
|
func (o *OCI) putBlob(name, digest string, data []byte) error {
|
|
resp, b, err := o.do(http.MethodPost, "/v2/"+name+"/blobs/uploads/", "", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusAccepted {
|
|
return fmt.Errorf("the store did not open an upload of %s (%d): %s", digest, resp.StatusCode, b)
|
|
}
|
|
loc, err := url.Parse(resp.Header.Get("Location"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
q := loc.Query()
|
|
q.Set("digest", digest)
|
|
loc.RawQuery = q.Encode()
|
|
path := loc.Path
|
|
if loc.RawQuery != "" {
|
|
path += "?" + loc.RawQuery
|
|
}
|
|
resp, b, err = o.do(http.MethodPut, path, "application/octet-stream", data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return fmt.Errorf("the store did not take %s (%d): %s", digest, resp.StatusCode, b)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *OCI) putManifest(name, ref, mediaType string, data []byte) (string, error) {
|
|
resp, b, err := o.do(http.MethodPut, "/v2/"+name+"/manifests/"+ref, mediaType, data)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return "", fmt.Errorf("the store did not take the manifest %s:%s (%d): %s", name, ref, resp.StatusCode, b)
|
|
}
|
|
return "sha256:" + sum(data), nil
|
|
}
|
|
|
|
// Put stores a transfer: an image's layers and config as blobs and its manifest (the part whose media type is a
|
|
// manifest's) under its version; a bundle's or an archive's one blob under an artifact manifest of an empty config and
|
|
// one layer (the shape the artifact sweep recognises), under its version, or by its digest alone when unnumbered.
|
|
func (o *OCI) Put(t PublishRequest, parts map[string][]byte) (string, error) {
|
|
var manifest *Part
|
|
digests := make([]string, 0, len(parts))
|
|
for d := range parts {
|
|
digests = append(digests, d)
|
|
}
|
|
sort.Strings(digests)
|
|
for i, p := range t.Parts {
|
|
// An image's manifest is the part whose digest is the publish's (the seat's contract names no media type); a
|
|
// part naming a manifest's media type is one too.
|
|
if (t.Kind == KindImage || t.Kind == KindUpstream) && (p.Digest == t.Digest || strings.Contains(p.MediaType, "manifest")) {
|
|
manifest = &t.Parts[i]
|
|
continue
|
|
}
|
|
if data, ok := parts[p.Digest]; ok {
|
|
if err := o.putBlob(t.Name, p.Digest, data); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
}
|
|
ref := t.Version
|
|
switch t.Kind {
|
|
case KindImage, KindUpstream:
|
|
if manifest == nil {
|
|
return "", fmt.Errorf("an image's publish names no manifest part")
|
|
}
|
|
data, ok := parts[manifest.Digest]
|
|
if !ok {
|
|
// Held already: fetch it to tag it under the version.
|
|
resp, b, err := o.do(http.MethodGet, "/v2/"+t.Name+"/manifests/"+manifest.Digest, "", nil, accepted...)
|
|
if err != nil || resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("the held manifest %s cannot be read: %v", manifest.Digest, err)
|
|
}
|
|
data = b
|
|
}
|
|
if ref == "" {
|
|
ref = manifest.Digest
|
|
}
|
|
mt := manifest.MediaType
|
|
var probe struct {
|
|
MediaType string `json:"mediaType"`
|
|
}
|
|
if mt == "" && json.Unmarshal(data, &probe) == nil {
|
|
mt = probe.MediaType
|
|
}
|
|
if mt == "" {
|
|
mt = mediaManifest
|
|
}
|
|
d, err := o.putManifest(t.Name, ref, mt, data)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return t.Name + "@" + d, nil
|
|
default:
|
|
if err := o.putBlob(t.Name, "sha256:"+sum([]byte(emptyConfig)), []byte(emptyConfig)); err != nil {
|
|
return "", err
|
|
}
|
|
size, err := o.layerSize(t, parts)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
m, _ := json.Marshal(map[string]any{
|
|
"schemaVersion": 2, "mediaType": mediaManifest,
|
|
"config": map[string]any{"mediaType": mediaEmptyConfig, "digest": "sha256:" + sum([]byte(emptyConfig)), "size": len(emptyConfig)},
|
|
"layers": []map[string]any{{"mediaType": mediaLayer, "digest": t.Digest, "size": size}},
|
|
})
|
|
if ref == "" {
|
|
ref = "sha256:" + sum(m)
|
|
}
|
|
if _, err := o.putManifest(t.Name, ref, mediaManifest, m); err != nil {
|
|
return "", err
|
|
}
|
|
return t.Name + "@" + t.Digest, nil
|
|
}
|
|
}
|
|
|
|
// layerSize is the size of a bundle's or an archive's one blob: its bytes' when they travelled, else the size its part
|
|
// names, else (a late publish of a held digest names it with no size, its bytes having left the build node) the store's.
|
|
func (o *OCI) layerSize(t PublishRequest, parts map[string][]byte) (int64, error) {
|
|
if data, ok := parts[t.Digest]; ok {
|
|
return int64(len(data)), nil
|
|
}
|
|
for _, p := range t.Parts {
|
|
if p.Digest == t.Digest && p.Size > 0 {
|
|
return p.Size, nil
|
|
}
|
|
}
|
|
resp, _, err := o.do(http.MethodHead, "/v2/"+t.Name+"/blobs/"+t.Digest, "", nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK || resp.ContentLength < 0 {
|
|
return 0, fmt.Errorf("the store holds no blob %s of %s to tag (%d)", t.Digest, t.Name, resp.StatusCode)
|
|
}
|
|
return resp.ContentLength, nil
|
|
}
|
|
|
|
// Held is the reference of name at version when the store holds digest under it.
|
|
func (o *OCI) Held(name, version, digest string) (string, bool, error) {
|
|
d, err := o.Digest(name, version)
|
|
if err != nil || d == "" {
|
|
return "", false, err
|
|
}
|
|
if d != digest {
|
|
return "", false, nil
|
|
}
|
|
return name + "@" + d, true, nil
|
|
}
|
|
|
|
// Versions are name's tags.
|
|
func (o *OCI) Versions(name string) ([]string, error) {
|
|
resp, b, err := o.do(http.MethodGet, "/v2/"+name+"/tags/list", "", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return []string{}, nil
|
|
}
|
|
var out struct {
|
|
Tags []string `json:"tags"`
|
|
}
|
|
if resp.StatusCode != http.StatusOK || json.Unmarshal(b, &out) != nil {
|
|
return nil, fmt.Errorf("the store's tags of %s (%d): %s", name, resp.StatusCode, b)
|
|
}
|
|
sort.Strings(out.Tags)
|
|
return out.Tags, nil
|
|
}
|
|
|
|
// Digest is the manifest digest name holds at version, empty when none.
|
|
func (o *OCI) Digest(name, version string) (string, error) {
|
|
resp, _, err := o.do(http.MethodHead, "/v2/"+name+"/manifests/"+version, "", nil, mediaManifest,
|
|
"application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.v2+json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return "", nil
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("the store answered %d for %s:%s", resp.StatusCode, name, version)
|
|
}
|
|
return resp.Header.Get("Docker-Content-Digest"), nil
|
|
}
|
|
|
|
// ErrNoTagAlone is a store that cannot delete a tag alone. registry 2.8.3, the store's pinned image, is one: its
|
|
// DeleteManifest (distribution v2.8.3 registry/handlers/manifests.go) ignores a tag reference and deletes the empty
|
|
// digest, whose check fails, answered 400 DIGEST_INVALID; registry 3 untags alone.
|
|
var ErrNoTagAlone = errors.New("the store cannot remove a tag alone")
|
|
|
|
// RemoveTag deletes the tag version of name alone (the OCI distribution protocol's DELETE of a manifest by its tag);
|
|
// the manifest stays under every other tag. A store that cannot delete a tag answers ErrNoTagAlone, any other failure
|
|
// an error of its own; either removes nothing.
|
|
func (o *OCI) RemoveTag(name, version string) error {
|
|
resp, b, err := o.do(http.MethodDelete, "/v2/"+name+"/manifests/"+version, "", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode == http.StatusBadRequest && bytes.Contains(b, []byte(`"DIGEST_INVALID"`)) {
|
|
return fmt.Errorf("%w: DELETE of %s:%s by its tag answered 400 DIGEST_INVALID", ErrNoTagAlone, name, version)
|
|
}
|
|
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNotFound {
|
|
return fmt.Errorf("the store did not delete the tag %s:%s alone (%d): %s", name, version, resp.StatusCode, b)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Remove deletes the manifest name holds at version, by its digest: every tag naming it goes with it.
|
|
func (o *OCI) Remove(name, version string) error {
|
|
d, err := o.Digest(name, version)
|
|
if err != nil || d == "" {
|
|
return err
|
|
}
|
|
return o.RemoveDigest(name, d)
|
|
}
|
|
|
|
// RemoveDigest deletes a manifest by its digest; the store's nightly collector frees its blobs.
|
|
func (o *OCI) RemoveDigest(name, digest string) error {
|
|
resp, b, err := o.do(http.MethodDelete, "/v2/"+name+"/manifests/"+digest, "", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNotFound {
|
|
return fmt.Errorf("the store did not delete %s@%s (%d): %s", name, digest, resp.StatusCode, b)
|
|
}
|
|
return nil
|
|
}
|