Files
mesh-host/internal/apply/fetch_first.go
T
jschoubben 22a37b758c Fetch every archive and process of an apply before anything is replaced (issue 1966, recovery)
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
2026-11-09 01:14:00 +01:00

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
}