Files
mesh-catalog/modules/distribution/internal/holder/verbs.go
T
jschoubben 4438045b8c A release of nothing held answers released false; the sweep names retention from the seat
distribution answers released false for what it does not hold, as the
registry seats now define, and takes the removal reason from the
generated constant; the sweep reads the answer instead of matching
the error's text.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:33:14 +01:00

328 lines
13 KiB
Go

package holder
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"time"
reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
)
// Catalog is what the store answers of what it holds.
type Catalog interface {
// Versions are the versions (tags) of name, sorted.
Versions(name string) ([]string, error)
// Digest is the digest name holds at version, empty when none.
Digest(name, version string) (string, error)
// Remove deletes name at version (its manifest); the store's collector frees its blobs.
Remove(name, version string) error
// RemoveDigest deletes an untagged manifest of name by its digest (retention).
RemoveDigest(name, digest string) error
// Platforms are the manifests an index of name names; none for a manifest that is not an index or is not held.
Platforms(name, digest string) ([]string, error)
// Mirror copies an outside image, pinned by digest, into upstream/<host>/<path>, answering the reference.
Mirror(source, digest string) (string, error)
// Reference is the seat's answer for name at digest, known before anything is copied.
Reference(name, digest string) string
}
// MirrorsState names the state of the copies `mirror` took on.
const MirrorsState = "mirrors"
// Mirrors is the state mirrors: each copy `mirror` took on, kept until its outcome is said, so a copy cut by a
// restart is taken on again from the state's watch.
type Mirrors interface {
// Ask keeps r under its key; an ask already kept is no fault (the copy is taken on once).
Ask(key string, r MirrorRequest) error
// Claim writes r (its Claim set) under key at revision, compare-and-set: false, and no error, when another
// writer wrote the key first.
Claim(key string, r MirrorRequest, revision uint64) (bool, error)
Delete(key string) error
}
// HoldState is distribution's own state holds (bus state, template state; ADR 0617 §2).
type HoldState interface {
Get(key string) (Hold, bool, error)
Put(h Hold) error
Delete(key string) error
List() ([]Hold, error)
}
// Hold keeps one untagged bundle, archive or copied base from retention's removal.
type Hold struct {
Seat string `json:"seat"`
Name string `json:"name"`
Kind string `json:"kind"`
Digest string `json:"digest"`
By string `json:"by"`
Why string `json:"why,omitempty"`
Since time.Time `json:"since"`
}
// Key is the hold's key in the state.
func (h Hold) Key() string {
return h.Seat + "." + strings.ReplaceAll(strings.ReplaceAll(h.Name, ".", "="), "@", "=") + "." + strings.TrimPrefix(h.Digest, "sha256:")
}
// Verbs answers the verbs other than the publish: versions, remove, hold, release, tags and mirror.
type Verbs struct {
*Holder
Catalog Catalog
Holds HoldState
Mirrors Mirrors
}
// VersionsAnswer is `versions`: the versions the seat holds of name.
type VersionsAnswer struct {
Seat string `json:"seat"`
Name string `json:"name"`
Versions []string `json:"versions"`
}
// Versions is `versions`.
func (v *Verbs) Versions(seat, caller, name string) (VersionsAnswer, error) {
if err := v.Callers.Allowed(VerbVersions, caller); err != nil {
return VersionsAnswer{}, err
}
if strings.TrimSpace(name) == "" {
return VersionsAnswer{}, errors.New("versions: no name")
}
got, err := v.Catalog.Versions(name)
if err != nil {
return VersionsAnswer{}, fmt.Errorf("versions: the store cannot list %s: %w", name, err)
}
if got == nil {
got = []string{}
}
return VersionsAnswer{Seat: seat, Name: name, Versions: got}, nil
}
// RemoveRequest is `remove`: a tagged version, or with the reason retention an untagged one by its digest.
type RemoveRequest struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Digest string `json:"digest,omitempty"`
Kind string `json:"kind,omitempty"`
Reason string `json:"reason"`
}
// ReasonRetention is the reason of the sweep's removal of an untagged version (ADR 0610 §3).
// The seat definitions name it (the removal reasons' enum), so the sweep and the holder share one value.
const ReasonRetention = reg.RemoveInputReasonRetention
// Remove is `remove`: the builder removes a tagged version; the artifact sweep's holder an untagged bundle, archive or
// copied base with the reason retention, which a hold keeps. Every removal says `removed` with its reason.
func (v *Verbs) Remove(seat, caller string, r RemoveRequest) error {
if err := v.Callers.Allowed(VerbRemove, caller); err != nil {
return err
}
if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.Reason) == "" {
return errors.New("remove: a name and a reason are required")
}
ev := Event{Name: r.Name, Version: r.Version, Kind: r.Kind, Digest: r.Digest, Holder: Self, Reason: r.Reason, By: caller}
switch {
case r.Reason == ReasonRetention:
if CallerModule(caller) != SweepHolder {
return fmt.Errorf("remove: refused for %s: a removal for retention is the artifact sweep's alone", caller)
}
if r.Digest == "" {
return errors.New("remove: a removal for retention names the digest")
}
if _, held, err := v.Holds.Get(Hold{Seat: seat, Name: r.Name, Digest: r.Digest}.Key()); err != nil {
return fmt.Errorf("remove: the holds cannot be read, so nothing is removed: %w", err)
} else if held {
return fmt.Errorf("remove: refused: %s@%s is held; release it first", r.Name, r.Digest)
}
if err := v.removeForRetention(seat, r.Name, r.Digest); err != nil {
return err
}
default:
if r.Version == "" || !strings.Contains(r.Version, "-") {
return fmt.Errorf("remove: refused: %s@%s has no tag; only a tagged version is removed, but for retention", r.Name, r.Version)
}
d, err := v.Catalog.Digest(r.Name, r.Version)
if err != nil {
return fmt.Errorf("remove: the store cannot read %s@%s: %w", r.Name, r.Version, err)
}
if d != "" {
if err := v.Catalog.Remove(r.Name, r.Version); err != nil {
return fmt.Errorf("remove: the store did not remove %s@%s: %w", r.Name, r.Version, err)
}
}
ev.Digest = d // a version already gone is said removed all the same, so the builder's retry ends
}
return v.Say(seat, EventRemoved, ev)
}
// HoldRequest is `hold` and `release`.
type HoldRequest struct {
Name string `json:"name"`
Kind string `json:"kind"`
Digest string `json:"digest"`
Why string `json:"why,omitempty"`
}
// HoldOne is `hold`: the artifact sweep's holder alone; an image is never held (its own rules keep images).
func (v *Verbs) HoldOne(seat, caller string, r HoldRequest) error {
if err := v.Callers.Allowed(VerbHold, caller); err != nil {
return err
}
// The seat's contract names no kind: on the image seat a hold is of a copied base (upstream/…), never an image of
// its own; on the artifact seat of a bundle or an archive.
if r.Kind == "" && seat == ImageSeat && strings.HasPrefix(r.Name, "upstream/") {
r.Kind = KindUpstream
}
switch {
case r.Kind == "" && seat == ArtifactSeat:
case r.Kind == KindBundle, r.Kind == KindArchive, r.Kind == KindUpstream:
default:
return fmt.Errorf("hold: refused: a %s is never held; only an untagged bundle, archive or copied base", r.Kind)
}
if r.Name == "" || !strings.HasPrefix(r.Digest, "sha256:") {
return errors.New("hold: a name and a sha256 digest are required")
}
h := Hold{Seat: seat, Name: r.Name, Kind: r.Kind, Digest: r.Digest, By: caller, Why: r.Why, Since: v.now()}
if err := v.Holds.Put(h); err != nil {
return fmt.Errorf("hold: the holds did not take it: %w", err)
}
return v.Say(seat, EventHeld, Event{Name: r.Name, Kind: r.Kind, Digest: r.Digest, Holder: Self, By: caller})
}
// Release is `release`: the artifact sweep's holder, or the operator's session through registry_holds (a hand act,
// recorded by the tool); distribution's own account is never on its list.
func (v *Verbs) Release(seat, caller string, r HoldRequest) (bool, error) {
if CallerModule(caller) == Self && !strings.HasPrefix(caller, "person.") {
return false, fmt.Errorf("release: refused for %s: distribution cannot release in its own name", caller)
}
if err := v.Callers.Allowed(VerbRelease, caller); err != nil {
return false, err
}
key := Hold{Seat: seat, Name: r.Name, Digest: r.Digest}.Key()
h, held, err := v.Holds.Get(key)
if err != nil {
return false, err
}
if !held {
return false, nil // nothing held: the outcome asked holds already, and nothing is said (the seat's definition)
}
if err := v.Holds.Delete(key); err != nil {
return false, err
}
return true, v.Say(seat, EventReleased, Event{Name: h.Name, Kind: h.Kind, Digest: h.Digest, Holder: Self, By: caller, Reason: r.Why})
}
// Tags is `tags` on the image seat: the tags of an image.
func (v *Verbs) Tags(seat, caller, name string) (VersionsAnswer, error) {
if seat != ImageSeat {
return VersionsAnswer{}, fmt.Errorf("tags: %s serves no tags", seat)
}
if err := v.Callers.Allowed(VerbTags, caller); err != nil {
return VersionsAnswer{}, err
}
got, err := v.Catalog.Versions(name)
if err != nil {
return VersionsAnswer{}, err
}
return VersionsAnswer{Seat: seat, Name: name, Versions: got}, nil
}
// MirrorRequest is `mirror`: an outside image by its pinned digest; By is the proved caller, kept with the ask.
type MirrorRequest struct {
Source string `json:"source"`
Digest string `json:"digest"`
By string `json:"by,omitempty"`
// ID is the ask's own id, made when it is asked: the id of the event that says its end.
ID string `json:"id,omitempty"`
// Claim is the follower that took the copy on: none until one claims it.
Claim string `json:"claim,omitempty"`
}
// MirrorsKey is an ask's key in the state mirrors: the copy's name and digest, in a key's characters.
func MirrorsKey(name, digest string) string {
return strings.NewReplacer(":", "_", "@", "_").Replace(name + "/" + digest)
}
// Mirror is `mirror` on the image seat: mesh-build-image alone; an image pinned by no digest is refused. It answers at
// once with the reference the copy will have and keeps the ask in the state mirrors; the copy runs on that state's
// watch (Mirrored), which says published or refused on the seat's events when it ends. The caller waits on that
// event, not on the call, so a large base never outlasts a call's bound.
func (v *Verbs) Mirror(seat, caller string, r MirrorRequest) (string, error) {
if seat != ImageSeat {
return "", fmt.Errorf("mirror: %s serves no mirror", seat)
}
if err := v.Callers.Allowed(VerbMirror, caller); err != nil {
return "", err
}
if r.Source == "" || !strings.HasPrefix(r.Digest, "sha256:") {
return "", errors.New("mirror: refused: an upstream image is copied only by its pinned digest")
}
src, err := ParseSource(r.Source)
if err != nil {
return "", err
}
r.By, r.Claim = caller, ""
if r.ID == "" {
id := make([]byte, 8)
_, _ = rand.Read(id)
r.ID = "mirror-" + hex.EncodeToString(id)
}
if err := v.Mirrors.Ask(MirrorsKey(src.Name(), r.Digest), r); err != nil {
return "", fmt.Errorf("mirror: the copy of %s@%s cannot be taken on: %w", r.Source, r.Digest, err)
}
return SeatReference(seat, v.Catalog.Reference(src.Name(), r.Digest)), nil
}
// Mirrored runs one asked copy and says its outcome on the image seat's events: published with the reference, or
// refused with the reason; the ask leaves the state either way. An outcome that cannot be said keeps the ask, so the
// next watch takes it on again.
func (v *Verbs) Mirrored(r MirrorRequest) error {
src, err := ParseSource(r.Source)
if err != nil {
return err
}
e := Event{Name: src.Name(), Kind: KindUpstream, Digest: r.Digest, Holder: Self, By: r.By, ID: r.ID}
event := EventPublished
path, err := v.Catalog.Mirror(r.Source, r.Digest)
e.Reference = SeatReference(ImageSeat, path)
if err != nil {
event, e.Reference, e.Reason = EventRefused, "", fmt.Sprintf("%s@%s was not copied: %v", r.Source, r.Digest, err)
}
if err := v.Say(ImageSeat, event, e); err != nil {
return fmt.Errorf("mirror: the outcome of %s@%s cannot be said: %w", r.Source, r.Digest, err)
}
return v.Mirrors.Delete(MirrorsKey(src.Name(), r.Digest))
}
// Take decides one change of the state mirrors for the follower self: an ask no follower claimed, or one claimed
// before this follower's watch began (its claimant may have been cut by a restart), is claimed by compare-and-set at
// revision and copied by the follower that won; an ask claimed since, by self or by another, is passed. Two followers
// that both start on the same cut ask can both win in turn; the outcome then carries the ask's id, so it is said once.
func (v *Verbs) Take(key string, r MirrorRequest, revision uint64, current bool, self string) error {
if r.Claim != "" && (r.Claim == self || !current) {
return nil
}
r.Claim = self
won, err := v.Mirrors.Claim(key, r, revision)
if err != nil {
return fmt.Errorf("mirror: the ask %s cannot be claimed: %w", key, err)
}
if !won {
return nil
}
return v.Mirrored(r)
}
// HeldNow lists the holds, for registry_holds.
func (v *Verbs) HeldNow() ([]Hold, error) {
all, err := v.Holds.List()
if err != nil {
return nil, err
}
slices.SortFunc(all, func(a, b Hold) int { return strings.Compare(a.Key(), b.Key()) })
return all, nil
}