G-catalog-B 6: on registry 2.8.3 a shared-digest tagged version's remove answers and says kept
Saying removed for a version the store still holds told the builder something false. remove now answers kept with the reason shared-digest and the other tags naming its digest, removed false, and says the seat's event kept with them (mesh-contracts' shared registry part); the builder raises no retry. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -59,7 +59,7 @@ func tagsAnswer(v *holder.Verbs, a holder.VersionsAnswer) reg.TagsAnswer {
|
||||
return out
|
||||
}
|
||||
|
||||
// eventBody is an event's body as the seat's contract defines it: an Outcome for published, refused and removed, a
|
||||
// eventBody is an event's body as the seat's contract defines it: an Outcome for published, refused, removed and kept, a
|
||||
// Hold for held and released.
|
||||
func eventBody(event string, e holder.Event) any {
|
||||
switch event {
|
||||
@@ -67,5 +67,5 @@ func eventBody(event string, e holder.Event) any {
|
||||
return reg.Hold{Name: e.Name, Digest: e.Digest, Holder: e.Holder, By: e.By, Reason: e.Reason, At: time.Now().UTC().Format(time.RFC3339)}
|
||||
}
|
||||
return reg.Outcome{Name: e.Name, Version: e.Version, Kind: e.Kind, Tag: e.Tag, Digest: e.Digest, Fingerprint: e.Fingerprint,
|
||||
Commit: e.Commit, Holder: e.Holder, Transfer: e.Transfer, Reference: e.Reference, Reason: e.Reason}
|
||||
Commit: e.Commit, Holder: e.Holder, Transfer: e.Transfer, Reference: e.Reference, Reason: e.Reason, Tags: e.Tags}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestTheVerbsSpeakTheSeatsContract(t *testing.T) {
|
||||
if seen != 14 {
|
||||
t.Errorf("%d seat verbs served, want 14 (8 on the image seat, 6 on the artifact seat)", seen)
|
||||
}
|
||||
for event, want := range map[string]any{holder.EventPublished: ®.Outcome{}, holder.EventHeld: ®.Hold{}, holder.EventReleased: ®.Hold{}} {
|
||||
for event, want := range map[string]any{holder.EventPublished: ®.Outcome{}, holder.EventKept: ®.KeptEvent{}, holder.EventHeld: ®.Hold{}, holder.EventReleased: ®.Hold{}} {
|
||||
b, _ := json.Marshal(eventBody(event, holder.Event{Name: "m/a", Kind: holder.KindArchive, Digest: "sha256:aa", Holder: holder.Self, By: "x.artifact-store-tools"}))
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
@@ -322,10 +322,11 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.
|
||||
return nil, err
|
||||
}
|
||||
r := holder.RemoveRequest{Name: in.Name, Version: in.Version, Digest: in.Digest, Reason: in.Reason}
|
||||
if err := v.Remove(seat, stdio.CallerOf(args), r); err != nil {
|
||||
got, err := v.Remove(seat, stdio.CallerOf(args), r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reg.RemoveAnswer{Removed: true, Artifact: ®.Artifact{Name: in.Name, Version: in.Version, Tag: in.Tag, Digest: in.Digest}}, nil
|
||||
return reg.RemoveAnswer{Removed: got.Removed, Kept: got.Kept, Reason: got.Reason, Tags: got.Tags, Artifact: ®.Artifact{Name: in.Name, Version: in.Version, Tag: in.Tag, Digest: in.Digest}}, nil
|
||||
}},
|
||||
stdio.Tool{Name: seat + "." + holder.VerbHold, Description: "Keep one untagged bundle, archive or copied base from retention (the artifact sweep alone); says held.",
|
||||
Input: obj(map[string]any{"name": s("name"), "digest": s("sha256:…"), "reason": s("which of the sweep's rules keeps it")}, "name", "digest", "reason"),
|
||||
|
||||
@@ -299,14 +299,17 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
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"})
|
||||
_, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Version: "0.1.4", Reason: "branch ended"})
|
||||
return err
|
||||
}, "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})
|
||||
_, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention})
|
||||
return err
|
||||
}, "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})
|
||||
_, err := b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/a", Digest: part.Digest, Reason: ReasonRetention})
|
||||
return err
|
||||
}, "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})
|
||||
@@ -423,7 +426,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
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 {
|
||||
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 {
|
||||
@@ -444,27 +447,32 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
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"] != "" {
|
||||
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)
|
||||
}
|
||||
// Security review F1 on registry 2.8.3: a reproducible build gives a branch's tagged version and the trunk's
|
||||
// numbered one one digest; the store cannot remove the tag alone, so nothing goes, the numbered one stays
|
||||
// pullable, and the removal is said removed with that reason: no refusal, so the builder raises no retry.
|
||||
// numbered one one digest; the store cannot remove the tag alone, so the version is kept whole, the numbered one
|
||||
// stays pullable, and remove answers and says kept (shared-digest, the other tags): no refusal, so the builder
|
||||
// raises no retry.
|
||||
b.cat.tags["m/p:0.1.6-feat-y.1"], b.cat.tags["m/p:0.1.6"] = part.Digest, part.Digest
|
||||
if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.6-feat-y.1", Reason: "branch merged"}); err != nil {
|
||||
t.Fatalf("the removal of a tagged version sharing its digest: %v; want no error", err)
|
||||
got, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.6-feat-y.1", Reason: "branch merged"})
|
||||
if err != nil || got.Removed || !got.Kept || got.Reason != ReasonSharedDigest || !reflect.DeepEqual(got.Tags, []string{"0.1.6"}) {
|
||||
t.Fatalf("the removal of a tagged version sharing its digest = %+v, %v; want kept, shared-digest, tags [0.1.6], no error", got, err)
|
||||
}
|
||||
if b.cat.tags["m/p:0.1.6-feat-y.1"] != part.Digest || b.cat.tags["m/p:0.1.6"] != part.Digest {
|
||||
t.Errorf("tags after the tagged version's removal: %v; want both kept at %s", b.cat.tags, part.Digest)
|
||||
}
|
||||
if last := b.said[len(b.said)-1]; last.event != EventRemoved || last.body.Reason != "branch merged; the store cannot remove a tag alone, so the tag 0.1.6-feat-y.1 is kept and its bytes stay under 0.1.6" {
|
||||
t.Errorf("said %s %q; want removed, saying the tag is kept under 0.1.6", last.event, last.body.Reason)
|
||||
if last := b.said[len(b.said)-1]; last.event != EventKept || last.body.Reason != ReasonSharedDigest || last.body.Digest != part.Digest || !reflect.DeepEqual(last.body.Tags, []string{"0.1.6"}) {
|
||||
t.Errorf("said %s %+v; want kept, shared-digest, its digest and the tags [0.1.6]", last.event, last.body)
|
||||
}
|
||||
if got, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.5-feat-v.1", Reason: "branch merged"}); err != nil || !got.Removed || got.Kept {
|
||||
t.Errorf("the removal of a version already gone = %+v, %v; want removed", got, err)
|
||||
}
|
||||
// Any other failure of the store removes nothing and is refused naming the shared digest.
|
||||
b.cat.tags["m/p:0.1.7-feat-z.1"], b.cat.tags["m/p:0.1.7"] = part.Digest, part.Digest
|
||||
b.cat.tagErr = errors.New("the store answered 500")
|
||||
said := len(b.said)
|
||||
if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.7-feat-z.1", Reason: "branch merged"}); err == nil || !strings.Contains(err.Error(), "shares its digest") || b.cat.tags["m/p:0.1.7"] != part.Digest || len(b.said) != said {
|
||||
if _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.7-feat-z.1", Reason: "branch merged"}); err == nil || !strings.Contains(err.Error(), "shares its digest") || b.cat.tags["m/p:0.1.7"] != part.Digest || len(b.said) != said {
|
||||
t.Errorf("a store failing otherwise: %v, tags %v; want refused, 0.1.7 kept, nothing said", err, b.cat.tags)
|
||||
}
|
||||
b.cat.tagErr = nil
|
||||
@@ -513,7 +521,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err := b.v.Remove(ImageSeat, sweep, RemoveRequest{Name: name, Digest: c.remove, Reason: ReasonRetention})
|
||||
_, err := b.v.Remove(ImageSeat, sweep, RemoveRequest{Name: name, Digest: c.remove, Reason: ReasonRetention})
|
||||
var removed []string
|
||||
for _, r := range b.cat.removed {
|
||||
removed = append(removed, strings.TrimPrefix(r, name+"@"))
|
||||
|
||||
@@ -104,6 +104,8 @@ type Event struct {
|
||||
// Reference is the seat's answer: `<registry>/<module>/<artifact>@sha256:<digest>` (ADR 0605 §9).
|
||||
Reference string `json:"reference,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Tags are, for kept, the other tags still naming its digest.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
// By is who asked a removal, hold or release: the caller the tool runner proved.
|
||||
By string `json:"by,omitempty"`
|
||||
// ID is the event's id on the bus when the outcome has one of its own (a mirror ask's): the same outcome said
|
||||
@@ -116,6 +118,7 @@ const (
|
||||
EventPublished = "published"
|
||||
EventRefused = "refused"
|
||||
EventRemoved = "removed"
|
||||
EventKept = "kept"
|
||||
EventHeld = "held"
|
||||
EventReleased = "released"
|
||||
)
|
||||
|
||||
@@ -123,64 +123,78 @@ type RemoveRequest struct {
|
||||
// The seat definitions name it (the removal reasons' enum), so the sweep and the holder share one value.
|
||||
const ReasonRetention = reg.RemoveInputReasonRetention
|
||||
|
||||
// Removal is what remove answers: Removed, or Kept with its reason and the other tags naming its digest.
|
||||
type Removal struct {
|
||||
Removed bool
|
||||
Kept bool
|
||||
Reason string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// ReasonSharedDigest is why a removal was kept: another tag names its digest, and the store cannot remove a tag alone.
|
||||
const ReasonSharedDigest = reg.RemoveAnswerReasonSharedDigest
|
||||
|
||||
// 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 {
|
||||
func (v *Verbs) Remove(seat, caller string, r RemoveRequest) (Removal, error) {
|
||||
if err := v.Callers.Allowed(VerbRemove, caller); err != nil {
|
||||
return err
|
||||
return Removal{}, err
|
||||
}
|
||||
if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.Reason) == "" {
|
||||
return errors.New("remove: a name and a reason are required")
|
||||
return Removal{}, 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)
|
||||
return Removal{}, 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")
|
||||
return Removal{}, 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)
|
||||
return Removal{}, 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)
|
||||
return Removal{}, 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
|
||||
return Removal{}, 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)
|
||||
return Removal{}, 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)
|
||||
return Removal{}, fmt.Errorf("remove: the store cannot read %s@%s: %w", r.Name, r.Version, err)
|
||||
}
|
||||
if d != "" {
|
||||
// Reproducible builds share a digest between a branch's tagged version and the trunk's numbered one: a
|
||||
// manifest deleted by its digest would take the trunk's version with it (security review F1). When another
|
||||
// tag names the digest, the tag alone goes. A store that cannot remove a tag alone (registry 2.8.3) keeps the
|
||||
// tag: its bytes are kept under the other tags anyway, so the removal is said removed with that reason and
|
||||
// the builder lets the version go from its keeping, no refusal and no retry. Any other failure is refused.
|
||||
// version whole, its tag and its bytes, which the other tags name anyway: remove answers and says kept with
|
||||
// the reason shared-digest and those tags, no refusal, so the builder raises no retry. Any other failure is
|
||||
// refused.
|
||||
others, err := v.otherTags(r.Name, r.Version, d)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove: the store cannot say which other tags of %s name %s, so nothing is removed: %w", r.Name, d, err)
|
||||
return Removal{}, fmt.Errorf("remove: the store cannot say which other tags of %s name %s, so nothing is removed: %w", r.Name, d, err)
|
||||
}
|
||||
if len(others) > 0 {
|
||||
if err := v.Catalog.RemoveTag(r.Name, r.Version); errors.Is(err, ErrNoTagAlone) {
|
||||
sort.Strings(others)
|
||||
ev.Reason = fmt.Sprintf("%s; the store cannot remove a tag alone, so the tag %s is kept and its bytes stay under %s", r.Reason, r.Version, strings.Join(others, ", "))
|
||||
ev.Digest, ev.Reason, ev.Tags = d, ReasonSharedDigest, others
|
||||
kept := Removal{Kept: true, Reason: ReasonSharedDigest, Tags: others}
|
||||
return kept, v.Say(seat, EventKept, ev)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("remove: refused: %s@%s shares its digest %s with %v, and the store did not remove the tag alone, so nothing is removed: %w", r.Name, r.Version, d, others, err)
|
||||
return Removal{}, fmt.Errorf("remove: refused: %s@%s shares its digest %s with %v, and the store did not remove the tag alone, so nothing is removed: %w", r.Name, r.Version, d, others, err)
|
||||
}
|
||||
} else 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)
|
||||
return Removal{}, 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)
|
||||
return Removal{Removed: true}, v.Say(seat, EventRemoved, ev)
|
||||
}
|
||||
|
||||
// otherTags are the tags of name but version that name digest.
|
||||
|
||||
Reference in New Issue
Block a user