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
328 lines
13 KiB
Go
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
|
|
}
|