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
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module distribution
|
||||
|
||||
go 1.26.0
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package holder is distribution's holding of the two image seats, `container-image-registry` and
|
||||
// `oci-artifact-registry`, and of the old seat `mesh-artifact-store` until its window closes (novox/hq design 51 *The
|
||||
// four registry seats*, ADR 0605, ADR 0611, ADR 0617 §2). A build module publishes into a seat through `publish` and
|
||||
// `publish-part`: the parts land in distribution's own object store `incoming`, are hashed, checked against the
|
||||
// builder's `builder_versions` and stored in the artifact store, which listens on its node's loopback; the store's port
|
||||
// is the program's, reads open, writes from its own process alone (front.go).
|
||||
package holder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The seats distribution holds, and the old one it holds until its window closes.
|
||||
const (
|
||||
ImageSeat = "container-image-registry"
|
||||
ArtifactSeat = "oci-artifact-registry"
|
||||
OldSeat = "mesh-artifact-store"
|
||||
)
|
||||
|
||||
// The verbs of the seats (design 26 *A seat's definition*; design 51 *One contract*).
|
||||
const (
|
||||
VerbPublish = "publish"
|
||||
VerbPublishPart = "publish-part"
|
||||
VerbVersions = "versions"
|
||||
VerbRemove = "remove"
|
||||
VerbMirror = "mirror"
|
||||
VerbTags = "tags"
|
||||
VerbHold = "hold"
|
||||
VerbRelease = "release"
|
||||
)
|
||||
|
||||
// Seats are the seats distribution answers verbs on.
|
||||
var Seats = []string{ImageSeat, ArtifactSeat}
|
||||
|
||||
// VerbsOf are a seat's verbs: mirror and tags on the image seat alone; hold and release on both of distribution's.
|
||||
func VerbsOf(seat string) []string {
|
||||
v := []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove, VerbHold, VerbRelease}
|
||||
if seat == ImageSeat {
|
||||
v = append(v, VerbMirror, VerbTags)
|
||||
}
|
||||
sort.Strings(v)
|
||||
return v
|
||||
}
|
||||
|
||||
// Callers is the holder's trusted config value `callers` (the form ADR 0363 gives a restricted family): per verb, the
|
||||
// modules that may call it. The holder checks the caller the tool runner proved and stamped on the request (design 25
|
||||
// § 12.4), never a name in the request's body.
|
||||
type Callers map[string][]string
|
||||
|
||||
// DefaultCallers is the list design 51 *Who may call which verb* names: publish and publish-part the build modules;
|
||||
// mirror mesh-build-image; remove the builder (a tagged version) and the artifact sweep's holder (retention); hold the
|
||||
// artifact sweep's holder alone; release that holder, and the operator's session only through registry_holds (Operator
|
||||
// below). versions is read by anyone on the mesh, as the store's reads are.
|
||||
func DefaultCallers() Callers {
|
||||
builds := []string{"mesh-build-go", "mesh-build-image", "mesh-build-node"}
|
||||
return Callers{
|
||||
VerbPublish: builds,
|
||||
VerbPublishPart: builds,
|
||||
VerbMirror: {"mesh-build-image"},
|
||||
VerbRemove: {"mesh-delivery-builder", SweepHolder},
|
||||
VerbHold: {SweepHolder},
|
||||
VerbRelease: {SweepHolder, Operator},
|
||||
VerbVersions: {Anyone},
|
||||
VerbTags: {Anyone},
|
||||
}
|
||||
}
|
||||
|
||||
// SweepHolder is the module holding the seat `mesh-artifact-sweep`, the artifact sweep.
|
||||
const SweepHolder = "artifact-store-tools"
|
||||
|
||||
// Anyone on a verb's list lets every proved caller call it.
|
||||
const Anyone = "*"
|
||||
|
||||
// Operator on a list stands for the operator's session, the caller the tool runner proves as `person.<name>` (ADR 0386
|
||||
// §7): a release by hand through registry_holds carries it on, never distribution's own account.
|
||||
const Operator = "operator"
|
||||
|
||||
// Unproven is the caller the runtime names for a call it could not prove (mesh-sdk stdio.Unproven).
|
||||
const Unproven = "unproven"
|
||||
|
||||
// Self is distribution's own account: never on release's list, so registry_holds cannot release in its own name.
|
||||
const Self = "distribution"
|
||||
|
||||
// ParseCallers reads the config value; an empty value is DefaultCallers. A verb no seat has is refused, and Self on
|
||||
// release's list is refused (design 51).
|
||||
func ParseCallers(raw string) (Callers, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return DefaultCallers(), nil
|
||||
}
|
||||
var c Callers
|
||||
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||
return nil, fmt.Errorf("callers is not a JSON object of verb to modules: %w", err)
|
||||
}
|
||||
known := VerbsOf(ImageSeat)
|
||||
for verb, mods := range c {
|
||||
if !slices.Contains(known, verb) {
|
||||
return nil, fmt.Errorf("callers names the verb %q, which no seat of distribution's has", verb)
|
||||
}
|
||||
if verb == VerbRelease && slices.Contains(mods, Self) {
|
||||
return nil, fmt.Errorf("callers puts %s on release's list: registry_holds would release in its own name", Self)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Allowed answers nil when caller may call verb, else the refusal naming the verb and the caller.
|
||||
func (c Callers) Allowed(verb, caller string) error {
|
||||
if caller == "" || caller == Unproven {
|
||||
return fmt.Errorf("%s: refused: the request carries no caller the tool runner proved", verb)
|
||||
}
|
||||
person := strings.HasPrefix(caller, "person.")
|
||||
for _, m := range c[verb] {
|
||||
switch {
|
||||
case m == Anyone, m == Operator && person, !person && m == CallerModule(caller):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s: refused for %s: not on the holder's caller list for %s", verb, caller, verb)
|
||||
}
|
||||
|
||||
// CallerModule is the module of a proved module caller, which the runtime stamps `<node>.<module>`.
|
||||
func CallerModule(proved string) string {
|
||||
if i := strings.LastIndexByte(proved, '.'); i >= 0 {
|
||||
return proved[i+1:]
|
||||
}
|
||||
return proved
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Front is the store's port, served by distribution's program in front of the registry container, which listens on its
|
||||
// node's loopback only (design 51 *No build module holds a registry's write credential*): reads (the OCI distribution
|
||||
// protocol's GET and HEAD) stay open to every node's container runtime; a write reaches the store only from
|
||||
// distribution's own process, which writes to the loopback directly and never through the front. While the direct-write
|
||||
// window is open, a build module's old build still pushes through the front as today, but never to a tag the seat holds
|
||||
// published.
|
||||
type Front struct {
|
||||
// Store is the registry container's loopback address.
|
||||
Store *url.URL
|
||||
// Window says whether the direct-write window is open.
|
||||
Window *atomic.Bool
|
||||
// Published says whether name holds a tag published through the seat.
|
||||
Published func(name, tag string) (bool, error)
|
||||
// Bootstrap is the bootstrap's one-off credential for the holders' first images (design 51 *On a fresh mesh*):
|
||||
// read on each write, never kept, so the file's removal (BootstrapFile) refuses the next write that carries it.
|
||||
Bootstrap func() (token string, ok bool)
|
||||
Logf func(string, ...any)
|
||||
proxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
// ServeHTTP answers one request of the store's port.
|
||||
func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if f.proxy == nil {
|
||||
f.proxy = httputil.NewSingleHostReverseProxy(f.Store)
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
f.proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
bootstrap := false
|
||||
if f.Bootstrap != nil {
|
||||
if token, ok := f.Bootstrap(); ok && token != "" {
|
||||
got, has := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
bootstrap = has && subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
|
||||
}
|
||||
}
|
||||
if !bootstrap && (f.Window == nil || !f.Window.Load()) {
|
||||
refuse(w, http.StatusForbidden, "DENIED", "the store takes writes from distribution's own process alone: publish through the seat's publish")
|
||||
return
|
||||
}
|
||||
if name, tag, ok := manifestPut(r); ok && f.Published != nil {
|
||||
held, err := f.Published(name, tag)
|
||||
if err != nil {
|
||||
refuse(w, http.StatusServiceUnavailable, "UNAVAILABLE", "whether "+name+":"+tag+" is published cannot be read, so nothing is written: "+err.Error())
|
||||
return
|
||||
}
|
||||
if held {
|
||||
refuse(w, http.StatusForbidden, "DENIED", fmt.Sprintf("%s:%s is published through the seat; a direct write cannot overwrite it: publish through the seat's publish", name, tag))
|
||||
return
|
||||
}
|
||||
}
|
||||
if bootstrap {
|
||||
if f.Logf != nil {
|
||||
f.Logf("a write %s %s from %s with the bootstrap's one-off credential", r.Method, r.URL.Path, r.RemoteAddr)
|
||||
}
|
||||
} else if f.Logf != nil {
|
||||
f.Logf("a direct write %s %s from %s, accepted while the direct-write window is open", r.Method, r.URL.Path, r.RemoteAddr)
|
||||
}
|
||||
f.proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// BootstrapFile is where the node-engine's bootstrap gives distribution its one-off credential, in distribution's
|
||||
// state directory (0600, distribution's account): one line, the token. The tools bundle removes it when it answers its
|
||||
// first publish, so the holders serve publish and the credential is gone (RemoveBootstrap).
|
||||
const BootstrapFile = "/var/lib/mesh/registry/bootstrap-push"
|
||||
|
||||
// manifestPut is a PUT of /v2/<name>/manifests/<tag>, a tag and not a digest.
|
||||
func manifestPut(r *http.Request) (name, tag string, ok bool) {
|
||||
if r.Method != http.MethodPut {
|
||||
return "", "", false
|
||||
}
|
||||
p := strings.TrimPrefix(r.URL.Path, "/v2/")
|
||||
i := strings.LastIndex(p, "/manifests/")
|
||||
if i <= 0 {
|
||||
return "", "", false
|
||||
}
|
||||
name, tag = p[:i], p[i+len("/manifests/"):]
|
||||
if strings.HasPrefix(tag, "sha256:") || tag == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return name, tag, true
|
||||
}
|
||||
|
||||
func refuse(w http.ResponseWriter, code int, kind, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprintf(w, `{"errors":[{"code":%q,"message":%q}]}`, kind, msg)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type memObjects map[string][]byte
|
||||
|
||||
func (m memObjects) Put(n string, d []byte) error { m[n] = append([]byte(nil), d...); return nil }
|
||||
func (m memObjects) Get(n string) ([]byte, error) {
|
||||
if d, ok := m[n]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, errors.New("no object " + n)
|
||||
}
|
||||
func (m memObjects) Delete(n string) error { delete(m, n); return nil }
|
||||
func (m memObjects) Used() (uint64, error) {
|
||||
var u uint64
|
||||
for _, d := range m {
|
||||
u += uint64(len(d))
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type memTransfers map[string]Transfer
|
||||
|
||||
func (m memTransfers) Get(id string) (Transfer, bool, error) { t, ok := m[id]; return t, ok, nil }
|
||||
func (m memTransfers) Put(t Transfer) error { m[t.Transfer] = t; return nil }
|
||||
func (m memTransfers) Delete(id string) error { delete(m, id); return nil }
|
||||
func (m memTransfers) List() ([]Transfer, error) {
|
||||
var out []Transfer
|
||||
for _, t := range m {
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeBuilder struct {
|
||||
ok bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (b fakeBuilder) Recorded(string, string, string) (bool, string, error) {
|
||||
return b.ok, "not numbered", b.err
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
has map[string]bool
|
||||
put []map[string][]byte
|
||||
}
|
||||
|
||||
func (s *fakeStore) Has(_, d string) (bool, error) { return s.has[d], nil }
|
||||
func (s *fakeStore) Put(t Transfer, parts map[string][]byte) (string, error) {
|
||||
s.put = append(s.put, parts)
|
||||
return "registry.mesh/" + t.Name + "@" + t.Digest, nil
|
||||
}
|
||||
func (s *fakeStore) Held(string, string, string) (string, bool, error) { return "", false, nil }
|
||||
|
||||
type said struct {
|
||||
seat, event string
|
||||
body Event
|
||||
}
|
||||
|
||||
type bed struct {
|
||||
h *Holder
|
||||
v *Verbs
|
||||
store *fakeStore
|
||||
said []said
|
||||
findings map[string]bool
|
||||
now time.Time
|
||||
cat *fakeCatalog
|
||||
}
|
||||
|
||||
type fakeCatalog struct {
|
||||
tags map[string]string // name:version -> digest
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (c *fakeCatalog) Versions(name string) ([]string, error) {
|
||||
var out []string
|
||||
for k := range c.tags {
|
||||
if n, v, _ := strings.Cut(k, ":"); n == name {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (c *fakeCatalog) Digest(name, version string) (string, error) {
|
||||
return c.tags[name+":"+version], nil
|
||||
}
|
||||
func (c *fakeCatalog) Remove(name, version string) error {
|
||||
c.removed = append(c.removed, name+":"+version)
|
||||
delete(c.tags, name+":"+version)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeCatalog) RemoveDigest(name, digest string) error {
|
||||
c.removed = append(c.removed, name+"@"+digest)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeCatalog) Mirror(source, digest string) (string, error) {
|
||||
return "registry.mesh/upstream/" + source + "@" + digest, nil
|
||||
}
|
||||
|
||||
type memHolds map[string]Hold
|
||||
|
||||
func (m memHolds) Get(k string) (Hold, bool, error) { h, ok := m[k]; return h, ok, nil }
|
||||
func (m memHolds) Put(h Hold) error { m[h.Key()] = h; return nil }
|
||||
func (m memHolds) Delete(k string) error { delete(m, k); return nil }
|
||||
func (m memHolds) List() ([]Hold, error) {
|
||||
var out []Hold
|
||||
for _, h := range m {
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newBed(builderOK bool) *bed {
|
||||
b := &bed{store: &fakeStore{has: map[string]bool{}}, findings: map[string]bool{}, now: time.Unix(1000, 0), cat: &fakeCatalog{tags: map[string]string{}}}
|
||||
b.h = &Holder{Callers: DefaultCallers(), Objects: memObjects{}, Transfers: memTransfers{}, Builder: fakeBuilder{ok: builderOK},
|
||||
Store: b.store, Now: func() time.Time { return b.now },
|
||||
Say: func(seat, event string, body Event) error {
|
||||
b.said = append(b.said, said{seat, event, body})
|
||||
return nil
|
||||
},
|
||||
Finding: func(kind, subject, _ string, raised bool) { b.findings[kind+" "+subject] = raised }}
|
||||
b.v = &Verbs{Holder: b.h, Catalog: b.cat, Holds: memHolds{}}
|
||||
return b
|
||||
}
|
||||
|
||||
func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) }
|
||||
|
||||
const builder = "ace.mesh-build-go"
|
||||
|
||||
// TestTheHolderOfTheTwoImageSeats holds design 51's holder cases at the unit level (CAT-D1, D2, D3, D3b, D4, D4b, D5):
|
||||
// one table or case per rule, the caller list and the store front's.
|
||||
func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
blob := []byte(strings.Repeat("a", 3000))
|
||||
part := Part{Digest: digestOf(blob), Size: int64(len(blob))}
|
||||
req := func(id string) PublishRequest {
|
||||
return PublishRequest{Transfer: id, Name: "m/program", Version: "0.1.4", Kind: KindBundle, Digest: part.Digest, Parts: []Part{part}}
|
||||
}
|
||||
|
||||
t.Run("a transfer in parts, cut and resumed, hashed, numbered by the builder, published", func(t *testing.T) {
|
||||
b := newBed(true)
|
||||
a, err := b.h.Publish(ArtifactSeat, builder, req("t1"))
|
||||
if err != nil || !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
|
||||
t.Fatalf("publish = %+v, %v", a, err)
|
||||
}
|
||||
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Cut: the build module opens again and is told what is still missing; its next chunk goes on from there.
|
||||
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
|
||||
t.Errorf("resumed publish = %+v", a)
|
||||
}
|
||||
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err == nil {
|
||||
t.Error("a chunk sent twice was taken: the transfer goes on from the chunks held")
|
||||
}
|
||||
a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]})
|
||||
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
|
||||
t.Fatalf("last chunk = %+v, %v; said %+v", a, err, b.said)
|
||||
}
|
||||
if len(b.h.Objects.(memObjects)) != 0 || len(b.h.Transfers.(memTransfers)) != 0 {
|
||||
t.Error("the transfer's chunks or key stayed after its end")
|
||||
}
|
||||
})
|
||||
t.Run("refusals", func(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
do func(b *bed) error
|
||||
want string
|
||||
}{
|
||||
{"a caller not on the list", func(b *bed) error { _, err := b.h.Publish(ArtifactSeat, "ace.mesh-provisioner", req("t")); return err }, "not on the holder's caller list"},
|
||||
{"an unproven caller", func(b *bed) error { _, err := b.h.Publish(ArtifactSeat, Unproven, req("t")); return err }, "no caller the tool runner proved"},
|
||||
{"a part from another caller than the opener", func(b *bed) error {
|
||||
_, _ = b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
_, err := b.h.PublishPart(ArtifactSeat, "ace.mesh-build-node", PartRequest{Transfer: "t", Digest: part.Digest, Last: true, Data: blob})
|
||||
return err
|
||||
}, "opened by another caller"},
|
||||
{"a part whose bytes do not match its digest", func(b *bed) error {
|
||||
_, _ = b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
_, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t", Digest: part.Digest, Last: true, Data: []byte("other")})
|
||||
return err
|
||||
}, "hash to"},
|
||||
{"a chunk over 1 MiB", func(b *bed) error {
|
||||
_, _ = b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
_, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t", Digest: part.Digest, Data: make([]byte, MaxPart+1)})
|
||||
return err
|
||||
}, "over the"},
|
||||
{"a new transfer at the cap", func(b *bed) error { b.h.Cap = 100; _, err := b.h.Publish(ArtifactSeat, builder, req("t")); return err }, "at its cap"},
|
||||
{"a version the builder did not number", func(b *bed) error {
|
||||
b.h.Builder = fakeBuilder{ok: false}
|
||||
b.store.has[part.Digest] = true // held already: the publish ends at once
|
||||
_, err := b.h.Publish(ArtifactSeat, builder, req("t"))
|
||||
return err
|
||||
}, "the builder did not number it"},
|
||||
{"an image on the artifact seat", func(b *bed) error {
|
||||
r := req("t")
|
||||
r.Kind = KindImage
|
||||
_, err := b.h.Publish(ArtifactSeat, builder, r)
|
||||
return err
|
||||
}, "holds bundles and archives"},
|
||||
{"remove of a version with no tag", func(b *bed) error {
|
||||
return b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Version: "0.1.4", Reason: "branch ended"})
|
||||
}, "has no tag"},
|
||||
{"a retention removal from another caller than the sweep", func(b *bed) error {
|
||||
return b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention})
|
||||
}, "artifact sweep's alone"},
|
||||
{"a retention removal of a held archive", func(b *bed) error {
|
||||
_ = b.v.HoldOne(ArtifactSeat, "novox.artifact-store-tools", HoldRequest{Name: "m/a", Kind: KindArchive, Digest: part.Digest})
|
||||
return b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/a", Digest: part.Digest, Reason: ReasonRetention})
|
||||
}, "is held"},
|
||||
{"a hold of an image", func(b *bed) error {
|
||||
return b.v.HoldOne(ImageSeat, "novox.artifact-store-tools", HoldRequest{Name: "m/i", Kind: KindImage, Digest: part.Digest})
|
||||
}, "never held"},
|
||||
{"a hold from another caller", func(b *bed) error {
|
||||
return b.v.HoldOne(ArtifactSeat, "novox.mesh-build-go", HoldRequest{Name: "m/a", Kind: KindArchive, Digest: part.Digest})
|
||||
}, "not on the holder's caller list"},
|
||||
{"a release from a caller not on the list", func(b *bed) error {
|
||||
return b.v.Release(ArtifactSeat, "novox.mesh-build-go", HoldRequest{Name: "m/a", Digest: part.Digest})
|
||||
}, "not on the holder's caller list"},
|
||||
{"a release in distribution's own name", func(b *bed) error {
|
||||
return b.v.Release(ArtifactSeat, "novox.distribution", HoldRequest{Name: "m/a", Digest: part.Digest})
|
||||
}, "in its own name"},
|
||||
{"a mirror by no digest", func(b *bed) error {
|
||||
_, err := b.v.Mirror(ImageSeat, "novox.mesh-build-image", MirrorRequest{Source: "docker.io/library/nats:2"})
|
||||
return err
|
||||
}, "pinned digest"},
|
||||
} {
|
||||
b := newBed(true)
|
||||
if err := c.do(b); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: err = %v, want one saying %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := ParseCallers(`{"release":["distribution"]}`); err == nil {
|
||||
t.Error("a caller list putting distribution on release's list is taken")
|
||||
}
|
||||
})
|
||||
t.Run("only the missing layers are sent, and an idle transfer is dropped", func(t *testing.T) {
|
||||
b := newBed(true)
|
||||
other := []byte("layer two")
|
||||
r := req("t2")
|
||||
r.Kind, r.Parts = KindImage, []Part{part, {Digest: digestOf(other), Size: int64(len(other))}}
|
||||
b.store.has[part.Digest] = true
|
||||
a, err := b.h.Publish(ImageSeat, builder, r)
|
||||
if err != nil || !reflect.DeepEqual(a.Missing, []string{digestOf(other)}) {
|
||||
t.Fatalf("publish = %+v, %v; want only the layer the store lacks", a, err)
|
||||
}
|
||||
b.now = b.now.Add(TransferIdle)
|
||||
if dropped, _ := b.h.DropIdle(); !reflect.DeepEqual(dropped, []string{"t2"}) {
|
||||
t.Errorf("DropIdle = %v, want t2 after 24 hours with no part", dropped)
|
||||
}
|
||||
})
|
||||
t.Run("incoming's finding at 80 %, cleared below", func(t *testing.T) {
|
||||
b := newBed(true)
|
||||
b.h.Cap = 3500
|
||||
_, _ = b.h.Publish(ArtifactSeat, builder, req("t3"))
|
||||
_, _ = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t3", Digest: part.Digest, Data: blob[:2800]})
|
||||
if !b.findings["incoming-full incoming"] {
|
||||
t.Error("2800 of 3500 bytes wrote no finding")
|
||||
}
|
||||
_, _ = b.h.Drop("t3")
|
||||
if b.findings["incoming-full incoming"] {
|
||||
t.Error("the finding stayed after the transfer was dropped")
|
||||
}
|
||||
})
|
||||
t.Run("the sweep's removal, hold and release", func(t *testing.T) {
|
||||
b := newBed(true)
|
||||
sweep := "novox.artifact-store-tools"
|
||||
if err := b.v.Remove(ArtifactSeat, sweep, RemoveRequest{Name: "m/a", Digest: part.Digest, Kind: KindArchive, Reason: ReasonRetention}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.v.HoldOne(ArtifactSeat, sweep, HoldRequest{Name: "m/b", Kind: KindArchive, Digest: part.Digest}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.v.Release(ArtifactSeat, "person.jochen", HoldRequest{Name: "m/b", Digest: part.Digest, Why: "by hand"}); err != nil {
|
||||
t.Errorf("the operator's session releasing through registry_holds: %v", err)
|
||||
}
|
||||
var events []string
|
||||
for _, s := range b.said {
|
||||
events = append(events, s.event+" "+s.body.By)
|
||||
}
|
||||
if want := []string{"removed " + sweep, "held " + sweep, "released person.jochen"}; !reflect.DeepEqual(events, want) {
|
||||
t.Errorf("said %v, want %v", events, want)
|
||||
}
|
||||
b.cat.tags["m/p:0.1.5-feat-x.1"] = part.Digest
|
||||
if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.5-feat-x.1", Reason: "branch merged"}); err != nil || b.cat.tags["m/p:0.1.5-feat-x.1"] != "" {
|
||||
t.Errorf("the builder's removal of a tagged version: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("the store's port: reads open, writes from distribution alone, and the direct-write window", func(t *testing.T) {
|
||||
store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }))
|
||||
defer store.Close()
|
||||
u, _ := url.Parse(store.URL)
|
||||
var open atomic.Bool
|
||||
token, kept := "s3cret", true
|
||||
f := &Front{Store: u, Window: &open, Published: func(name, tag string) (bool, error) { return name == "m/i" && tag == "0.1.3", nil },
|
||||
Bootstrap: func() (string, bool) { return token, kept }}
|
||||
auth := ""
|
||||
do := func(method, path string) int {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+auth)
|
||||
}
|
||||
f.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
for _, c := range []struct {
|
||||
open bool
|
||||
method, path string
|
||||
want int
|
||||
}{
|
||||
{false, http.MethodGet, "/v2/m/i/manifests/0.1.3", http.StatusCreated},
|
||||
{false, http.MethodHead, "/v2/m/i/blobs/sha256:aa", http.StatusCreated},
|
||||
{false, http.MethodPut, "/v2/m/i/manifests/0.1.9", http.StatusForbidden},
|
||||
{false, http.MethodPost, "/v2/m/i/blobs/uploads/", http.StatusForbidden},
|
||||
{true, http.MethodPost, "/v2/m/i/blobs/uploads/", http.StatusCreated},
|
||||
{true, http.MethodPut, "/v2/m/i/manifests/0.1.9", http.StatusCreated},
|
||||
{true, http.MethodPut, "/v2/m/i/manifests/0.1.3", http.StatusForbidden}, // published through the seat
|
||||
} {
|
||||
open.Store(c.open)
|
||||
if got := do(c.method, c.path); got != c.want {
|
||||
t.Errorf("window open %v: %s %s = %d, want %d", c.open, c.method, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
// The bootstrap's one-off credential writes with the window closed, never over a published tag, and not once
|
||||
// its file is gone; a wrong token writes nothing.
|
||||
open.Store(false)
|
||||
for _, c := range []struct {
|
||||
auth string
|
||||
kept bool
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"s3cret", true, "/v2/gitea/program/manifests/0.1.0", http.StatusCreated},
|
||||
{"s3cret", true, "/v2/m/i/manifests/0.1.3", http.StatusForbidden},
|
||||
{"wrong", true, "/v2/gitea/program/manifests/0.1.0", http.StatusForbidden},
|
||||
{"s3cret", false, "/v2/gitea/program/manifests/0.1.0", http.StatusForbidden},
|
||||
} {
|
||||
auth, kept = c.auth, c.kept
|
||||
if got := do(http.MethodPut, c.path); got != c.want {
|
||||
t.Errorf("bootstrap %q kept %v: PUT %s = %d, want %d", c.auth, c.kept, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("the windows", func(t *testing.T) {
|
||||
nodes := []NodeRead{
|
||||
{Node: "ace", Serves: true, Stated: []string{NewNamesCapability}, NamesArtifact: true, BuildModules: map[string]bool{"mesh-build-go": true}},
|
||||
{Node: "g14", NamesArtifact: true, BuildModules: map[string]bool{"mesh-build-go": false}}, // away, old node-engine
|
||||
{Node: "novox", Serves: true, Stated: nil, NamesArtifact: false},
|
||||
}
|
||||
if got := HoldingOldName(nodes, nil); !reflect.DeepEqual(got, []string{"g14"}) {
|
||||
t.Errorf("old name held by %v, want g14 (no manifest-reads: it states nothing)", got)
|
||||
}
|
||||
if got := HoldingDirectWrites(nodes, nil); !reflect.DeepEqual(got, []string{"g14"}) {
|
||||
t.Errorf("direct writes held by %v, want g14", got)
|
||||
}
|
||||
t0 := time.Unix(0, 0)
|
||||
w, closed, _ := Window{Name: OldNameWindow}.Step([]string{"g14"}, t0)
|
||||
if closed != nil || !w.Open {
|
||||
t.Fatalf("Step = %+v, %+v", w, closed)
|
||||
}
|
||||
if _, _, overdue := w.Step([]string{"g14"}, t0.Add(WindowFindingAfter)); !reflect.DeepEqual(overdue, []string{"g14"}) {
|
||||
t.Errorf("seven days: overdue %v", overdue)
|
||||
}
|
||||
w, _ = w.Drop("g14", "person.jochen", "retired", t0)
|
||||
w, closed, _ = w.Step(HoldingOldName(nodes, w.Dropped), t0.Add(time.Hour))
|
||||
if closed == nil || closed.Window != OldNameWindow || !reflect.DeepEqual(closed.LastHolding, []string{"g14"}) || len(closed.Dropped) != 1 {
|
||||
t.Errorf("closing = %+v", closed)
|
||||
}
|
||||
if _, again, _ := w.Step([]string{"g14"}, t0.Add(2*time.Hour)); again != nil {
|
||||
t.Error("a closed window closed again")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"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. Registry is the reach the seat answers in a
|
||||
// reference (`<registry>/<module>/<artifact>@sha256:<digest>`, ADR 0605 §9).
|
||||
type OCI struct {
|
||||
Base *url.URL
|
||||
Registry string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
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 Transfer, 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 {
|
||||
if 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, manifest.MediaType)
|
||||
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
|
||||
}
|
||||
d, err := o.putManifest(t.Name, ref, manifest.MediaType, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return o.Registry + "/" + t.Name + "@" + d, nil
|
||||
default:
|
||||
if err := o.putBlob(t.Name, "sha256:"+sum([]byte(emptyConfig)), []byte(emptyConfig)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
size := int64(0)
|
||||
for _, p := range t.Parts {
|
||||
if p.Digest == t.Digest {
|
||||
size = p.Size
|
||||
}
|
||||
}
|
||||
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 o.Registry + "/" + t.Name + "@" + t.Digest, 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 o.Registry + "/" + 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
|
||||
}
|
||||
|
||||
// Remove deletes the manifest name holds at version.
|
||||
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
|
||||
}
|
||||
|
||||
// Mirror is not done by the store itself: mesh-build-image sends an upstream image's parts through publish of kind
|
||||
// upstream, named `upstream/<host>/<path>`; mirror answers where a held copy is, and refuses one the store lacks so
|
||||
// the build module publishes it.
|
||||
func (o *OCI) Mirror(source, digest string) (string, error) {
|
||||
name := "upstream/" + source
|
||||
if i := strings.LastIndexAny(name, ":@"); i > strings.LastIndex(name, "/") {
|
||||
name = name[:i]
|
||||
}
|
||||
resp, _, err := o.do(http.MethodHead, "/v2/"+name+"/manifests/"+digest, "", nil, "application/vnd.oci.image.index.v1+json",
|
||||
mediaManifest, "application/vnd.docker.distribution.manifest.list.v2+json", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("the store holds no copy of %s@%s: publish it with kind upstream under %s", source, digest, name)
|
||||
}
|
||||
return o.Registry + "/" + name + "@" + digest, nil
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
// Mirror copies an outside image, pinned by digest, into upstream/<host>/<path>, answering the reference.
|
||||
Mirror(source, digest string) (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
|
||||
}
|
||||
|
||||
// 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).
|
||||
const ReasonRetention = "retention"
|
||||
|
||||
// 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.Catalog.RemoveDigest(r.Name, r.Digest); err != nil {
|
||||
return fmt.Errorf("remove: the store did not remove %s@%s: %w", r.Name, r.Digest, 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
|
||||
}
|
||||
switch r.Kind {
|
||||
case KindBundle, KindArchive, 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) error {
|
||||
if CallerModule(caller) == Self && !strings.HasPrefix(caller, "person.") {
|
||||
return fmt.Errorf("release: refused for %s: distribution cannot release in its own name", caller)
|
||||
}
|
||||
if err := v.Callers.Allowed(VerbRelease, caller); err != nil {
|
||||
return err
|
||||
}
|
||||
key := Hold{Seat: seat, Name: r.Name, Digest: r.Digest}.Key()
|
||||
h, held, err := v.Holds.Get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !held {
|
||||
return fmt.Errorf("release: %s@%s is not held on %s", r.Name, r.Digest, seat)
|
||||
}
|
||||
if err := v.Holds.Delete(key); err != nil {
|
||||
return err
|
||||
}
|
||||
return 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.
|
||||
type MirrorRequest struct {
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// Mirror is `mirror` on the image seat: mesh-build-image alone; an image pinned by no digest is refused.
|
||||
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")
|
||||
}
|
||||
ref, err := v.Catalog.Mirror(r.Source, r.Digest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mirror: %s@%s was not copied: %w", r.Source, r.Digest, err)
|
||||
}
|
||||
return ref, v.Say(seat, EventPublished, Event{Name: "upstream/" + r.Source, Kind: KindUpstream, Digest: r.Digest, Holder: Self, Reference: ref, By: caller})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package holder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The two windows distribution keeps (design 51): the old seat name `mesh-artifact-store`, served until every node whose
|
||||
// declaration names an artifact applied a build whose node-engine states it reads the new names (ADR 0610 §4, ADR 0614
|
||||
// §4), and the direct writes to the store, accepted from the mesh until every node's have of every build module runs a
|
||||
// build that publishes through the seats. Each wakes at distribution's start, on each `delivered` and on the node-engine
|
||||
// seat's `manifest-reads-changed` (ADR 0618 §4), never on a timer; each reads every node, away ones included, through
|
||||
// node_haves and `manifest-reads`, never a watch of another module's state (ADR 0596).
|
||||
const (
|
||||
OldNameWindow = "old-name"
|
||||
DirectWritesWindow = "direct-writes"
|
||||
// WindowFindingAfter is how long a node holds a window open before it is distribution's finding naming the node.
|
||||
WindowFindingAfter = 7 * 24 * time.Hour
|
||||
// The events a window's close says on distribution's own family distribution.events (7 days, 16 MiB, drop
|
||||
// oldest), never on a seat's `<seat>.events`.
|
||||
OldNameClosedEvent = "old-name-closed"
|
||||
DirectWritesClosedEvent = "direct-writes-closed"
|
||||
// NewNamesCapability is the reader capability a node-engine states when it reads the new seat names (ADR 0614 §4).
|
||||
NewNamesCapability = "seat:" + ImageSeat
|
||||
)
|
||||
|
||||
// Window is one window's record, kept in distribution's state windows: what registry_old_name and
|
||||
// registry_direct_writes answer.
|
||||
type Window struct {
|
||||
Name string `json:"name"`
|
||||
Open bool `json:"open"`
|
||||
Holding []string `json:"holding,omitempty"`
|
||||
Since map[string]time.Time `json:"since,omitempty"`
|
||||
Dropped []DroppedNode `json:"dropped,omitempty"`
|
||||
Closed time.Time `json:"closed,omitzero"`
|
||||
// LastHolding are the nodes that held it last, said when it closes.
|
||||
LastHolding []string `json:"last_holding,omitempty"`
|
||||
}
|
||||
|
||||
// DroppedNode is one retired node dropped from a window by hand: a hand act.
|
||||
type DroppedNode struct {
|
||||
Node string `json:"node"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Closed is the body of a window's closing event.
|
||||
type Closed struct {
|
||||
Window string `json:"window"`
|
||||
Seat string `json:"seat,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
LastHolding []string `json:"last_holding"`
|
||||
Dropped []DroppedNode `json:"dropped,omitempty"`
|
||||
}
|
||||
|
||||
// NodeRead is what distribution read of one node: the reader capabilities its node-engine states through
|
||||
// `manifest-reads` (Serves false while it does not serve the verb: it states nothing), whether its declaration names an
|
||||
// artifact, and its build modules' haves: whether each runs a build that publishes through the seats.
|
||||
type NodeRead struct {
|
||||
Node string `json:"node"`
|
||||
Serves bool `json:"serves"`
|
||||
Stated []string `json:"stated,omitempty"`
|
||||
NamesArtifact bool `json:"names_artifact"`
|
||||
BuildModules map[string]bool `json:"build_modules,omitempty"`
|
||||
}
|
||||
|
||||
// HoldingOldName are the nodes holding the old name open: a node whose declaration names an artifact and whose
|
||||
// node-engine does not state the new names (one not serving manifest-reads states nothing).
|
||||
func HoldingOldName(nodes []NodeRead, dropped []DroppedNode) []string {
|
||||
var out []string
|
||||
for _, n := range nodes {
|
||||
if isDropped(dropped, n.Node) || !n.NamesArtifact {
|
||||
continue
|
||||
}
|
||||
if !n.Serves || !slices.Contains(n.Stated, NewNamesCapability) {
|
||||
out = append(out, n.Node)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// HoldingDirectWrites are the nodes one of whose build modules' haves runs a build that does not yet publish through
|
||||
// the seats.
|
||||
func HoldingDirectWrites(nodes []NodeRead, dropped []DroppedNode) []string {
|
||||
var out []string
|
||||
for _, n := range nodes {
|
||||
if isDropped(dropped, n.Node) {
|
||||
continue
|
||||
}
|
||||
for _, publishes := range n.BuildModules {
|
||||
if !publishes {
|
||||
out = append(out, n.Node)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func isDropped(dropped []DroppedNode, node string) bool {
|
||||
return slices.ContainsFunc(dropped, func(d DroppedNode) bool { return d.Node == node })
|
||||
}
|
||||
|
||||
// Step moves the window to the nodes holding it at now. It answers the next record, the closing event when it closed in
|
||||
// this step (said once), and the nodes holding it past WindowFindingAfter (the finding; cleared when it closes). A
|
||||
// window once closed stays closed: a node that holds it later is no reason to serve the old name again.
|
||||
func (w Window) Step(holding []string, now time.Time) (next Window, closed *Closed, overdue []string) {
|
||||
next = Window{Name: w.Name, Dropped: w.Dropped, Closed: w.Closed, LastHolding: w.LastHolding}
|
||||
if !w.Open && !w.Closed.IsZero() {
|
||||
return next, nil, nil
|
||||
}
|
||||
if len(holding) == 0 {
|
||||
next.Closed, next.LastHolding = now, w.Holding
|
||||
if next.LastHolding == nil {
|
||||
next.LastHolding = []string{}
|
||||
}
|
||||
return next, &Closed{Window: w.Name, At: now, LastHolding: next.LastHolding, Dropped: w.Dropped}, nil
|
||||
}
|
||||
next.Open, next.Holding, next.Since = true, holding, map[string]time.Time{}
|
||||
for _, n := range holding {
|
||||
since, had := w.Since[n]
|
||||
if !had {
|
||||
since = now
|
||||
}
|
||||
next.Since[n] = since
|
||||
if now.Sub(since) >= WindowFindingAfter {
|
||||
overdue = append(overdue, n)
|
||||
}
|
||||
}
|
||||
return next, nil, overdue
|
||||
}
|
||||
|
||||
// Drop drops a retired node from the window by hand, a hand act with who and why.
|
||||
func (w Window) Drop(node, by, why string, now time.Time) (Window, error) {
|
||||
if node == "" || why == "" {
|
||||
return w, fmt.Errorf("drop: a node and why are required (a hand act)")
|
||||
}
|
||||
if isDropped(w.Dropped, node) {
|
||||
return w, fmt.Errorf("drop: %s is dropped from %s already", node, w.Name)
|
||||
}
|
||||
w.Dropped = append(w.Dropped, DroppedNode{Node: node, By: by, Why: why, At: now})
|
||||
return w, nil
|
||||
}
|
||||
Reference in New Issue
Block a user