Documentation
¶
Overview ¶
Package peerimage is the PEER half of the DKS "recipes, not blobs" image distribution model (docs/peer-dks-image-distribution.md). It is the peer-plane sibling of internal/agent/recipebuilder, which is the cloudbox-driven half.
The model, unchanged from the design: a RECIPE (an indexed Dockerfile + build context + its sha256) travels between nodes; an image BLOB never does. Base images come from a public registry/CDN; app images are built locally on every node from the recipe. This package adds four verbs on top of that:
publish persist a recipe locally + serve it to peers over the mesh mesh-resolve find the DISTINCT peers exposing the recipe service ensure make the recipe's image resident on THIS node, and prove it report emit identity-bound evidence that this node is in that state
Four invariants shape every function here:
- No cloudbox on the peer execution path. The transport is the existing mesh forwarder (an allowlisted loopback service), never a new overlay.
- Absence of evidence is never success. An unreachable node, an empty listing, an unreadable digest and a missing report are each a FAILURE with its own message — none of them collapses into "already satisfied".
- What is resident is decided by the containerd CONTENT DIGEST, never by a podman/ctr reference, which can be stale, retagged, or point at other bytes. A digest that disagrees with the recorded provenance fails loudly and is never "repaired" by fetching something else.
- Evidence is bound to one identity. A report carries the (node, ref, recipe, nonce) tuple it was challenged with; anything else — a node that was never asked, a nonce belonging to another node, a second report from the same node — is refused. See inspect.go.
Index ¶
- Variables
- func RandomNonce() (string, error)
- func ValidContentDigest(s string) bool
- func ValidRecipeDigest(s string) bool
- type AddrPolicy
- type Builder
- type Challenge
- type CtrRuntime
- type DigestState
- type EnsureResult
- type Fetcher
- type Inspector
- type NodeIdentity
- type NonceFunc
- type PeerRef
- type Provenance
- type Publication
- type RecipeBuilder
- type Report
- type ResolveResult
- type Runtime
- type Service
- func (s *Service) Ensure(ctx context.Context, name string) (EnsureResult, error)
- func (s *Service) FetchRecipe(ctx context.Context, forwardAddr, name string) (Publication, error)
- func (s *Service) IndexHandler() http.Handler
- func (s *Service) MeshResolve(_ context.Context, service string, minimum int) (ResolveResult, error)
- func (s *Service) Publications() ([]Publication, error)
- func (s *Service) Publish(_ context.Context, name, body string) (Publication, error)
- func (s *Service) Report(ctx context.Context, ch Challenge) (Report, error)
- type Store
- type Summary
- type Verdict
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNoRecipe — the recipe is not in the local store. An unknown recipe is // a failure, never "nothing to do". ErrNoRecipe = errors.New("recipe not found") // ErrDigestMismatch — the live containerd digest disagrees with the // recorded provenance. Loud by design; never auto-repaired. ErrDigestMismatch = errors.New("resident content digest does not match recorded provenance") // ErrDigestUnknown — the ref exists but its content digest is unreadable. ErrDigestUnknown = errors.New("could not determine the resident content digest") // ErrNotResident — the runtime answered and the ref is absent. ErrNotResident = errors.New("image is not present in the node's containerd") // ErrNoProvenance — the ref is resident but this node has no record of // building it, so its bytes cannot be attributed to any recipe. ErrNoProvenance = errors.New("no local provenance for the resident image") // ErrNoPeers — nothing exposes the recipe service. An empty listing is a // failure, not a satisfied precondition. ErrNoPeers = errors.New("no peer exposes the recipe service") // ErrNotDistinct — fewer DISTINCT peers/nodes than the operation claims. ErrNotDistinct = errors.New("not enough distinct peers") )
Errors surfaced by this package. Callers map them onto their protocol's status; the shell harness mirrors them by name.
var ErrLoopbackTarget = errors.New("refusing to fetch a loopback/link-local address")
ErrLoopbackTarget is returned when a URL — at ANY hop — resolves to an address the daemon must never be talked into fetching. The daemon's own admin/MCP surface listens on loopback, so a redirect that lands there is an SSRF primitive against this process, not a routing detail.
Functions ¶
func RandomNonce ¶
RandomNonce is the production NonceFunc: 128 bits of crypto/rand, hex.
func ValidContentDigest ¶
ValidContentDigest reports whether s is a well-formed "sha256:<64 hex>". Anything else — empty, truncated, a bare hex string, a ref that happens to look like a digest — is NOT a digest and must not be treated as evidence.
func ValidRecipeDigest ¶
ValidRecipeDigest reports whether s is a well-formed "sha256:<64 hex>".
Types ¶
type AddrPolicy ¶
type AddrPolicy struct {
// AllowLoopback is the exact "host:port" set permitted to resolve to a
// loopback address. Populate it with the mesh forward listeners this
// daemon opened, and nothing else.
AllowLoopback map[string]struct{}
// Resolve maps a hostname to IPs. nil → net.DefaultResolver. Injected so
// the multi-hop tests run offline with no DNS.
Resolve func(ctx context.Context, host string) ([]net.IP, error)
}
AddrPolicy decides which URLs the recipe fetcher may contact.
The peer path legitimately fetches over loopback: a mesh forward is a local TCP listener this daemon opened itself. So the rule is not "no loopback" but "no loopback OTHER than the exact forward addresses we opened" — which is why the allowance is an exact host:port set rather than a subnet.
func Allow ¶
func Allow(addrs ...string) AddrPolicy
Allow returns a policy permitting exactly these host:port loopback targets.
type Builder ¶
Builder materializes a recipe into the node's containerd: resolve context → native build → load. It is an interface so Ensure is testable with neither podman nor a cluster.
type Challenge ¶
type Challenge struct {
Node string `json:"node"`
Ref string `json:"ref"`
Recipe string `json:"recipe_digest"`
Nonce string `json:"nonce"`
}
Challenge is one node's identity-bound ask. All four fields travel together and a Report must echo all four: the node it is about, the ref it is about, the recipe digest it is expected to descend from, and a nonce that exists only for this (node, ref, recipe) triple.
The nonce is what makes evidence non-transferable. Without it, a report is just a claim about a node name, and one node's answer can be replayed as another's — which is exactly how an "N nodes reached" result gets fabricated from one node.
type CtrRuntime ¶
type CtrRuntime struct {
// BashyBin is the resolved bashy executable.
BashyBin string
// BashyPath, when set, resolves the bashy executable lazily on each call
// and takes precedence over BashyBin. The daemon wires this to its
// self-healing resolver so constructing the service never blocks boot on
// provisioning a missing userland — the first digest read resolves it.
BashyPath func(ctx context.Context) (string, error)
// Container is the <node-name>-runtime container hosting k3s containerd.
Container string
// Namespace is the containerd namespace; empty → k8s.io (what k3s uses
// for images the kubelet can actually run).
Namespace string
// contains filtered or unexported fields
}
CtrRuntime reads the resident content digest out of a node's k3s containerd by execing into the <node>-runtime container, the same path recipebuilder uses to load images. It deliberately asks containerd (`ctr images ls`) rather than podman: the podman-side tag is a build artifact that can be stale or retagged, while containerd's DIGEST column is the bytes the kubelet will run.
func (CtrRuntime) ResidentDigest ¶
func (c CtrRuntime) ResidentDigest(ctx context.Context, ref string) (DigestState, string, error)
ResidentDigest returns the tri-state answer for ref.
A failure to consult the runtime returns an error — never StateAbsent. That distinction is the whole point: an unreachable runtime must not read as "the image simply isn't there", which a caller could then satisfy by building.
type DigestState ¶
type DigestState string
DigestState is the tri-state answer to "what is actually resident under this ref?". The three values are deliberately distinct: "I could not determine the digest" is NOT "the image is absent", and neither one is a pass.
const ( // StateResident — the ref exists and a well-formed content digest was read. StateResident DigestState = "resident" // StateAbsent — the runtime answered, and the ref is not in its image store. StateAbsent DigestState = "absent" // StateUnknown — the ref exists but its content digest could not be // determined. Never treat this as either resident or absent. StateUnknown DigestState = "unknown" )
type EnsureResult ¶
type EnsureResult struct {
Node string `json:"node"`
Recipe string `json:"recipe"`
Ref string `json:"ref"`
RecipeDigest string `json:"recipe_digest"`
State DigestState `json:"state"`
ContentDigest string `json:"content_digest,omitempty"`
// Built is true when this call performed the build; false means the image
// was already resident AND its digest correlated with the provenance.
Built bool `json:"built"`
}
EnsureResult describes the state this node ended in.
type Fetcher ¶
type Fetcher struct {
Policy AddrPolicy
// Client is the underlying transport. Its CheckRedirect is overwritten —
// this type never lets the stdlib follow a redirect on its own.
Client *http.Client
}
Fetcher performs recipe fetches with redirects DISABLED at the transport and re-driven explicitly, so the policy runs before every single request.
http.Client's CheckRedirect hook is not used to make the decision. Returning an error from it after the fact still leaves the redirect semantics inside the client; driving the chain here makes "validate, then request" the only order that exists.
func NewFetcher ¶
func NewFetcher(policy AddrPolicy) *Fetcher
NewFetcher builds a Fetcher with redirects disabled and a bounded timeout.
type Inspector ¶
type Inspector struct {
// contains filtered or unexported fields
}
Inspector collects identity-bound evidence from a fixed, DISTINCT set of nodes and decides whether the claim holds. It is a pure state machine — no I/O, no clock, no randomness once constructed — so both the Go callers and the shell harness can be tested to the same rules offline.
func NewInspector ¶
func NewInspector(ref, recipeDigest string, nodes []string, minimum int, nonce NonceFunc) (*Inspector, error)
NewInspector builds an Inspector for ref@recipeDigest over nodes.
It REFUSES to start unless the node set is genuinely distinct and at least `minimum` large. This is the requirement-2 gate: an operation that claims to reach N nodes must be constructed from N distinct node NAMES up front, so a duplicated node cannot inflate the count later. Node names carry the per-backend discriminator; the outpost.dhnt.io/host LABEL does not (two virtual backends on one host share it), so it is never an identity here.
func (*Inspector) Accept ¶
Accept admits one report, or refuses it with the rule that refused it.
The order of the checks matters: identity is settled BEFORE the observation is read, so a forged or replayed report is never scored on its contents.
func (*Inspector) Challenges ¶
Challenges returns the per-node asks, sorted by node name for determinism.
type NodeIdentity ¶
type NodeIdentity struct {
Name string
}
NodeIdentity is how this daemon names itself in evidence. Name is the cluster NODE name (which carries the per-backend discriminator), not the host name: one host running two virtual backends is TWO nodes, and evidence that cannot tell them apart cannot prove it reached two.
type PeerRef ¶
type PeerRef struct {
Host string `json:"host"`
PeerID string `json:"peer_id"`
Services []string `json:"services,omitempty"`
}
PeerRef is one peer from the mesh service registry. It is deliberately the same shape admincore.MeshResolvedPeer carries, minus the import.
type Provenance ¶
type Provenance struct {
// Node is the cluster node name this provenance belongs to — the node
// NAME, never the outpost.dhnt.io/host label, which names a HOST and is
// shared by every virtual backend that host runs.
Node string `json:"node"`
// Ref is the image reference the recipe builds to.
Ref string `json:"ref"`
// Recipe is the recipe name.
Recipe string `json:"recipe"`
// RecipeDigest is sha256:<hex> over the recipe's canonical form — the
// CROSS-NODE identity. Two nodes running the same recipe agree here.
RecipeDigest string `json:"recipe_digest"`
// ContentDigest is the containerd content digest observed immediately
// after the local build+load — the PER-NODE identity. Container builds
// are not bit-reproducible, so two nodes running the same recipe are
// expected to differ here; that is why the cross-node claim is made on
// RecipeDigest and the local integrity claim on ContentDigest.
ContentDigest string `json:"content_digest"`
// BuiltAt is when the build+load completed.
BuiltAt time.Time `json:"built_at"`
}
Provenance is what THIS node recorded when it built and loaded a ref. It is the local anchor the live containerd digest is correlated against: the ref is mutable (a retag points it at different bytes without changing its name), the content digest is not.
type Publication ¶
type Publication struct {
Name string `json:"name"`
Ref string `json:"ref"`
Digest string `json:"recipe_digest"`
PublishedAt time.Time `json:"published_at"`
}
Publication is one published recipe as the index reports it. It carries no build context — a peer fetches the recipe document itself to get that.
type RecipeBuilder ¶
type RecipeBuilder struct {
Runner recipebuilder.Runner
WorkDir string
Platform string
RuntimeContainer string
}
RecipeBuilder adapts recipebuilder's native build+load sequence to the Builder interface, so the peer path and the cloudbox-polling path build images the exact same way.
func (RecipeBuilder) Materialize ¶
func (b RecipeBuilder) Materialize(ctx context.Context, body string) error
Materialize parses the recipe document and runs the shared build sequence. Empty Platform/WorkDir take recipebuilder's own defaults (native platform, a temp workdir) so a caller cannot accidentally build an unlabeled target.
type Report ¶
type Report struct {
Node string `json:"node"`
Ref string `json:"ref"`
Recipe string `json:"recipe_digest"`
Nonce string `json:"nonce"`
// State is the tri-state containerd answer.
State DigestState `json:"state"`
// ContentDigest is what containerd says is resident RIGHT NOW.
ContentDigest string `json:"content_digest,omitempty"`
// ProvenanceDigest is what this node recorded when it built the ref.
// Empty means the node cannot attribute the resident bytes to any recipe.
ProvenanceDigest string `json:"provenance_digest,omitempty"`
// Detail is a human note; never authoritative, never trusted.
Detail string `json:"detail,omitempty"`
}
Report is one node's answer to its Challenge. The first four fields are the echoed identity; the rest is the observation.
type ResolveResult ¶
type ResolveResult struct {
Service string `json:"service"`
Peers []PeerRef `json:"peers"`
// Distinct is the number of DISTINCT peer identities, which is what any
// "reaches N peers" claim must be made from.
Distinct int `json:"distinct"`
}
ResolveResult is a mesh-resolve outcome.
type Runtime ¶
type Runtime interface {
ResidentDigest(ctx context.Context, ref string) (DigestState, string, error)
}
Runtime reads what is actually resident in a node's containerd.
Implementations MUST distinguish the three DigestStates and MUST return a non-nil error (rather than StateAbsent) when the runtime could not be consulted at all — an unreachable runtime is not an absent image.
type Service ¶
type Service struct {
// Identity names THIS node in every piece of evidence it emits.
Identity NodeIdentity
// Store holds published recipes + this node's build provenance.
Store *Store
// Runtime reads what containerd actually has.
Runtime Runtime
// Build materializes a recipe locally. nil → Ensure can verify but not
// build, and says so rather than reporting success.
Build Builder
// Resolver returns the peers exposing a mesh service. nil → mesh-resolve
// reports the mesh is unavailable rather than returning an empty set.
Resolver func(service string) ([]PeerRef, error)
// contains filtered or unexported fields
}
Service is the node-local peer-image engine. All four verbs run here; the admincore/MCP/CLI surfaces are thin wrappers over these four methods.
func (*Service) Ensure ¶
Ensure makes the named recipe's image resident on THIS node and proves it by content digest.
The sequence, and why each step is where it is:
- Load the recipe. Absent → ErrNoRecipe. There is no "nothing to do".
- Read the live containerd digest. resident + provenance agrees → done, Built=false. resident + provenance differs → ErrDigestMismatch, LOUD, no repair. The ref was retagged onto other bytes; fetching something else would hide it. resident + no provenance → rebuild from the verified recipe, which is the only thing that lets this node attribute the bytes it runs. unknown → ErrDigestUnknown. Not absent, not a pass. absent → build.
- Build + load, then read the digest BACK. A build that "succeeded" without producing a readable resident digest is a failure.
- Record provenance only from the digest actually read in step 3.
Side-effect class: Live.
func (*Service) FetchRecipe ¶
FetchRecipe pulls a recipe document from a peer's index through an already- open mesh forward at forwardAddr, and stores it locally under its own name.
forwardAddr is loopback by construction (it is a listener this daemon opened), so it is passed to the fetcher as an EXACT allowance. Any redirect that leaves it — including one that lands on a different loopback port, e.g. the admin/MCP surface — is refused at that hop. See safehttp.go.
func (*Service) IndexHandler ¶
IndexHandler serves this node's published recipes to peers:
GET /recipes → the Publication index (JSON) GET /recipes/<name> → the recipe document (text/yaml)
It is mounted on a LOOPBACK listener and reached by peers only through the mesh forwarder under an allowlisted service name — the same boundary every other wrapped tool uses. It adds no overlay and widens no allowlist.
It serves recipes only. Provenance is node-private: it is the local anchor a node's own live digest is correlated against, and handing it to a peer would let a peer's claim be built from someone else's record.
func (*Service) MeshResolve ¶
func (s *Service) MeshResolve(_ context.Context, service string, minimum int) (ResolveResult, error)
MeshResolve finds the peers exposing service, requiring at least `minimum` DISTINCT peer identities.
An empty registry answer is ErrNoPeers, never an empty success: "nobody exposes it" and "we reached everybody who does (zero)" are not the same claim, and only the second one could be read as satisfied.
Side-effect class: Live (read-only).
func (*Service) Publications ¶
func (s *Service) Publications() ([]Publication, error)
Publications lists what this node currently publishes.
func (*Service) Publish ¶
Publish stores a recipe locally and makes it fetchable by peers through the mesh recipe index. It transfers no image bytes — publishing a recipe is the whole point of the model.
Side-effect class: Live.
func (*Service) Report ¶
Report answers a Challenge with this node's identity-bound evidence.
The node name in the answer is always THIS daemon's identity — never the challenge's — so a challenge addressed to another node produces a refusal rather than a report attributed to the wrong node.
Side-effect class: Live (read-only).
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the node-local recipe + provenance store. Recipes are what peers fetch; provenance is private to this node (it is what its own live digest is correlated against) and is never served.
func NewStore ¶
NewStore opens (creating if needed) a store rooted at dir. Mode 0700: a recipe can carry an inline build context, which is source code.
func (*Store) List ¶
func (s *Store) List() ([]Publication, error)
List returns every published recipe, sorted by name. An unreadable or invalid entry is an error: a truncated listing that silently drops a recipe would let "the peer doesn't have it" mean "we failed to read it".
func (*Store) Provenance ¶
func (s *Store) Provenance(ref string) (Provenance, bool, error)
Provenance returns this node's build record for ref. The bool is false when there is no record — which callers must treat as "cannot attribute these bytes", not as "fine".
func (*Store) Publish ¶
func (s *Store) Publish(name, body string) (Publication, error)
Publish validates and stores a recipe document, returning its identity. Republishing the same name overwrites — the digest tells peers whether anything actually changed.
Side-effect class: Live. Nothing here is read at boot, so no restart.
func (*Store) PutProvenance ¶
func (s *Store) PutProvenance(p Provenance) error
PutProvenance records what this node built. Called only after the content digest has actually been read back out of containerd.
type Summary ¶
type Summary struct {
// Ref + RecipeDigest are the cross-node claim being proven.
Ref string `json:"ref"`
RecipeDigest string `json:"recipe_digest"`
// Asked is how many DISTINCT nodes were challenged.
Asked int `json:"asked"`
// Proven is how many DISTINCT nodes returned accepted evidence.
Proven int `json:"proven"`
// OK is true only when Proven == Asked and Asked >= the required minimum.
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
Verdicts []Verdict `json:"verdicts"`
// Rejected records evidence the Inspector refused, with the rule that
// refused it. Rejections are surfaced, never silently dropped.
Rejected []Verdict `json:"rejected,omitempty"`
}
Summary is the whole inspection's outcome.