peerimage

package
v0.14.29-dev Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 21 Imported by: 0

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:

  1. No cloudbox on the peer execution path. The transport is the existing mesh forwarder (an allowlisted loopback service), never a new overlay.
  2. 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".
  3. 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.
  4. 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

Constants

This section is empty.

Variables

View Source
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.

View Source
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

func RandomNonce() (string, error)

RandomNonce is the production NonceFunc: 128 bits of crypto/rand, hex.

func ValidContentDigest

func ValidContentDigest(s string) bool

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

func ValidRecipeDigest(s string) bool

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.

func (AddrPolicy) Check

func (p AddrPolicy) Check(ctx context.Context, u *url.URL) error

Check validates ONE hop. Callers must invoke it for every hop, including every Location a redirect produces — a check applied only to the first URL is bypassed by a chain whose last hop is the interesting one.

type Builder

type Builder interface {
	Materialize(ctx context.Context, recipeBody string) error
}

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.

func (*Fetcher) Get

func (f *Fetcher) Get(ctx context.Context, rawURL string) ([]byte, error)

Get fetches rawURL, following redirects MANUALLY and re-validating each Location before it is requested. Returns the body of the first non-redirect 2xx response.

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

func (i *Inspector) Accept(r Report) error

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

func (i *Inspector) Challenges() []Challenge

Challenges returns the per-node asks, sorted by node name for determinism.

func (*Inspector) Summarize

func (i *Inspector) Summarize() Summary

Summarize returns the outcome. It is OK only when EVERY challenged node returned accepted, positive evidence — a node that never reported at all fails the inspection rather than being skipped.

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 NonceFunc

type NonceFunc func() (string, error)

NonceFunc mints a fresh per-node nonce. Injected so tests are deterministic.

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.

func DistinctPeers

func DistinctPeers(peers []PeerRef, minimum int) ([]PeerRef, error)

DistinctPeers dedupes by peer id and enforces the minimum. Entries without a peer id are dropped: a peer we cannot dial is not a peer we reached.

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

func (s *Service) Ensure(ctx context.Context, name string) (EnsureResult, error)

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:

  1. Load the recipe. Absent → ErrNoRecipe. There is no "nothing to do".
  2. 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.
  3. Build + load, then read the digest BACK. A build that "succeeded" without producing a readable resident digest is a failure.
  4. Record provenance only from the digest actually read in step 3.

Side-effect class: Live.

func (*Service) FetchRecipe

func (s *Service) FetchRecipe(ctx context.Context, forwardAddr, name string) (Publication, error)

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

func (s *Service) IndexHandler() http.Handler

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

func (s *Service) Publish(_ context.Context, name, body string) (Publication, error)

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

func (s *Service) Report(ctx context.Context, ch Challenge) (Report, error)

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

func NewStore(dir string) (*Store, error)

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.

func (*Store) Recipe

func (s *Store) Recipe(name string) (string, recipebuilder.Recipe, error)

Recipe returns the stored recipe document and its parsed form. A missing recipe is ErrNoRecipe — never an empty Recipe with a nil error.

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.

type Verdict

type Verdict struct {
	Node          string `json:"node"`
	OK            bool   `json:"ok"`
	Reason        string `json:"reason,omitempty"`
	ContentDigest string `json:"content_digest,omitempty"`
}

Verdict is the per-node outcome the Inspector reached.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL