Files
mesh-catalog/modules/distribution/internal/holder/transfer.go
T
jschoubben 9ad22ad1e1 CAT-D1..D5 (holder core): distribution's holding of the two image seats, its caller list, incoming, the store's front and the two windows
The holder answers publish and publish-part into incoming (parts of at most 1 MiB, resumed from what is held,
hashed, only the missing layers sent, 20 GiB with its 80 % finding and a refusal at the cap, a transfer idle 24
hours dropped), asks builder_versions before it stores, and says published or refused (design 51 *Publishing*,
ADR 0605 §4, ADR 0611). versions, remove (a tagged version; untagged only for the sweep's retention, which a
hold keeps), hold and release (ADR 0617 §2: the sweep alone; the operator's session by hand; never
distribution's own account), tags and mirror. The caller list is a trusted config value checked against the
caller the tool runner proved. The store's port is the program's: reads open, writes refused but while the
direct-write window is open (never over a tag published through the seat) or with the bootstrap's one-off
credential. The old-name and direct-write windows read every node, away ones included, close once, and name a
node holding them seven days.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:52:37 +01:00

449 lines
16 KiB
Go

package holder
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
)
// The bounds of incoming (design 51 *Publishing*, ADR 0611): a part is at most 1 MiB, the store 20 GiB, a transfer
// with no part for 24 hours is dropped, and past 80 % of the cap the holder writes a finding.
const (
MaxPart = 1 << 20
IncomingCap = 20 << 30
TransferIdle = 24 * time.Hour
IncomingFullPct = 80
// IncomingObjects and TransfersState are the object store and the state distribution asks as bus state.
IncomingObjects = "incoming"
TransfersState = "transfers"
)
// The kinds a seat holds.
const (
KindImage = "image"
KindBundle = "bundle"
KindArchive = "archive"
KindUpstream = "upstream"
)
// Part is one file of a publish: an image's layer, config or manifest, a bundle's or an archive's blob, by its digest.
type Part struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
// MediaType says what the part is to the store; an image's manifest names its own.
MediaType string `json:"media_type,omitempty"`
}
// PublishRequest is the body of `publish` (ADR 0611 §1).
type PublishRequest struct {
Transfer string `json:"transfer"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
Kind string `json:"kind"`
Fingerprint string `json:"fingerprint,omitempty"`
Commit string `json:"commit,omitempty"`
// Digest is the digest the build recorded: an image's manifest, a bundle's or an archive's blob.
Digest string `json:"digest"`
Parts []Part `json:"parts"`
}
// PublishAnswer is what `publish` answers: the parts the holder lacks, which the build module sends; none when it
// holds them all and the publish ended (Published names how).
type PublishAnswer struct {
Transfer string `json:"transfer"`
Missing []string `json:"missing"`
// Ended says the publish ended at once: everything was held, `published` or `refused` is said.
Ended bool `json:"ended,omitempty"`
}
// PartRequest is the body of `publish-part` (ADR 0611 §2): one chunk of one part.
type PartRequest struct {
Transfer string `json:"transfer"`
Digest string `json:"digest"`
Sequence int `json:"sequence"`
Last bool `json:"last"`
Data []byte `json:"data"`
}
// Transfer is one open publish, kept in the state transfers; its chunks are objects of incoming.
type Transfer struct {
PublishRequest
Seat string `json:"seat"`
Opener string `json:"opener"`
// Held are the parts whose every chunk arrived and hashed right; Chunks the chunks heard per part.
Held []string `json:"held,omitempty"`
Chunks map[string]int `json:"chunks,omitempty"`
Opened time.Time `json:"opened"`
Last time.Time `json:"last"`
}
func (t Transfer) missing() []string {
var out []string
for _, p := range t.Parts {
if !slices.Contains(t.Held, p.Digest) && !slices.Contains(out, p.Digest) {
out = append(out, p.Digest)
}
}
sort.Strings(out)
return out
}
// Event is the body of `published`, `refused` and `removed` on `<seat>.events` (design 51 *One contract*): the name,
// version, kind, tag, digest, fingerprint, commit and holder; `refused` its reason, `removed` its reason too.
type Event struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Kind string `json:"kind"`
Tag string `json:"tag,omitempty"`
Digest string `json:"digest"`
Fingerprint string `json:"fingerprint,omitempty"`
Commit string `json:"commit,omitempty"`
Holder string `json:"holder"`
Transfer string `json:"transfer,omitempty"`
// Reference is the seat's answer: `<registry>/<module>/<artifact>@sha256:<digest>` (ADR 0605 §9).
Reference string `json:"reference,omitempty"`
Reason string `json:"reason,omitempty"`
// By is who asked a removal, hold or release: the caller the tool runner proved.
By string `json:"by,omitempty"`
}
// The events of the seats.
const (
EventPublished = "published"
EventRefused = "refused"
EventRemoved = "removed"
EventHeld = "held"
EventReleased = "released"
)
// Objects is distribution's own object store incoming.
type Objects interface {
Put(name string, data []byte) error
Get(name string) ([]byte, error)
Delete(name string) error
// Used is the bytes the store holds.
Used() (uint64, error)
}
// TransferState is the state transfers.
type TransferState interface {
Get(id string) (Transfer, bool, error)
Put(t Transfer) error
Delete(id string) error
List() ([]Transfer, error)
}
// Builder is the builder's tool builder_versions, asked before anything is stored (ADR 0605 §4).
type Builder interface {
Recorded(name, version, digest string) (ok bool, why string, err error)
}
// Store is the artifact store behind the loopback, written by distribution's own process alone.
type Store interface {
// Has says whether the store holds a blob of name.
Has(name, digest string) (bool, error)
// Put stores a transfer's parts under its name and version and answers the reference.
Put(t Transfer, parts map[string][]byte) (reference string, err error)
// Held is the reference of name at version when the store already holds digest under it: a rebuild of a held
// fingerprint answers it and stores nothing.
Held(name, version, digest string) (reference string, ok bool, err error)
}
// Say says an event on a seat's family `<seat>.events`.
type Say func(seat, event string, body Event) error
// Finding writes (raised true) or clears distribution's finding of a kind for a subject.
type Finding func(kind, subject, words string, raised bool)
// Holder answers the verbs.
type Holder struct {
Callers Callers
Objects Objects
Transfers TransferState
Builder Builder
Store Store
Say Say
Finding Finding
Now func() time.Time
// Cap is incoming's cap; IncomingCap when zero.
Cap uint64
}
func (h *Holder) now() time.Time {
if h.Now != nil {
return h.Now().UTC()
}
return time.Now().UTC()
}
func (h *Holder) cap() uint64 {
if h.Cap > 0 {
return h.Cap
}
return IncomingCap
}
// Publish is `publish` on seat for the proved caller.
func (h *Holder) Publish(seat, caller string, r PublishRequest) (PublishAnswer, error) {
if err := h.Callers.Allowed(VerbPublish, caller); err != nil {
return PublishAnswer{}, err
}
if err := checkPublish(seat, r); err != nil {
return PublishAnswer{}, err
}
if t, found, err := h.Transfers.Get(r.Transfer); err != nil {
return PublishAnswer{}, err
} else if found {
// A publish opening a transfer already open answers the parts held: a cut transfer goes on from there.
if t.Opener != caller {
return PublishAnswer{}, fmt.Errorf("publish: refused for %s: transfer %s was opened by another caller", caller, r.Transfer)
}
return PublishAnswer{Transfer: t.Transfer, Missing: t.missing()}, nil
}
// A rebuild of a held fingerprint answers the held version and stores nothing.
if r.Version != "" {
if ref, ok, err := h.Store.Held(r.Name, r.Version, r.Digest); err != nil {
return PublishAnswer{}, fmt.Errorf("publish: the store cannot say whether it holds %s@%s: %w", r.Name, r.Version, err)
} else if ok {
return PublishAnswer{Transfer: r.Transfer, Ended: true}, h.Say(seat, EventPublished, h.eventOf(seat, r, ref, ""))
}
}
used, err := h.Objects.Used()
if err != nil {
return PublishAnswer{}, fmt.Errorf("publish: incoming's fill cannot be read: %w", err)
}
var size uint64
for _, p := range r.Parts {
size += uint64(max(p.Size, 0))
}
if used+size > h.cap() {
reason := fmt.Sprintf("incoming is at its cap of %d bytes (%d held, %d asked)", h.cap(), used, size)
_ = h.Say(seat, EventRefused, h.eventOf(seat, r, "", reason))
return PublishAnswer{Transfer: r.Transfer, Ended: true}, errors.New("publish: refused: " + reason)
}
t := Transfer{PublishRequest: r, Seat: seat, Opener: caller, Opened: h.now(), Last: h.now(), Chunks: map[string]int{}}
// Only the missing layers are sent: a part the store already holds under the name counts as held.
for _, p := range r.Parts {
if ok, err := h.Store.Has(r.Name, p.Digest); err == nil && ok {
t.Held = append(t.Held, p.Digest)
}
}
if err := h.Transfers.Put(t); err != nil {
return PublishAnswer{}, fmt.Errorf("publish: transfer %s cannot be kept: %w", r.Transfer, err)
}
h.fill()
if len(t.missing()) == 0 {
return PublishAnswer{Transfer: r.Transfer, Ended: true}, h.finish(t)
}
return PublishAnswer{Transfer: r.Transfer, Missing: t.missing()}, nil
}
// PublishPart is `publish-part`: one chunk, from the caller that opened the transfer alone.
func (h *Holder) PublishPart(seat, caller string, r PartRequest) (PublishAnswer, error) {
if err := h.Callers.Allowed(VerbPublishPart, caller); err != nil {
return PublishAnswer{}, err
}
t, found, err := h.Transfers.Get(r.Transfer)
switch {
case err != nil:
return PublishAnswer{}, err
case !found:
return PublishAnswer{}, fmt.Errorf("publish-part: transfer %s is not open (dropped after %s with no part, or never opened by publish)", r.Transfer, TransferIdle)
case t.Opener != caller:
return PublishAnswer{}, fmt.Errorf("publish-part: refused for %s: transfer %s was opened by another caller", caller, r.Transfer)
case t.Seat != seat:
return PublishAnswer{}, fmt.Errorf("publish-part: transfer %s is %s's, not %s's", r.Transfer, t.Seat, seat)
case len(r.Data) > MaxPart:
return PublishAnswer{}, fmt.Errorf("publish-part: a chunk of %d bytes is over the %d a part may carry", len(r.Data), MaxPart)
case !slices.ContainsFunc(t.Parts, func(p Part) bool { return p.Digest == r.Digest }):
return PublishAnswer{}, fmt.Errorf("publish-part: transfer %s names no part %s", r.Transfer, r.Digest)
case slices.Contains(t.Held, r.Digest):
return PublishAnswer{Transfer: t.Transfer, Missing: t.missing()}, nil
case r.Sequence != t.Chunks[r.Digest]:
return PublishAnswer{}, fmt.Errorf("publish-part: part %s of transfer %s waits for chunk %d, not %d", r.Digest, r.Transfer, t.Chunks[r.Digest], r.Sequence)
}
if err := h.Objects.Put(chunkName(t.Transfer, r.Digest, r.Sequence), r.Data); err != nil {
return PublishAnswer{}, fmt.Errorf("publish-part: incoming did not take the chunk: %w", err)
}
t.Chunks[r.Digest] = r.Sequence + 1
t.Last = h.now()
if r.Last {
whole, err := h.part(t, r.Digest)
if err != nil {
return PublishAnswer{}, err
}
if got := "sha256:" + sum(whole); got != r.Digest {
h.dropChunks(t, r.Digest)
delete(t.Chunks, r.Digest)
_ = h.Transfers.Put(t)
return PublishAnswer{}, fmt.Errorf("publish-part: refused: the bytes of part %s hash to %s; send the part again", r.Digest, got)
}
t.Held = append(t.Held, r.Digest)
}
if err := h.Transfers.Put(t); err != nil {
return PublishAnswer{}, err
}
h.fill()
if len(t.missing()) == 0 {
return PublishAnswer{Transfer: t.Transfer, Ended: true}, h.finish(t)
}
return PublishAnswer{Transfer: t.Transfer, Missing: t.missing()}, nil
}
// finish asks the builder and stores, and says published or refused; the transfer's chunks go either way.
func (h *Holder) finish(t Transfer) error {
defer func() {
for _, p := range t.Parts {
h.dropChunks(t, p.Digest)
}
_ = h.Transfers.Delete(t.Transfer)
h.fill()
}()
refuse := func(reason string) error {
h.Finding("publish-refused", t.Name+"@"+t.Version, reason, true)
if err := h.Say(t.Seat, EventRefused, h.eventOf(t.Seat, t.PublishRequest, "", reason)); err != nil {
return fmt.Errorf("%s; and refused could not be said: %w", reason, err)
}
return errors.New("publish: refused: " + reason)
}
if t.Version != "" {
ok, why, err := h.Builder.Recorded(t.Name, t.Version, t.Digest)
if err != nil {
return refuse("builder_versions did not answer, so nothing is stored: " + err.Error())
}
if !ok {
return refuse("the builder did not number it: " + why)
}
}
parts := map[string][]byte{}
for _, p := range t.Parts {
if _, had := t.Chunks[p.Digest]; !had {
continue // held by the store already
}
b, err := h.part(t, p.Digest)
if err != nil {
return refuse("part " + p.Digest + " cannot be read from incoming: " + err.Error())
}
parts[p.Digest] = b
}
ref, err := h.Store.Put(t, parts)
if err != nil {
return refuse("the store did not take it: " + err.Error())
}
h.Finding("publish-refused", t.Name+"@"+t.Version, "", false)
return h.Say(t.Seat, EventPublished, h.eventOf(t.Seat, t.PublishRequest, ref, ""))
}
func (h *Holder) eventOf(seat string, r PublishRequest, ref, reason string) Event {
return Event{Name: r.Name, Version: r.Version, Kind: r.Kind, Tag: r.Tag, Digest: r.Digest, Fingerprint: r.Fingerprint,
Commit: r.Commit, Holder: Self, Transfer: r.Transfer, Reference: ref, Reason: reason}
}
func (h *Holder) part(t Transfer, digest string) ([]byte, error) {
var buf bytes.Buffer
for seq := 0; seq < t.Chunks[digest]; seq++ {
b, err := h.Objects.Get(chunkName(t.Transfer, digest, seq))
if err != nil {
return nil, err
}
buf.Write(b)
}
return buf.Bytes(), nil
}
func (h *Holder) dropChunks(t Transfer, digest string) {
for seq := 0; seq < t.Chunks[digest]; seq++ {
_ = h.Objects.Delete(chunkName(t.Transfer, digest, seq))
}
}
// fill writes incoming's finding at 80 % of the cap and clears it below.
func (h *Holder) fill() {
used, err := h.Objects.Used()
if err != nil {
return
}
full := used*100 >= h.cap()*IncomingFullPct
h.Finding("incoming-full", IncomingObjects, fmt.Sprintf("incoming holds %d of its %d bytes (%d %%); registry_incoming lists and drops transfers", used, h.cap(), used*100/h.cap()), full)
}
// Drop drops a transfer by hand (registry_incoming) or because it was idle for TransferIdle: its chunks and its key.
func (h *Holder) Drop(id string) (Transfer, error) {
t, found, err := h.Transfers.Get(id)
if err != nil {
return Transfer{}, err
}
if !found {
return Transfer{}, fmt.Errorf("transfer %s is not open", id)
}
for _, p := range t.Parts {
h.dropChunks(t, p.Digest)
}
if err := h.Transfers.Delete(id); err != nil {
return Transfer{}, err
}
h.fill()
return t, nil
}
// DropIdle drops every transfer with no part for TransferIdle, answering them; it runs on each publish's open and each
// registry_incoming call, never on a timer (the object store's own lifetime of 24 hours drops the bytes too).
func (h *Holder) DropIdle() ([]string, error) {
all, err := h.Transfers.List()
if err != nil {
return nil, err
}
var out []string
for _, t := range all {
if h.now().Sub(t.Last) >= TransferIdle {
if _, err := h.Drop(t.Transfer); err == nil {
out = append(out, t.Transfer)
}
}
}
return out, nil
}
func chunkName(transfer, digest string, seq int) string {
return fmt.Sprintf("%s/%s/%d", transfer, strings.TrimPrefix(digest, "sha256:"), seq)
}
func sum(b []byte) string {
s := sha256.Sum256(b)
return hex.EncodeToString(s[:])
}
func checkPublish(seat string, r PublishRequest) error {
switch {
case !slices.Contains(Seats, seat):
return fmt.Errorf("publish: distribution holds no seat %s", seat)
case strings.TrimSpace(r.Transfer) == "" || strings.ContainsAny(r.Transfer, "/. *>"):
return fmt.Errorf("publish: transfer %q is no transfer id", r.Transfer)
case strings.TrimSpace(r.Name) == "":
return errors.New("publish: no name")
case !strings.HasPrefix(r.Digest, "sha256:"):
return fmt.Errorf("publish: digest %q is no sha256 digest", r.Digest)
case len(r.Parts) == 0:
return errors.New("publish: no parts")
}
switch {
case seat == ImageSeat && r.Kind != KindImage && r.Kind != KindUpstream:
return fmt.Errorf("publish: %s holds images, not a %s", seat, r.Kind)
case seat == ArtifactSeat && r.Kind != KindBundle && r.Kind != KindArchive:
return fmt.Errorf("publish: %s holds bundles and archives, not a %s", seat, r.Kind)
}
for _, p := range r.Parts {
if !strings.HasPrefix(p.Digest, "sha256:") || len(p.Digest) != len("sha256:")+64 {
return fmt.Errorf("publish: part digest %q is no sha256 digest", p.Digest)
}
}
return nil
}