Files
mesh-sdk/go/delivery/placement_candidate.go
T

165 lines
6.6 KiB
Go

package delivery
import (
"encoding/json"
"fmt"
"strings"
)
// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees
// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull
// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the
// key `<group>.<node>` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is
// a state of its own, since the readers of `placement` refuse any key that is not `placement.<node>`.
//
// The node it names composes in shadow with the candidate (its own sealed values, the boxes it already runs) and
// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `<node>.node`
// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed
// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's
// write is the node's event, the `<node>.node` change the check's.
const (
// PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket.
PlacementCandidatesState = "placement-candidates"
PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState
// PlacementCandidatesHistory is the revisions the bucket keeps per key.
PlacementCandidatesHistory = 5
)
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group:
// `<group>.<node>`, the group escaped as CandidatesKey escapes it, so a node watches `*.<node>` and a group's keys are
// `<group>.*`.
func PlacementCandidateKey(group, node string) (string, error) {
g, err := groupKey(group)
if err != nil {
return "", err
}
if err := checkName("node", node); err != nil {
return "", err
}
return g + "." + node, nil
}
// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it.
func ParsePlacementCandidateKey(key string) (group, node string, err error) {
g, node, ok := strings.Cut(key, ".")
if !ok || checkName("node", node) != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <group>.<node>", key)
}
if group, err = parseGroupKey(g); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err)
}
return group, node, nil
}
// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement
// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file.
type PlacementCandidate struct {
V int `json:"v"`
Group string `json:"group"`
Pull Head `json:"pull"`
Placement *Placement `json:"placement,omitempty"`
}
func (c PlacementCandidate) check() error {
if c.Group == "" {
return fmt.Errorf("delivery: a placement candidate names no group")
}
if err := checkRepository(c.Pull.Repository); err != nil {
return err
}
if c.Pull.Number <= 0 || c.Pull.Head == "" {
return fmt.Errorf("delivery: a placement candidate needs its pull request's number and head")
}
if c.Placement != nil {
return c.Placement.check()
}
return nil
}
// EncodePlacementCandidate is c as JSON at Version; it refuses what DecodePlacementCandidate refuses.
func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) {
if err := c.check(); err != nil {
return nil, err
}
c.V = Version
if c.Placement != nil {
p := *c.Placement
p.V = Version
c.Placement = &p
}
return json.Marshal(c)
}
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or
// head, and a placement DecodePlacement refuses.
func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) {
var c PlacementCandidate
if err := decode(data, &c); err != nil {
return PlacementCandidate{}, err
}
if err := c.check(); err != nil {
return PlacementCandidate{}, err
}
return c, nil
}
// The verdicts of a node's answer to a candidate.
const (
// CandidateSame: every composition the node composes with the candidate is the one it runs, and it runs no other.
CandidateSame = "same"
// CandidateDiffers: some composition differs, is composed and not run, or is run and not composed (Differs).
CandidateDiffers = "differs"
// CandidateRefused: the node cannot compose with the candidate (Refused says why: an unbound requirement, a stand-in,
// a manifest it cannot read).
CandidateRefused = "refused"
)
// CandidateAnswer is a node's answer to its candidate of one group, in HaveNode.Candidates: the head and the key
// revision it answered, its verdict, the modules composed the same, those that differ, or why it could not compose.
// The node's own composition is named "node" (HaveNodeWord) in Same and Differs.
type CandidateAnswer struct {
Head string `json:"head"`
Revision uint64 `json:"revision"`
Verdict string `json:"verdict"`
Same []string `json:"same,omitempty"`
Differs []ModuleDiffers `json:"differs,omitempty"`
Refused string `json:"refused,omitempty"`
}
// ModuleDiffers is one module whose composition with the candidate is not the one the node runs: the first resource
// that differs by its identity (declaration.Resource's Identity), or empty when the whole module is composed and not run
// or run and not composed, and why in words.
type ModuleDiffers struct {
Module string `json:"module"`
Resource string `json:"resource,omitempty"`
Why string `json:"why"`
}
func (a CandidateAnswer) check() error {
if a.Head == "" {
return fmt.Errorf("delivery: a candidate answer names no head")
}
switch a.Verdict {
case CandidateSame:
if len(a.Differs) > 0 || a.Refused != "" {
return fmt.Errorf("delivery: a candidate answered same names a difference or a refusal")
}
case CandidateDiffers:
if len(a.Differs) == 0 || a.Refused != "" {
return fmt.Errorf("delivery: a candidate answered differs names no difference, or a refusal")
}
case CandidateRefused:
if a.Refused == "" || len(a.Same)+len(a.Differs) > 0 {
return fmt.Errorf("delivery: a candidate answered refused says no reason, or names modules")
}
default:
return fmt.Errorf("delivery: the candidate verdict %q is not same, differs or refused", a.Verdict)
}
for _, d := range a.Differs {
if d.Module == "" || d.Why == "" {
return fmt.Errorf("delivery: a candidate difference names no module or no why")
}
}
return nil
}