a73x

internal/agent/bootstrap/bootstrap.go

Ref:   Size: 7.5 KiB   History

// Package bootstrap installs the agent's runtime — the cloud-hypervisor
// binary and its UEFI guest firmware (CLOUDHV.fd) — the first time it is
// missing on a host, by fetching sha-pinned artifacts from the eitri.sh
// release manifest (internal/relmanifest). It runs once at agent startup,
// before the first reconcile.
//
// Existing fleets and airgapped hosts never touch the network: if both files
// are already present, Ensure returns immediately with zero HTTP requests.
// An empty manifest URL disables bootstrap entirely — the operator manages
// the runtime by hand.
//
// Ensure is idempotent per file: a file that already exists is never
// touched. There is no version comparison — upgrading an already-installed
// runtime is a separate concern from bootstrapping a missing one.
package bootstrap

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"time"

	"github.com/a73x/eitri/internal/relmanifest"
)

// manifestMaxBytes bounds the manifest fetch: it is a small, hand-authored
// JSON document, so anything vastly larger indicates a misconfigured or
// hostile URL rather than a legitimate manifest.
const manifestMaxBytes = 1 << 20

// Bootstrapper installs cloud-hypervisor and CLOUDHV.fd if either is missing
// on disk. The zero value works in production against a configured
// ManifestURL; tests inject the seams.
type Bootstrapper struct {
	// HTTP is the manifest+artifact download client (nil ⇒ a 5-minute-timeout
	// default, mirroring selfupdate's seam style).
	HTTP *http.Client
	// Log receives progress and warning lines (nil ⇒ slog.Default()).
	Log *slog.Logger
	// CHPath and FirmwarePath are the destination paths this agent was
	// started with (the same values as its --ch-bin/--firmware flags).
	CHPath, FirmwarePath string
	// ManifestURL is the eitri.sh release manifest to fetch from. "" disables
	// bootstrap entirely: the operator manages the runtime by hand.
	ManifestURL string
}

func (b *Bootstrapper) httpClient() *http.Client {
	if b.HTTP != nil {
		return b.HTTP
	}
	return &http.Client{Timeout: 5 * time.Minute}
}

func (b *Bootstrapper) log() *slog.Logger {
	if b.Log != nil {
		return b.Log
	}
	return slog.Default()
}

// Ensure installs whichever of cloud-hypervisor / CLOUDHV.fd is missing at
// CHPath / FirmwarePath, fetching the manifest from ManifestURL. It is meant
// to run once, before the first reconcile.
func (b *Bootstrapper) Ensure(ctx context.Context) error {
	chMissing := !fileExists(b.CHPath)
	fwMissing := !fileExists(b.FirmwarePath)
	if !chMissing && !fwMissing {
		return nil
	}

	if b.ManifestURL == "" {
		b.log().Info("bootstrap: runtime file(s) missing and no manifest URL configured — operator manages cloud-hypervisor/firmware by hand",
			"ch_missing", chMissing, "firmware_missing", fwMissing)
		return nil
	}

	man, err := b.fetchManifest(ctx)
	if err != nil {
		return fmt.Errorf("bootstrap: fetch manifest: %w", err)
	}
	platform := runtime.GOOS + "/" + runtime.GOARCH

	if chMissing {
		art, ok := man.Artifacts["cloud-hypervisor"][platform]
		if !ok {
			return fmt.Errorf("bootstrap: manifest has no cloud-hypervisor artifact for platform %s; the agent cannot run without it", platform)
		}
		if err := b.install(ctx, art, b.CHPath, 0o755); err != nil {
			return fmt.Errorf("bootstrap: install cloud-hypervisor: %w", err)
		}
		b.log().Info("bootstrap: installed cloud-hypervisor", "path", b.CHPath, "version", man.Version)
	}

	if fwMissing {
		art, ok := man.Artifacts["firmware"][platform]
		if !ok {
			b.log().Warn("bootstrap: manifest has no firmware artifact for this platform; VM creates will fail until it is installed by hand",
				"platform", platform, "path", b.FirmwarePath)
		} else if err := b.install(ctx, art, b.FirmwarePath, 0o644); err != nil {
			return fmt.Errorf("bootstrap: install firmware: %w", err)
		} else {
			b.log().Info("bootstrap: installed firmware", "path", b.FirmwarePath, "version", man.Version)
		}
	}

	return nil
}

