A large base could outlast a call's bound. The ask is kept in the state mirrors and the copy runs on that state's watch, so a copy a restart cut is taken on again; published (or refused, with why) on container-image-registry's events is the end the caller waits on, the place the seat's contract already gives mirror. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
303 lines
9.3 KiB
Go
303 lines
9.3 KiB
Go
package holder
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Upstream reads an outside registry by the OCI distribution protocol, anonymously: on a 401 it takes the bearer
|
|
// token the registry's WWW-Authenticate names, scoped to a pull of the one repository, and asks again (the form
|
|
// Docker Hub, ghcr.io and quay.io answer). Scheme is "https" but in tests.
|
|
type Upstream struct {
|
|
Client *http.Client
|
|
Scheme string
|
|
}
|
|
|
|
func (u *Upstream) client() *http.Client {
|
|
if u != nil && u.Client != nil {
|
|
return u.Client
|
|
}
|
|
return http.DefaultClient
|
|
}
|
|
|
|
func (u *Upstream) scheme() string {
|
|
if u != nil && u.Scheme != "" {
|
|
return u.Scheme
|
|
}
|
|
return "https"
|
|
}
|
|
|
|
// accepted are the manifest forms a mirror copies.
|
|
var accepted = []string{
|
|
"application/vnd.oci.image.index.v1+json", mediaManifest,
|
|
"application/vnd.docker.distribution.manifest.list.v2+json", "application/vnd.docker.distribution.manifest.v2+json",
|
|
}
|
|
|
|
// Source is an outside image's place: its registry host (as reached) and its repository path, from `<host>/<path>`
|
|
// with a tag or digest dropped. A name with no host is Docker Hub's, and a one-part path there is under library/.
|
|
type Source struct {
|
|
Host, Path string
|
|
}
|
|
|
|
// ParseSource reads an outside image's name.
|
|
func ParseSource(s string) (Source, error) {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.Index(s, "@"); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if i := strings.LastIndex(s, ":"); i > strings.LastIndex(s, "/") {
|
|
s = s[:i]
|
|
}
|
|
if s == "" || strings.Contains(s, "..") {
|
|
return Source{}, fmt.Errorf("mirror: %q is not an image's name", s)
|
|
}
|
|
host, path, ok := strings.Cut(s, "/")
|
|
if !ok || !(strings.ContainsAny(host, ".:") || host == "localhost") {
|
|
host, path = "docker.io", s
|
|
}
|
|
if host == "docker.io" && !strings.Contains(path, "/") {
|
|
path = "library/" + path
|
|
}
|
|
return Source{Host: host, Path: path}, nil
|
|
}
|
|
|
|
// Name is the copy's name in the store, `upstream/<host>/<path>` (design 51).
|
|
func (s Source) Name() string { return "upstream/" + s.Host + "/" + s.Path }
|
|
|
|
func (s Source) reach() string {
|
|
if s.Host == "docker.io" {
|
|
return "registry-1.docker.io"
|
|
}
|
|
return s.Host
|
|
}
|
|
|
|
// get asks the upstream for path, taking an anonymous token once when the upstream asks for one.
|
|
func (u *Upstream) get(src Source, path string, accept []string) (*http.Response, error) {
|
|
target := u.scheme() + "://" + src.reach() + "/v2/" + src.Path + path
|
|
token := ""
|
|
for try := 0; try < 2; try++ {
|
|
req, err := http.NewRequest(http.MethodGet, target, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, a := range accept {
|
|
req.Header.Add("Accept", a)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := u.client().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusUnauthorized || token != "" {
|
|
return resp, nil
|
|
}
|
|
challenge := resp.Header.Get("WWW-Authenticate")
|
|
resp.Body.Close()
|
|
if token, err = u.token(challenge, src); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("mirror: %s refused the anonymous token", src.reach())
|
|
}
|
|
|
|
// token takes an anonymous pull token from a Bearer challenge.
|
|
func (u *Upstream) token(challenge string, src Source) (string, error) {
|
|
rest, ok := strings.CutPrefix(challenge, "Bearer ")
|
|
if !ok {
|
|
return "", fmt.Errorf("mirror: %s asks a credential the mesh does not hold (%q); only anonymous reads are copied", src.reach(), challenge)
|
|
}
|
|
params := map[string]string{}
|
|
for _, kv := range strings.Split(rest, ",") {
|
|
k, v, _ := strings.Cut(strings.TrimSpace(kv), "=")
|
|
params[k] = strings.Trim(v, `"`)
|
|
}
|
|
realm, err := url.Parse(params["realm"])
|
|
if err != nil || params["realm"] == "" {
|
|
return "", fmt.Errorf("mirror: %s names no token realm", src.reach())
|
|
}
|
|
q := realm.Query()
|
|
if params["service"] != "" {
|
|
q.Set("service", params["service"])
|
|
}
|
|
q.Set("scope", "repository:"+src.Path+":pull")
|
|
realm.RawQuery = q.Encode()
|
|
resp, err := u.client().Get(realm.String())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
var t struct {
|
|
Token string `json:"token"`
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&t) != nil {
|
|
return "", fmt.Errorf("mirror: %s gave no anonymous token (%d)", realm.Host, resp.StatusCode)
|
|
}
|
|
if t.Token == "" {
|
|
t.Token = t.AccessToken
|
|
}
|
|
return t.Token, nil
|
|
}
|
|
|
|
// manifest reads one manifest by its digest and checks its bytes against it.
|
|
func (u *Upstream) manifest(src Source, digest string) ([]byte, string, error) {
|
|
resp, err := u.get(src, "/manifests/"+digest, accepted)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, "", fmt.Errorf("mirror: %s/%s@%s answered %d", src.Host, src.Path, digest, resp.StatusCode)
|
|
}
|
|
b, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if "sha256:"+sum(b) != digest {
|
|
return nil, "", fmt.Errorf("mirror: the manifest %s answered does not hash to its digest", digest)
|
|
}
|
|
mt := resp.Header.Get("Content-Type")
|
|
var probe struct {
|
|
MediaType string `json:"mediaType"`
|
|
}
|
|
if json.Unmarshal(b, &probe) == nil && probe.MediaType != "" {
|
|
mt = probe.MediaType
|
|
}
|
|
return b, mt, nil
|
|
}
|
|
|
|
// copyBlob streams one blob from the upstream into the store, its digest checked as it passes; a blob held is
|
|
// not fetched.
|
|
func (o *OCI) copyBlob(up *Upstream, src Source, name string, desc descriptor) error {
|
|
if has, err := o.Has(name, desc.Digest); err != nil {
|
|
return err
|
|
} else if has {
|
|
return nil
|
|
}
|
|
resp, err := up.get(src, "/blobs/"+desc.Digest, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("mirror: the blob %s answered %d", desc.Digest, resp.StatusCode)
|
|
}
|
|
h := sha256.New()
|
|
body := io.TeeReader(io.LimitReader(resp.Body, desc.Size+1), h)
|
|
if err := o.putBlobStream(name, desc.Digest, desc.Size, body); err != nil {
|
|
return err
|
|
}
|
|
if got := "sha256:" + hex.EncodeToString(h.Sum(nil)); got != desc.Digest {
|
|
return fmt.Errorf("mirror: the blob %s answered hashes to %s", desc.Digest, got)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// putBlobStream stores a blob of a known size from a reader, monolithically.
|
|
func (o *OCI) putBlobStream(name, digest string, size int64, body io.Reader) 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()
|
|
req, err := http.NewRequest(http.MethodPut, o.url(loc.Path+"?"+loc.RawQuery), body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.ContentLength = size
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
req.Header.Set("Content-Length", strconv.FormatInt(size, 10))
|
|
put, err := o.client().Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer put.Body.Close()
|
|
if put.StatusCode != http.StatusCreated {
|
|
b, _ := io.ReadAll(io.LimitReader(put.Body, 4096))
|
|
return fmt.Errorf("the store did not take %s (%d): %s", digest, put.StatusCode, b)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type descriptor struct {
|
|
MediaType string `json:"mediaType"`
|
|
Digest string `json:"digest"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// copyManifest copies one manifest and all it names: an index's every child first (the store refuses an index whose
|
|
// children it lacks), an image's config and layers; then the manifest itself under its digest.
|
|
func (o *OCI) copyManifest(up *Upstream, src Source, name, digest string, depth int) error {
|
|
if depth > 2 {
|
|
return fmt.Errorf("mirror: %s nests indexes deeper than an image does", digest)
|
|
}
|
|
if d, err := o.Digest(name, digest); err == nil && d == digest {
|
|
return nil // held whole already
|
|
}
|
|
b, mt, err := up.manifest(src, digest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var m struct {
|
|
Manifests []descriptor `json:"manifests"`
|
|
Config descriptor `json:"config"`
|
|
Layers []descriptor `json:"layers"`
|
|
}
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return fmt.Errorf("mirror: the manifest %s does not read: %w", digest, err)
|
|
}
|
|
for _, c := range m.Manifests {
|
|
if err := o.copyManifest(up, src, name, c.Digest, depth+1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if m.Config.Digest != "" {
|
|
if err := o.copyBlob(up, src, name, m.Config); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, l := range m.Layers {
|
|
if err := o.copyBlob(up, src, name, l); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = o.putManifest(name, digest, mt, b)
|
|
return err
|
|
}
|
|
|
|
// Mirror copies an outside image, pinned by its digest, into the store as `upstream/<host>/<path>` (design 51: an
|
|
// upstream image is copied by `mirror` with its pinned digest), every platform of an index included, only the parts
|
|
// the store lacks; it answers the copy's reference. A copy held already is answered at once.
|
|
func (o *OCI) Mirror(source, digest string) (string, error) {
|
|
src, err := ParseSource(source)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
name := src.Name()
|
|
if err := o.copyManifest(o.Upstream, src, name, digest, 0); err != nil {
|
|
return "", err
|
|
}
|
|
return o.Reference(name, digest), nil
|
|
}
|
|
|
|
// Reference is the seat's answer for name at digest: `<registry>/<name>@<digest>`.
|
|
func (o *OCI) Reference(name, digest string) string { return o.Registry + "/" + name + "@" + digest }
|