165 lines
6.6 KiB
Go
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
|
|
}
|