diff --git a/modules/distribution/cmd/distribution-tools/contract.go b/modules/distribution/cmd/distribution-tools/contract.go index 8e724dd2..520e4cad 100644 --- a/modules/distribution/cmd/distribution-tools/contract.go +++ b/modules/distribution/cmd/distribution-tools/contract.go @@ -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} } diff --git a/modules/distribution/cmd/distribution-tools/contract_test.go b/modules/distribution/cmd/distribution-tools/contract_test.go index 0cc8fb8f..2e89953b 100644 --- a/modules/distribution/cmd/distribution-tools/contract_test.go +++ b/modules/distribution/cmd/distribution-tools/contract_test.go @@ -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() diff --git a/modules/distribution/cmd/distribution-tools/main.go b/modules/distribution/cmd/distribution-tools/main.go index de40893b..46ef39fa 100644 --- a/modules/distribution/cmd/distribution-tools/main.go +++ b/modules/distribution/cmd/distribution-tools/main.go @@ -20,6 +20,7 @@ import ( reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/parts" @@ -164,6 +165,12 @@ func learnInto(state casState, module, fp string, attempts int) (bool, error) { return false, fmt.Errorf("%s was written by another writer first %d times: %w", holder.PublishingKey, attempts, stdio.ErrChanged) } +// recordAct records a hand act through the seat mesh-hand-acts alone. +func recordAct(q handacts.Request) error { + _, err := handacts.Recorder{Ask: stdio.Ask}.Record(q) + return err +} + // mirrors is the state mirrors: an ask is created once; one already kept is the same copy, taken on already. type mirrors struct{} @@ -315,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"), @@ -409,12 +417,12 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. } idle, _ := v.DropIdle() if id := str(args, "drop"); id != "" { - if str(args, "why") == "" { - return nil, errors.New("a drop is a hand act: say why") + t, err := v.DropByHand(id, stdio.CallerOf(args), str(args, "why")) + if err != nil { + return nil, err } - t, err := v.Drop(id) logf("transfer %s dropped by hand by %s: %s", id, stdio.CallerOf(args), str(args, "why")) - return map[string]any{"dropped": t.ID}, err + return map[string]any{"dropped": t.ID}, nil } all, err := v.Transfers.List() used, _ := v.Objects.Used() @@ -498,10 +506,10 @@ func main() { base, _ := url.Parse("http://127.0.0.1:5001") store := &holder.OCI{Base: base, Upstream: &holder.Upstream{}} h := &holder.Holder{Objects: parts.BusObjects(holder.IncomingObjects), Builder: delivery.BuilderVersions{Ask: stdio.Ask}, Store: store, Say: say, Finding: finding, - Transfers: parts.BusTransfers[holder.Opening](holder.TransfersState), Cap: parts.BusCap(holder.IncomingObjects), Idle: parts.BusArm(holder.IdleState)} + Transfers: parts.BusTransfers[holder.Opening](holder.TransfersState), Cap: parts.BusCap(holder.IncomingObjects), Record: recordAct, Idle: parts.BusArm(holder.IdleState)} v := &holder.Verbs{Holder: h, Catalog: store, Holds: kept[holder.Hold]{state: "holds", key: holder.Hold.Key}, Mirrors: mirrors{}} go followMirrors(v) - go parts.FollowIdle(holder.IdleState, h.Expired, func(id string, dropped bool, err error) { + go parts.FollowIdle(holder.IdleState, parts.IDs(h.Transfers), h.Expired, func(id string, dropped bool, err error) { if err != nil { logf("%v", err) return diff --git a/modules/distribution/internal/holder/holder_test.go b/modules/distribution/internal/holder/holder_test.go index 36ae96ae..43318fb4 100644 --- a/modules/distribution/internal/holder/holder_test.go +++ b/modules/distribution/internal/holder/holder_test.go @@ -17,6 +17,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/handacts" ) type memObjects map[string][]byte @@ -298,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}) @@ -377,6 +381,23 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if _, err := b.h.Publish(ArtifactSeat, builder, req("t5")); err == nil || !strings.Contains(err.Error(), "idle bound") { t.Errorf("a publish whose idle key is not written = %v; want it failed loudly", err) } + // Seams note 2: registry_incoming's drop is a hand act recorded through mesh-hand-acts before anything goes; + // an act not recorded leaves the transfer open. + var acts []handacts.Request + b.h.Record = func(q handacts.Request) error { return errors.New("mesh-hand-acts has no holder") } + if _, err := b.h.DropByHand("t5", "person.jochen", "stuck"); err == nil || !strings.Contains(err.Error(), "stays open") { + t.Errorf("a drop whose act is not recorded = %v; want refused, t5 open", err) + } + b.h.Record = func(q handacts.Request) error { acts = append(acts, q); return nil } + if _, err := b.h.DropByHand("t5", "person.jochen", ""); err == nil || len(acts) != 0 { + t.Errorf("a drop with no why = %v, acts %v; want refused, nothing recorded", err, acts) + } + if got, err := b.h.DropByHand("t5", "person.jochen", "stuck"); err != nil || got.ID != "t5" || len(acts) != 1 || acts[0].Why != "stuck" || acts[0].Cause != "registry_incoming" || !strings.Contains(acts[0].What, "t5") || !strings.Contains(acts[0].What, "person.jochen") { + t.Errorf("a drop by hand = %+v, %v, acts %+v; want t5 dropped and its act recorded", got, err, acts) + } + if _, found, _ := b.h.Transfers.Get("t5"); found { + t.Errorf("t5 kept after its drop by hand") + } }) t.Run("incoming's finding at 80 %, cleared below", func(t *testing.T) { b := newBed(true) @@ -405,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 { @@ -426,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 @@ -495,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+"@")) diff --git a/modules/distribution/internal/holder/transfer.go b/modules/distribution/internal/holder/transfer.go index 599b105f..fcd04865 100644 --- a/modules/distribution/internal/holder/transfer.go +++ b/modules/distribution/internal/holder/transfer.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/parts" ) @@ -103,6 +104,8 @@ type Event struct { // Reference is the seat's answer: `//@sha256:` (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 @@ -115,6 +118,7 @@ const ( EventPublished = "published" EventRefused = "refused" EventRemoved = "removed" + EventKept = "kept" EventHeld = "held" EventReleased = "released" ) @@ -164,6 +168,9 @@ type Holder struct { Now func() time.Time // Cap reads incoming's cap from its binding (parts.BusCap); IncomingCap when nil or when the binding names none. Cap func() (uint64, error) + // Record records a hand act through the seat mesh-hand-acts (handacts.Recorder); a drop by hand is refused + // without it. + Record func(handacts.Request) error // Idle writes a transfer's idle key with its own time to live, once (parts.BusArm over IdleState); the receiver // arms it on each open that leaves a transfer open. Nil arms nothing. Idle func(id string, ttl time.Duration) error @@ -288,6 +295,29 @@ func (h *Holder) eventOf(seat string, r PublishRequest, ref, reason string) Even // 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) { return h.receiver().Drop(id) } +// DropByHand is registry_incoming's drop: by a person, with why, recorded as a hand act through mesh-hand-acts before +// anything goes; an act not recorded leaves the transfer open. +func (h *Holder) DropByHand(id, by, why string) (Transfer, error) { + if strings.TrimSpace(why) == "" { + return Transfer{}, errors.New("a drop is a hand act: say why") + } + if h.Record == nil { + return Transfer{}, errors.New("no hand act can be recorded here, so nothing is dropped") + } + t, found, err := h.Transfers.Get(id) + if err != nil { + return Transfer{}, fmt.Errorf("transfer %s cannot be read, so nothing is dropped: %w", id, err) + } + if !found { + return Transfer{}, fmt.Errorf("transfer %s is not open", id) + } + what := fmt.Sprintf("dropped the open transfer %s of incoming (%s@%s on %s, opened by %s, %d parts lacked), by %s", id, t.Shape.Name, t.Shape.Version, t.Shape.Seat, t.Opener, len(t.Lacks()), by) + if err := h.Record(handacts.Request{What: what, Why: why, Cause: "registry_incoming"}); err != nil { + return Transfer{}, fmt.Errorf("the hand act cannot be recorded, so transfer %s stays open: %w", id, err) + } + return h.Drop(id) +} + // DropIdle drops every transfer with no part for TransferIdle, answering them: registry_incoming's look, by hand. The // drop itself follows each transfer's idle key (Expired); the objects template keeps for good, so nothing else does. func (h *Holder) DropIdle() ([]string, error) { return h.receiver().DropIdle() } diff --git a/modules/distribution/internal/holder/verbs.go b/modules/distribution/internal/holder/verbs.go index dd184de7..33c64a2b 100644 --- a/modules/distribution/internal/holder/verbs.go +++ b/modules/distribution/internal/holder/verbs.go @@ -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. diff --git a/modules/distribution/module.json b/modules/distribution/module.json index bf8c4f4b..cfc9b8f2 100644 --- a/modules/distribution/module.json +++ b/modules/distribution/module.json @@ -55,7 +55,8 @@ "distribution.registry_callers", "mesh-delivery-builder.builder_versions", "mesh-delivery-view.node_haves", - "seat:node-engine.manifest-reads" + "seat:node-engine.manifest-reads", + "seat:mesh-hand-acts.hand-act" ], "consumes": [ "mesh-delivery-view.delivered", diff --git a/modules/gitea/cmd/agent-teams/retire.go b/modules/gitea/cmd/agent-teams/retire.go index 8da05e44..0ed7b2c8 100644 --- a/modules/gitea/cmd/agent-teams/retire.go +++ b/modules/gitea/cmd/agent-teams/retire.go @@ -19,10 +19,17 @@ import ( // seats came, made each consumer of npm-package-registry an account under the mesh's derived login // (mesh__) and put it in the organisation's team packages (read and write) or readers (read only). // Reads of the packages are anonymous and every write goes through the seats, so both teams go, and each account of -// that form in no team but them goes with them. Nothing else is touched: a team of another name, an account of -// another form or one still in a team the module does not retire stays as it is. +// that form in no team but them goes with them. Only what the provisioner made goes: a team of those names in its +// organisation (RetiredOrg) is the provisioner's when every member of it is an account of the provisioner's form and +// it has one at least, since the provisioner put its own accounts in them alone and nobody else ever made one. A team +// a person makes later under such a name holds no such account (they are gone with the first run), so it stays, and +// so does a team of those names with a person in it or with no member. Nothing else is touched: a team of another +// name or organisation, an account of another form or one still in a team the module does not retire stays as it is. var RetiredTeams = []string{"packages", "readers"} +// RetiredOrg is the organisation the npm pair provisioner made its teams in (its ORG, the npm registry's owner). +const RetiredOrg = "novox" + // retiredAccount is the login form the npm pair provisioner made its accounts under. var retiredAccount = regexp.MustCompile(`^mesh_[a-z0-9-]+_[a-z0-9_]+$`) @@ -37,6 +44,7 @@ func (s *Syncer) Retire(ctx context.Context, orgs []string) error { team forgeTeam } var gone []retired + retiring := map[string]bool{} // org/team for _, org := range orgs { teams, err := s.teams(ctx, org) if err != nil { @@ -50,15 +58,14 @@ func (s *Syncer) Retire(ctx context.Context, orgs []string) error { for _, m := range members { teamsOf[m.Login] = append(teamsOf[m.Login], org+"/"+t.Name) } - if slices.Contains(RetiredTeams, t.Name) { + made := len(members) > 0 && !slices.ContainsFunc(members, func(m forgeMember) bool { return !retiredAccount.MatchString(m.Login) }) + if org == RetiredOrg && slices.Contains(RetiredTeams, t.Name) && made { gone = append(gone, retired{org, t}) + retiring[org+"/"+t.Name] = true } } } - isRetired := func(orgTeam string) bool { - _, team, _ := strings.Cut(orgTeam, "/") - return slices.Contains(RetiredTeams, team) - } + isRetired := func(orgTeam string) bool { return retiring[orgTeam] } var accounts []string for login, in := range teamsOf { if retiredAccount.MatchString(login) && slices.ContainsFunc(in, isRetired) && !slices.ContainsFunc(in, func(t string) bool { return !isRetired(t) }) { diff --git a/modules/gitea/cmd/agent-teams/teams_test.go b/modules/gitea/cmd/agent-teams/teams_test.go index a6800757..c5e746a1 100644 --- a/modules/gitea/cmd/agent-teams/teams_test.go +++ b/modules/gitea/cmd/agent-teams/teams_test.go @@ -365,11 +365,15 @@ func TestReadValue(t *testing.T) { // form still in another team, an account of another form and every other team stay; a second run changes nothing. func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) { f := newFake([]string{"novox"}, "mesh_ace_mesh_build_node", "mesh_shanks_lab", "mesh_g14_agent", "mesh_novox_owner", - "mesh-packages", "agent-ace", "jschoubben") + "mesh-packages", "agent-ace", "jschoubben", "mesh_ace_agent") f.owners = map[string]bool{"mesh_novox_owner": true} - f.add("novox", "packages", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_g14_agent", "mesh_novox_owner", "jschoubben"}) + f.add("novox", "packages", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_g14_agent", "mesh_novox_owner"}) f.add("novox", "readers", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_shanks_lab"}) f.add("novox", "agents", "none", nil, []string{"mesh_g14_agent", "agent-ace"}) + // Catalog note: a team of a retired name the provisioner did not make (a person in it, or in another + // organisation) stays. + f.orgs = append(f.orgs, "other") + f.add("other", "readers", "none", nil, []string{"mesh_ace_agent"}) f.add("novox", "mesh-packages", "none", nil, []string{"mesh-packages"}) s, said := syncer(t, f) if err := s.Sync(context.Background(), Value{}); err != nil { @@ -382,7 +386,7 @@ func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) { t.Errorf("a team the module did not retire was removed: %+v", f.teams) } for u, want := range map[string]bool{"mesh_ace_mesh_build_node": false, "mesh_shanks_lab": false, "mesh_g14_agent": true, - "mesh_novox_owner": true, "mesh-packages": true, "agent-ace": true, "jschoubben": true} { + "mesh_novox_owner": true, "mesh-packages": true, "agent-ace": true, "jschoubben": true, "mesh_ace_agent": true} { if f.users[u] != want { t.Errorf("account %s kept %v, want %v", u, f.users[u], want) } @@ -390,8 +394,17 @@ func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) { if !f.prohibited["mesh_novox_owner"] || len(f.prohibited) != 1 { t.Errorf("prohibited %v, want mesh_novox_owner alone", f.prohibited) } + if f.byName("other", "readers") == nil { + t.Errorf("a readers team in another organisation was removed") + } + // A team a person makes after the first run under a retired name, with persons in it or none, stays. + f.add("novox", "packages", "none", nil, []string{"jschoubben"}) + f.add("novox", "readers", "none", nil, nil) before := len(*said) if err := s.Sync(context.Background(), Value{}); err != nil || len(*said) != before { t.Errorf("a second run: %v, said %v; want nothing", err, (*said)[before:]) } + if f.byName("novox", "packages") == nil || f.byName("novox", "readers") == nil || !f.users["jschoubben"] { + t.Errorf("a team a person made later under a retired name was removed: %+v", f.teams) + } } diff --git a/modules/gitea/cmd/package-registry/incoming.go b/modules/gitea/cmd/package-registry/incoming.go index 9ad5c3c7..3473dab3 100644 --- a/modules/gitea/cmd/package-registry/incoming.go +++ b/modules/gitea/cmd/package-registry/incoming.go @@ -6,7 +6,6 @@ package main // when its idle key ends (the bus's marker), never on a timer. import ( - "errors" "fmt" "os" "strings" @@ -36,10 +35,7 @@ func incomingTool(typ, seat string, holder func() (*packageseats.Holder, error)) return nil, err } if id := str(args, "drop"); id != "" { - if str(args, "why") == "" { - return nil, errors.New("a drop is a hand act: say why") - } - t, err := h.Drop(seat, id) + t, err := h.DropByHand(seat, id, stdio.CallerOf(args), str(args, "why")) if err != nil { return nil, err } @@ -51,9 +47,17 @@ func incomingTool(typ, seat string, holder func() (*packageseats.Holder, error)) } } -// followIdle drops a transfer when its idle key ends (packageseats.Holder.Expired): the bus's marker is the event. +// followIdle drops a transfer when its idle key ends (packageseats.Holder.Expired): the bus's marker is the event; at the +// watch's start every open transfer is looked at too, so one that ended while the bundle was down is dropped. func followIdle(holder func() (*packageseats.Holder, error)) { - parts.FollowIdle(packageseats.IdleState, func(id string) (bool, error) { + open := func() ([]string, error) { + h, err := holder() + if err != nil { + return nil, fmt.Errorf("the holder cannot be made: %w", err) + } + return parts.IDs(h.Transfers)() + } + parts.FollowIdle(packageseats.IdleState, open, func(id string) (bool, error) { h, err := holder() if err != nil { return false, fmt.Errorf("transfer %s's idle bound ended, but the holder cannot be made: %w", id, err) diff --git a/modules/gitea/cmd/package-registry/seats.go b/modules/gitea/cmd/package-registry/seats.go index 36a0ff90..eed19585 100644 --- a/modules/gitea/cmd/package-registry/seats.go +++ b/modules/gitea/cmd/package-registry/seats.go @@ -31,6 +31,7 @@ import ( npmreg "git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/parts" @@ -263,6 +264,12 @@ func finding(kind, subject, words string, raised bool) { } } +// recordAct records a hand act through the seat mesh-hand-acts alone. +func recordAct(q handacts.Request) error { + _, err := handacts.Recorder{Ask: stdio.Ask}.Record(q) + return err +} + // boundCap is incoming's cap from its binding, read once per bundle. var boundCap = parts.BusCap(packageseats.IncomingObjects) @@ -275,7 +282,7 @@ func busHolder(forge func() (*Forge, error)) (*packageseats.Holder, error) { return &packageseats.Holder{Callers: callers, Objects: parts.BusObjects(packageseats.IncomingObjects), Transfers: parts.BusTransfers[packageseats.Opening](packageseats.TransfersState), Builder: delivery.BuilderVersions{Ask: stdio.Ask}, Store: seatStore{forge}, Say: say, Finding: finding, - Cap: boundCap, Idle: parts.BusArm(packageseats.IdleState)}, nil + Cap: boundCap, Record: recordAct, Idle: parts.BusArm(packageseats.IdleState)}, nil } // SeatTools are both seats' verbs as tools .. diff --git a/modules/gitea/internal/packageseats/holder.go b/modules/gitea/internal/packageseats/holder.go index 557fa181..3b586704 100644 --- a/modules/gitea/internal/packageseats/holder.go +++ b/modules/gitea/internal/packageseats/holder.go @@ -13,6 +13,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/parts" ) @@ -156,6 +157,9 @@ type Holder struct { Now func() time.Time // Cap reads incoming's cap from its binding (parts.BusCap); IncomingCap when nil or when the binding names none. Cap func() (uint64, error) + // Record records a hand act through the seat mesh-hand-acts (handacts.Recorder); a drop by hand is refused + // without it. + Record func(handacts.Request) error // Idle writes a transfer's idle key with its own time to live, once (parts.BusArm over IdleState); the receiver // arms it on each open that leaves a transfer open. Nil arms nothing. Idle func(key string, ttl time.Duration) error @@ -334,6 +338,29 @@ func (h *Holder) Drop(seat, transfer string) (Transfer, error) { return h.receiver().Drop(key(seat, transfer)) } +// DropByHand is _incoming's drop: by a person, with why, recorded as a hand act through mesh-hand-acts before +// anything goes; an act not recorded leaves the transfer open. +func (h *Holder) DropByHand(seat, transfer, by, why string) (Transfer, error) { + if strings.TrimSpace(why) == "" { + return Transfer{}, errors.New("a drop is a hand act: say why") + } + if h.Record == nil { + return Transfer{}, errors.New("no hand act can be recorded here, so nothing is dropped") + } + t, found, err := h.Transfers.Get(key(seat, transfer)) + if err != nil { + return Transfer{}, fmt.Errorf("transfer %s cannot be read, so nothing is dropped: %w", transfer, err) + } + if !found { + return Transfer{}, fmt.Errorf("transfer %s of %s is not open", transfer, seat) + } + what := fmt.Sprintf("dropped the open transfer %s of %s's incoming (%s@%s, opened by %s), by %s", transfer, seat, t.Shape.Name, t.Shape.Version, t.Opener, by) + if err := h.Record(handacts.Request{What: what, Why: why, Cause: Seats[seat] + "_incoming"}); err != nil { + return Transfer{}, fmt.Errorf("the hand act cannot be recorded, so transfer %s stays open: %w", transfer, err) + } + return h.Drop(seat, transfer) +} + // Incoming is what npm_incoming and go_incoming answer of a seat: its open transfers, incoming's fill (both seats // share the store) and the cap its binding carries. type Incoming struct { diff --git a/modules/gitea/internal/packageseats/holder_test.go b/modules/gitea/internal/packageseats/holder_test.go index 700001c0..bf814fea 100644 --- a/modules/gitea/internal/packageseats/holder_test.go +++ b/modules/gitea/internal/packageseats/holder_test.go @@ -9,6 +9,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/parts" ) @@ -190,6 +191,20 @@ func TestTheForgeHoldsBothPackageSeats(t *testing.T) { if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("d", "0.1.0", file)); err == nil || !strings.Contains(err.Error(), "idle bound") { t.Errorf("a publish whose idle key is not written = %v; want it failed loudly", err) } + // Seams note 2: go_incoming's drop is a hand act recorded through mesh-hand-acts before anything goes; an act + // not recorded leaves the transfer open. + var acts []handacts.Request + b.h.Record = func(handacts.Request) error { return errors.New("mesh-hand-acts has no holder") } + if _, err := b.h.DropByHand(GoSeat, "t-b", "person.jochen", "stuck"); err == nil || !strings.Contains(err.Error(), "stays open") { + t.Errorf("a drop whose act is not recorded = %v; want refused, t-b open", err) + } + b.h.Record = func(q handacts.Request) error { acts = append(acts, q); return nil } + if got, err := b.h.DropByHand(GoSeat, "t-b", "person.jochen", "stuck"); err != nil || got.Shape.Name != "b" || len(acts) != 1 || acts[0].Cause != "go_incoming" || !strings.Contains(acts[0].What, "t-b") || !strings.Contains(acts[0].What, "person.jochen") { + t.Errorf("a drop by hand = %+v, %v, acts %+v; want t-b dropped and its act recorded", got, err, acts) + } + if in, _ := b.h.IncomingOf(GoSeat); len(in.Transfers) != 0 { + t.Errorf("after the drop by hand: %+v; want no Go transfer", in) + } }) t.Run("refusals", func(t *testing.T) { for _, c := range []struct {