a73x

internal/agent/run/wire_linux.go

Ref:   Size: 6.4 KiB   History

//go:build linux

package run

import (
	"context"
	"fmt"
	"maps"
	"slices"

	"github.com/a73x/eitri/internal/agent/bootstrap"
	"github.com/a73x/eitri/internal/agent/cloudhv"
	"github.com/a73x/eitri/internal/agent/netenv"
	"github.com/a73x/eitri/internal/agent/reconcile"
	"github.com/a73x/eitri/internal/agent/serialpump"
	"github.com/a73x/eitri/internal/agent/state"
)

// cloudhv.Provisioner is what serve() drives through the reconcile.Provisioner
// seam. Pin the method-set match at compile time: a drift on either side (a
// renamed Boot, a changed Capabilities signature) fails the build here rather
// than silently at the wiring call below.
var _ reconcile.Provisioner = (*cloudhv.Provisioner)(nil)

// platformProvisioner is what this host advertises to the server at join
// time — an opaque label the server stores and never interprets.
const platformProvisioner = "cloudhv"

// platform is this host's backend pair. It is a struct, not an interface:
// nothing consumes the bundle as a type, so the two seams stay independent — a
// different VMM is a change in this file alone. serve() is the contract's only
// consumer: a sibling wire_*.go must produce this same struct shape from a
// newPlatform with this same signature, and the fields must satisfy what
// serve() actually calls on them — Prov is reconcile.Provisioner; Pumps needs
// Ensure, StopAll, and the console Attach the sync client uses to service
// server-opened streams.
//
// Host networking does not appear here. It is per-VM state the provisioner
// attaches inside Boot, and host-wide setup that this file performs once at
// startup — neither is anything the platform-neutral serve() can sequence.
//
// Pumps is the console seam already wired to its lifecycle: the platform knows
// both how its console is exposed and which provisioner drives the pump, so it
// hands back a Manager rather than a raw source. One pump runs per running VM,
// started at Boot (cloudhv hook); CH runs in its own process group, so a pump
// surviving an agent restart reconnects to the still-listening serial socket
// rather than needing to be relaunched.
type platform struct {
	Prov  reconcile.Provisioner
	Pumps *serialpump.Manager
	// GuestCIDR reports the subnet this host's guests are on, asked once per
	// report. Linux genuinely knows at startup — it builds the bridge — so this
	// returns a constant; the poll shape costs it nothing and lets a platform
	// whose OS owns the network answer late, or not at all.
	GuestCIDR func() string
	// HostNetworks are the named guest networks this host serves, sorted. A
	// value rather than a poll, unlike GuestCIDR: the set is the agent's
	// command line, so it changes only with a restart — which is why it rides
	// in Hello and not in every report.
	HostNetworks []string
}

// Per-platform because parseConfig is deliberately platform-neutral — every
// flag is accepted everywhere and ignored by hosts that do not run them — so a
// shared reader of cfg.BridgeCIDR would have a Mac propose a fabricated Linux
// subnet.
func proposeGuestCIDR(cfg Config) *string {
	if cfg.BridgeCIDR == "" {
		return nil
	}
	return &cfg.BridgeCIDR
}

// defaultStateDir is where a Linux agent keeps its state when nothing says
// otherwise. The unit runs as root and the agent creates the directory itself,
// so there is nothing for an operator to pre-create.
func defaultStateDir() (string, error) { return "/var/lib/eitri-agent", nil }

// lastResortGuestCIDR is used only when nothing else produced one: no persisted
// identity, no flag, and no suggestion from the control plane. It is a last
// resort and not "the default" — a host that reaches it is a host the fleet
// never advised, and two of them on one network would collide.
//
// It lives here, not in the resolver, because a fabricated subnet is only ever
// right on a platform that is about to CREATE the network it names. A host
// whose OS already owns the guest network must answer "" and go and look.
const lastResortGuestCIDR = "10.77.1.0/24"

// newPlatform builds the Linux backend: cloud-hypervisor over a bridge and tap,
// with the guest console on cloud-hypervisor's serial socket. A bare host that
// just joined has neither the hypervisor nor its UEFI firmware, so newPlatform
// installs them before anything can try to launch a VM.
func newPlatform(ctx context.Context, cfg Config, st *state.Store) (platform, error) {
	bridgeCIDR := resolveGuestCIDR(st, cfg, "")
	if bridgeCIDR == "" {
		bridgeCIDR = lastResortGuestCIDR
		persistGuestCIDR(st, bridgeCIDR)
	}
	net, err := netenv.New(hostRunner, bridgeCIDR, cfg.HostNetworks)
	if err != nil {
		return platform{}, fmt.Errorf("netenv init: %w", err)
	}
	// Before the NAT bridge and before any VM: a configured network whose
	// bridge is missing is a host that cannot honor what it is about to
	// advertise. Refuse to start, in the flag's own vocabulary.
	if err := net.VerifyNetworks(); err != nil {
		return platform{}, err
	}
	// Before anything is created: a subnet overlapping this host's own uplink
	// would make its address local and stop it answering, and the only machine
	// positioned to fix that is the one that just lost the route. Refusing here
	// costs a startup error; not refusing costs the host.
	if err := net.CheckUplinkCollision(); err != nil {
		return platform{}, err
	}
	if err := net.EnsureBridge(ctx); err != nil {
		return platform{}, fmt.Errorf("ensure bridge: %w", err)
	}

	// Must precede StartDHCP: the responder may not answer a surviving guest's
	// renewal from an empty table.
	replayReservations(st, net)
	if err := net.StartDHCP(ctx); err != nil {
		return platform{}, fmt.Errorf("start dhcp: %w", err)
	}

	// BootstrapDest maps the --ch-bin value (usually a bare $PATH name) to a
	// real install path.
	bs := &bootstrap.Bootstrapper{
		CHPath:       cloudhv.BootstrapDest(cfg.CHBin),
		FirmwarePath: cfg.Firmware,
		ManifestURL:  cfg.BootstrapURL,
	}
	if err := bs.Ensure(ctx); err != nil {
		return platform{}, fmt.Errorf("bootstrap runtime: %w", err)
	}

	prov := cloudhv.New(st, cfg.CHBin, cfg.Firmware, hostRunner, net)
	pumps := serialpump.NewManager(cloudhv.ConsoleSource(st.SerialSocketPath), st.SerialLogPath)
	prov.Pumps = pumps

	return platform{Prov: prov, Pumps: pumps,
		GuestCIDR: func() string { return bridgeCIDR },
		// Sorted so every Hello from an unchanged host is byte-identical: map
		// iteration order would make the advertisement churn for no reason.
		HostNetworks: slices.Sorted(maps.Keys(cfg.HostNetworks))}, nil
}