internal/server/release/release.go
Ref: Size: 6.5 KiB History
// Package release discovers the latest eitri release from a manifest URL
// (eitri.sh) and names the agent floors the control plane admits against. The
// manifest is the bootstrap contract: stable URLs + sha256 per artifact,
// fetchable by tooling and humans alike. Version ORDERING is not here — it is
// in internal/version, beside the stamp, because the agent needs it too and
// may not import a control-plane package (arch R1).
package release
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/a73x/eitri/internal/relmanifest"
"github.com/a73x/eitri/internal/version"
)
// Artifact and Manifest are the shared wire types; see internal/relmanifest.
type (
Artifact = relmanifest.Artifact
Manifest = relmanifest.Manifest
)
// Client fetches and caches the latest manifest. Construct with New.
type Client struct {
url string
hc *http.Client
mu sync.RWMutex
latest *Manifest
}
// New builds a Client for the given manifest URL.
func New(url string) *Client {
return &Client{url: url, hc: &http.Client{Timeout: 30 * time.Second}}
}
// Latest returns the most recently fetched manifest; ok=false before the
// first successful Refresh. The returned Manifest's Artifacts map aliases
// cached state and must be treated read-only — callers never mutate it, and
// Refresh only ever swaps the whole manifest (never writes in place), so the
// borrow stays valid without copying.
func (c *Client) Latest() (Manifest, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.latest == nil {
return Manifest{}, false
}
return *c.latest, true
}
// Refresh fetches the manifest once. A failure leaves the previous manifest
// in place (stale beats absent for a signal-only feature).
func (c *Client) Refresh(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("fetch release manifest: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("fetch release manifest: HTTP %d", resp.StatusCode)
}
var m Manifest
// A manifest is a few KB; bound the read so a hostile or misconfigured
// endpoint can't balloon memory.
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
return fmt.Errorf("decode release manifest: %w", err)
}
if m.Version == "" {
return fmt.Errorf("release manifest has no version")
}
c.mu.Lock()
c.latest = &m
c.mu.Unlock()
return nil
}
// warmRetry is how often Poll retries while NO manifest has ever been fetched
// successfully — deliberately short so a transient network blip at server
// boot doesn't leave release discovery dark for a full `every` interval
// (typically 24h). Overridable in tests, like sshgate.handshakeGrace.
var warmRetry = 15 * time.Minute
// Poll refreshes now and then on a schedule until ctx is cancelled. Before the
// first successful fetch, a failed refresh is retried every warmRetry (fast)
// rather than waiting the full `every` interval; once a manifest has been
// fetched at least once, Poll settles into ticking at `every`. Refresh errors
// are reported through onErr (nil ⇒ ignored); a failure keeps the last good
// manifest.
func (c *Client) Poll(ctx context.Context, every time.Duration, onErr func(error)) {
refresh := func() {
if err := c.Refresh(ctx); err != nil && onErr != nil {
onErr(err)
}
}
refresh()
for {
interval := every
if _, ok := c.Latest(); !ok {
interval = warmRetry
}
t := time.NewTimer(interval)
select {
case <-ctx.Done():
t.Stop()
return
case <-t.C:
refresh()
}
}
}
// Feature is one agent-side capability and the release whose agent first
// carried it. Admission asks SupportedBy before handing a host work an older
// agent would silently mishandle; see api.refuseBelowFloor.
type Feature struct {
Name string // how a refusal names it: "certified host keys"
Since string // the release tag that first carried it
// Consequence is one sentence on what goes wrong if the floor is ignored,
// written to follow "host h (id) runs agent v (…): ". A refusal that names
// only a version leaves the operator to guess why it matters, and leaves a
// model reading it back through MCP with nothing to act on.
Consequence string
}
var (
// CertifiedHostKeys: the agent generates each guest's SSH host key and
// submits the public half for signing. A VM created by anything older
// carries no host certificate and can never be issued one — the key it
// would certify was never reported.
CertifiedHostKeys = Feature{Name: "certified host keys", Since: "v0.0.4",
Consequence: "a guest created there gets no certified host key, so nothing could verify it " +
"and it would be unreachable through the gate"}
// DatagramExposures: the agent honours an exposure's protocol. A UDP grant
// handed to anything older is bound as TCP by a converge loop that calls
// net.Listen("tcp", …) unconditionally, and reported active regardless.
DatagramExposures = Feature{Name: "datagram exposures", Since: "v0.0.5",
Consequence: "a UDP grant there would be bound as TCP and reported active, " +
"publishing a port that carries no datagrams"}
// Volumes: the agent materialises VolumeSpec files and attaches them. An
// older agent ignores volume_ids and boots the guest bare, so the data the
// tenant meant for the volume lands on the root disk. Since is the first
// stamped build that carries it.
Volumes = Feature{Name: "volumes", Since: "v0.0.8-pre.1",
Consequence: "a guest created there would boot without its volumes and write the data " +
"meant for them to its root disk"}
)
// The two shipped floors by their original names; every refusal quotes them.
const (
FirstCertifiedHostKeys = "v0.0.4"
FirstDatagramExposures = "v0.0.5"
)
// SupportedBy reports whether an agent at version v carries f, ordering v
// against f.Since by the same rule Less publishes. An unparsable version —
// "dev", a "-dirty" tree, the empty version a host reports before it has said
// anything — does not: nothing can be proven about a version that cannot be
// read, the same conservative reading the upgrade path takes when it never
// offers such a build an upgrade. Neither does an unparsable floor.
func (f Feature) SupportedBy(v string) bool {
if !version.Ordered(v) || !version.Ordered(f.Since) {
return false
}
return !version.Less(v, f.Since)
}
// CertifiesGuestHostKeys is CertifiedHostKeys.SupportedBy.
func CertifiesGuestHostKeys(v string) bool { return CertifiedHostKeys.SupportedBy(v) }