a73x

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) }