On 2026-11-08 23:25 UTC the node-engine replaced distribution's store container and only then fetched distribution's program from the store it had just cut off: the module was left half applied and the artifact store unreachable from every other machine. An apply now fetches and checks every archive and process first, as it already pulls a module's images first; one that cannot be had gates its module, so none of its resources is replaced and none of its orphans removed. Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
103 lines
4.1 KiB
Go
103 lines
4.1 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/declaration"
|
|
)
|
|
|
|
// **Every artifact an apply fetches is in hand before anything is replaced** (novox/hq issue 1966, 2026-11-08 23:25 UTC).
|
|
//
|
|
// An archive and a process are fetched from the artifact store on their own turn, and a container is replaced on its
|
|
// own: so a module whose container serves the store (distribution) had that container replaced, cut off from where it
|
|
// was reached, and then failed to fetch its program from the store it had just cut off, left half applied with the
|
|
// store unreachable from every other machine. pullFirst does this for a module's images (ENG-7); fetchFirst does it for
|
|
// its archives and processes, before any of the apply's resources is applied or any orphan removed.
|
|
//
|
|
// A fetch that fails (the store does not answer for the apply's patience, or what arrived is not what was declared)
|
|
// gates the resource's module, as a pull that fails does: nothing of the module is replaced or removed, the failure names
|
|
// the artifact, and the rest of the machine is applied. A resource of no module keeps its failure for its own turn.
|
|
|
|
// fetchedArtifact is one archive's or process's bytes, fetched and checked before the apply began, or why it was not.
|
|
type fetchedArtifact struct {
|
|
body []byte
|
|
err error
|
|
}
|
|
|
|
// artifactOf is the archive's or process's source and digest, and whether the resource is one the apply fetches.
|
|
func artifactOf(r declaration.Resource) (source, digest string, ok bool) {
|
|
switch res := r.(type) {
|
|
case *declaration.Archive:
|
|
return res.Source, res.Digest, res.Source != ""
|
|
case *declaration.Process:
|
|
return res.Source, res.Digest, res.Source != ""
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// fetchFirst fetches each archive and process the declaration applies, checks each against its declared digest, and
|
|
// keeps the bytes for its turn (in.fetched). It is called before the loop that applies, after pullFirst.
|
|
func fetchFirst(ctx context.Context, ordered []declaration.Resource, d *declaration.Declaration, in *inputs,
|
|
failures *[]*Error, gated map[string]bool, gatedWhy map[string]string, log func(string)) {
|
|
in.fetched = map[string]fetchedArtifact{}
|
|
bySource := map[string]fetchedArtifact{}
|
|
for _, resource := range ordered {
|
|
source, digest, isArtifact := artifactOf(resource)
|
|
if !isArtifact {
|
|
continue
|
|
}
|
|
module, ours := d.ModuleOfID(resource.Identity())
|
|
if ours && gated[module] {
|
|
continue
|
|
}
|
|
// On an adopted node, what a module not yet taken declares may be held as found and never fetched (hold.go): its
|
|
// own turn fetches it, as before.
|
|
if d.Adoption != nil {
|
|
if _, untaken := d.Adoption.UntakenModuleOf(resource.Identity()); untaken || runsIn(resource) != "" {
|
|
continue
|
|
}
|
|
}
|
|
got, seen := bySource[source]
|
|
if !seen {
|
|
got.body, got.err = fetchMinding(ctx, source, in.away)
|
|
bySource[source] = got
|
|
}
|
|
if got.err == nil {
|
|
sum := sha256.Sum256(got.body)
|
|
if have := "sha256:" + hex.EncodeToString(sum[:]); have != digest {
|
|
got = fetchedArtifact{err: fmt.Errorf("%s was declared as %s and what arrived is %s; nothing was unpacked",
|
|
source, digest, have)}
|
|
}
|
|
}
|
|
if got.err == nil {
|
|
in.fetched[resource.Identity()] = got
|
|
continue
|
|
}
|
|
if !ours {
|
|
// Nothing to gate: the resource fails on its own turn with this error.
|
|
in.fetched[resource.Identity()] = got
|
|
continue
|
|
}
|
|
err := fmt.Errorf("not replacing anything of its module, which keeps running what it ran: %w", got.err)
|
|
*failures = append(*failures, &Error{Resource: resource.Identity(), Err: err})
|
|
log(fmt.Sprintf(" failed %s (%s): %v", resource.Identity(), resource.Target(), err))
|
|
gated[module] = true
|
|
gatedWhy[module] = "its artifact " + source + " could not be fetched, so nothing of it was replaced " +
|
|
"(novox/hq issue 1966)"
|
|
}
|
|
}
|
|
|
|
// artifact is the bytes fetchFirst holds for a resource, taken once so they are not kept past their turn; nil and no
|
|
// error when it holds none (an apply that did not fetch first: its applier fetches itself).
|
|
func (in inputs) artifact(id string) ([]byte, error) {
|
|
got, ok := in.fetched[id]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
delete(in.fetched, id)
|
|
return got.body, got.err
|
|
}
|