// fetchManifest GETs and decodes the release manifest.
func (b *Bootstrapper) fetchManifest(ctx context.Context) (relmanifest.Manifest, error) {
	var man relmanifest.Manifest
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.ManifestURL, nil)
	if err != nil {
		return man, err
	}
	resp, err := b.httpClient().Do(req)
	if err != nil {
		return man, fmt.Errorf("GET %s: %w", b.ManifestURL, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return man, fmt.Errorf("GET %s: HTTP %d", b.ManifestURL, resp.StatusCode)
	}
	if err := json.NewDecoder(io.LimitReader(resp.Body, manifestMaxBytes)).Decode(&man); err != nil {
		return man, fmt.Errorf("decode manifest from %s: %w", b.ManifestURL, err)
	}
	return man, nil
}

// install downloads art to dest, streaming its sha256 and verifying it
// against art.SHA256 before the file is ever visible at dest. It mirrors
// selfupdate's durable-install idiom: a temp file beside the destination (so
// the final rename is atomic on the same filesystem), fsync before close,
// then an atomic rename plus a best-effort parent-dir fsync.
func (b *Bootstrapper) install(ctx context.Context, art relmanifest.Artifact, dest string, perm os.FileMode) error {
	dir := filepath.Dir(dest)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("create parent dir %s: %w", dir, err)
	}

	// Sweep temps a crashed prior run left behind; they never block a fresh
	// install (dest-existence gates Ensure) but would accumulate.
	if stale, err := filepath.Glob(filepath.Join(dir, ".eitri-bootstrap-*")); err == nil {
		for _, s := range stale {
			os.Remove(s)
		}
	}

	tmp, err := os.CreateTemp(dir, ".eitri-bootstrap-*")
	if err != nil {
		return fmt.Errorf("create temp beside %s: %w", dest, err)
	}
	defer os.Remove(tmp.Name()) // no-op after a successful rename

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, art.URL, nil)
	if err != nil {
		tmp.Close()
		return err
	}
	resp, err := b.httpClient().Do(req)
	if err != nil {
		tmp.Close()
		return fmt.Errorf("download %s: %w", art.URL, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		tmp.Close()
		return fmt.Errorf("download %s: HTTP %d", art.URL, resp.StatusCode)
	}

	h := sha256.New()
	if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil {
		tmp.Close()
		return fmt.Errorf("download %s: %w", art.URL, err)
	}
	// Durability: fsync the downloaded bytes before Close, same rationale as
	// selfupdate — a torn install can't heal itself on the next Ensure (the
	// file would already "exist" and be skipped).
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return fmt.Errorf("sync downloaded temp: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if got := hex.EncodeToString(h.Sum(nil)); got != art.SHA256 {
		return fmt.Errorf("sha256 mismatch for %s: got %s want %s", art.URL, got, art.SHA256)
	}
	if err := os.Chmod(tmp.Name(), perm); err != nil {
		return err
	}
	if err := os.Rename(tmp.Name(), dest); err != nil {
		return fmt.Errorf("install %s: %w", dest, err)
	}
	// Durability: fsync the directory entry itself — see selfupdate's note on
	// why a rename alone is not durable on every filesystem. Best-effort: the
	// install already happened either way.
	if df, derr := os.Open(dir); derr == nil {
		_ = df.Sync()
		_ = df.Close()
	}
	return nil
}

// fileExists reports whether path names an existing file (any stat error,
// including permission errors, is treated as "missing" — Ensure's job is to
// put a working file there, not to diagnose why one isn't visible).
func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}