a73x

internal/agent/run/cli_test.go

Ref:   Size: 11.9 KiB   History

package run

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/agent/state"
	"github.com/a73x/eitri/internal/agent/statelock"
	"github.com/a73x/eitri/internal/joinblob"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestHostRunnerReturnsCombinedOutput pins that stdout and stderr both land in
// hostRunner's single return string: injected consumers (netenv, imagecache,
// cloudhv, syncclient) diagnose failures from that one string, so losing
// either stream would blind them.
func TestHostRunnerReturnsCombinedOutput(t *testing.T) {
	out, err := hostRunner(context.Background(), "sh", "-c", "echo out; echo err 1>&2")
	require.NoError(t, err)
	assert.Contains(t, out, "out")
	assert.Contains(t, out, "err")
}

// TestHostRunnerReturnsErrorAndOutputOnFailure pins that a non-zero exit
// surfaces both a non-nil error AND the output collected up to that point —
// callers need the output alongside the error to diagnose what went wrong.
func TestHostRunnerReturnsErrorAndOutputOnFailure(t *testing.T) {
	out, err := hostRunner(context.Background(), "sh", "-c", "echo boom 1>&2; exit 3")
	require.Error(t, err)
	assert.Contains(t, out, "boom")
}

// TestParseConfigDefaults pins every flag default: this binary runs the fleet,
// so a silent change to any default is a production behavior change.
func TestParseConfigDefaults(t *testing.T) {
	cfg, rest, err := parseConfig(nil)
	require.NoError(t, err)
	assert.Empty(t, rest)
	assert.Equal(t, "/var/lib/eitri-agent", cfg.StateDir)
	assert.Equal(t, "cloud-hypervisor", cfg.CHBin)
	assert.Equal(t, "/usr/share/eitri/CLOUDHV.fd", cfg.Firmware)
	assert.Equal(t, "https://eitri.sh/dl/latest/manifest.json", cfg.BootstrapURL)
	assert.Equal(t, 5*time.Minute, cfg.TombstoneGrace)
	assert.Equal(t, time.Hour, cfg.VanishGrace)
	assert.Equal(t, 15*time.Minute, cfg.VMTimeout)
	assert.Equal(t, int64(20), cfg.ImageCacheMaxGB)
	assert.Equal(t, 4, cfg.MaxConcurrentCreates)
	assert.Equal(t, int64(0), cfg.MaxVCPUs)
	assert.Equal(t, int64(0), cfg.MaxMemMB)
	assert.Equal(t, int64(0), cfg.MaxDiskGB)
	assert.Equal(t, map[string]string{}, cfg.HostNetworks)
}

// TestParseConfigOverrides confirms every flag threads through to the Config.
func TestParseConfigOverrides(t *testing.T) {
	cfg, rest, err := parseConfig([]string{
		"--state-dir=/srv/state",
		"--ch-bin=/opt/ch",
		"--firmware=/opt/fw.fd",
		"--bootstrap-url=",
		"--tombstone-grace=90s",
		"--vanish-grace=2h",
		"--vm-timeout=0",
		"--image-cache-max-gb=50",
		"--max-concurrent-creates=1",
		"--max-vcpus=8",
		"--max-mem-mb=4096",
		"--max-disk-gb=100",
	})
	require.NoError(t, err)
	assert.Empty(t, rest)
	assert.Equal(t, "/srv/state", cfg.StateDir)
	assert.Equal(t, "/opt/ch", cfg.CHBin)
	assert.Equal(t, "/opt/fw.fd", cfg.Firmware)
	assert.Equal(t, "", cfg.BootstrapURL)
	assert.Equal(t, 90*time.Second, cfg.TombstoneGrace)
	assert.Equal(t, 2*time.Hour, cfg.VanishGrace)
	assert.Equal(t, time.Duration(0), cfg.VMTimeout)
	assert.Equal(t, int64(50), cfg.ImageCacheMaxGB)
	assert.Equal(t, 1, cfg.MaxConcurrentCreates)
	assert.Equal(t, int64(8), cfg.MaxVCPUs)
	assert.Equal(t, int64(4096), cfg.MaxMemMB)
	assert.Equal(t, int64(100), cfg.MaxDiskGB)
}

// TestParseConfigJoinSubcommand returns the positional args so RunCLI can
// dispatch the join flow.
func TestParseConfigJoinSubcommand(t *testing.T) {
	_, rest, err := parseConfig([]string{"--state-dir=/srv/state", "join", "the-blob"})
	require.NoError(t, err)
	require.Equal(t, []string{"join", "the-blob"}, rest)
}

// TestParseConfigRejectsNegativeCaps rejects a negative resource cap (0 means
// unlimited) and names the offending flag deterministically.
func TestParseConfigRejectsNegativeCaps(t *testing.T) {
	for _, flag := range []string{"max-vcpus", "max-mem-mb", "max-disk-gb"} {
		_, _, err := parseConfig([]string{"--" + flag + "=-1"})
		require.Error(t, err)
		assert.Contains(t, err.Error(), "--"+flag)
	}
}

// TestParseConfigBadFlag surfaces an unknown flag as an error rather than
// exiting the process (ContinueOnError).
func TestParseConfigBadFlag(t *testing.T) {
	_, _, err := parseConfig([]string{"--nonesuch"})
	require.Error(t, err)
}

// TestParseHostNetworkFlag confirms --host-network is repeatable and each
// name=bridge pair lands in Config.HostNetworks.
func TestParseHostNetworkFlag(t *testing.T) {
	cfg, _, err := parseConfig([]string{"--host-network", "lan=br0", "--host-network", "lab=br1"})
	require.NoError(t, err)
	assert.Equal(t, map[string]string{"lan": "br0", "lab": "br1"}, cfg.HostNetworks)
}

// TestParseHostNetworkFlagRefusals rejects every malformed or ambiguous
// --host-network value: missing/empty bridge, the reserved "nat" name, a name
// that fails the shared grammar, and a duplicate declaration.
func TestParseHostNetworkFlagRefusals(t *testing.T) {
	for _, args := range [][]string{
		{"--host-network", "lan"},                                  // no bridge
		{"--host-network", "lan="},                                 // empty bridge
		{"--host-network", "nat=br0"},                              // reserved name
		{"--host-network", "LAN=br0"},                              // bad grammar
		{"--host-network", "lan=br0", "--host-network", "lan=br1"}, // duplicate
	} {
		_, _, err := parseConfig(args)
		assert.Error(t, err, "parseConfig(%v) accepted, want error", args)
	}
}

// TestParseConfigOnRefusesHostNetworkOnUnsupportedGOOS proves the refusal is
// actually wired into the parse path, not just unit-tested in isolation:
// deleting the validateHostNetworks call in parseConfigOn makes this fail,
// where a test of validateHostNetworks alone would not notice.
func TestParseConfigOnRefusesHostNetworkOnUnsupportedGOOS(t *testing.T) {
	_, _, err := parseConfigOn("darwin", []string{"--host-network", "lan=br0"})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "--host-network")
	assert.Contains(t, err.Error(), "Linux")
}

// TestParseConfigOnAllowsUnsupportedGOOSWithoutTheFlag confirms a Mac agent
// with no --host-network at all still parses cleanly — the refusal is for the
// flag, not the platform.
func TestParseConfigOnAllowsUnsupportedGOOSWithoutTheFlag(t *testing.T) {
	cfg, _, err := parseConfigOn("darwin", []string{"--max-vcpus=2"})
	require.NoError(t, err)
	assert.Equal(t, int64(2), cfg.MaxVCPUs)
}

// TestValidateHostNetworksRefusesUnsupportedPlatform rejects --host-network on
// a platform that cannot serve it (Mac today), naming the remedy rather than
// leaving the operator to rediscover it from a later 409. The message says
// "Linux" once, not twice — the fact ("Linux hosts only") already carries the
// remedy's platform, so the remedy itself need not repeat it.
func TestValidateHostNetworksRefusesUnsupportedPlatform(t *testing.T) {
	err := validateHostNetworks(false, map[string]string{"lan": "br0"})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "--host-network")
	assert.Equal(t, 1, strings.Count(err.Error(), "Linux"), "message %q should name Linux once, not repeat it", err.Error())
}

// TestValidateHostNetworksAllowsSupportedPlatform lets --host-network through
// on a platform that can serve it (Linux today).
func TestValidateHostNetworksAllowsSupportedPlatform(t *testing.T) {
	err := validateHostNetworks(true, map[string]string{"lan": "br0"})
	require.NoError(t, err)
}

// TestValidateHostNetworksVacuousWithoutFlag is fine on any platform: an
// operator who never passed --host-network has nothing to be refused for.
func TestValidateHostNetworksVacuousWithoutFlag(t *testing.T) {
	err := validateHostNetworks(false, map[string]string{})
	require.NoError(t, err)
}

// TestJoinEmptyBlob rejects a missing blob before any decode or network call;
// st is never touched, so nil is safe.
func TestJoinEmptyBlob(t *testing.T) {
	err := join(nil, Config{}, "")
	require.Error(t, err)
	assert.Contains(t, err.Error(), "usage: eitri-agent join")
}

// TestJoinInvalidBlob rejects an undecodable blob and never echoes the blob
// itself (it carries a bearer token). Decode fails before st is used.
func TestJoinInvalidBlob(t *testing.T) {
	const secret = "not-a-valid-join-blob-with-secret"
	err := join(nil, Config{}, secret)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "invalid join blob")
	assert.NotContains(t, err.Error(), secret, "the blob carries a bearer token and must never appear in errors")
}

// TestServeNotEnrolled refuses to run before the host has enrolled, before any
// network or signal handler is set up. One directory, as production always has
// it: the store and the config must name the same state dir or the test proves
// nothing about the pairing serve is actually given.
func TestServeNotEnrolled(t *testing.T) {
	dir := t.TempDir()
	st, err := state.Open(dir)
	require.NoError(t, err)
	err = serve(st, Config{StateDir: dir})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "not enrolled", "an unenrolled agent has no identity to drive")
}

// TestServeRefusesASecondAgentOnTheSameState pins the first thing serve does.
// Two agents on one state directory means two hypervisor managers driving the
// same VM records and the same disks, and the control plane keeps one sync
// session per host, so the second one silently displaces the first. The lock
// must be claimed before anything else — including the identity check, which is
// why an ENROLLED-looking failure here would be the wrong error.
func TestServeRefusesASecondAgentOnTheSameState(t *testing.T) {
	dir := t.TempDir()
	st, err := state.Open(dir)
	require.NoError(t, err)

	// Stand in for the agent that is already running on this identity.
	lk, err := statelock.Acquire(dir)
	require.NoError(t, err)
	defer func() { _ = lk.Release() }()

	err = serve(st, Config{StateDir: dir})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "already running",
		"serve must claim the state directory before it touches anything: an unlocked second agent drives the same VMs and disks as the first")
	assert.NotContains(t, err.Error(), "not enrolled",
		"the lock is claimed FIRST — reaching the identity check means the ordering serve promises has been reversed")
}

// TestJoinPinsTheBlobsCertFingerprint proves the join blob is the sole trust
// root. The agent's TLS config compares every server it dials against this one
// stored fingerprint, so whatever join persists here decides which control
// plane the host will ever talk to. The fake plane answers with a decoy
// fingerprint in its JSON body: enrollclient.Response deliberately has no field
// to receive it, and this test is the witness that the omission is load-bearing
// rather than an oversight someone later "completes".
func TestJoinPinsTheBlobsCertFingerprint(t *testing.T) {
	const blobFP = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
	const decoyFP = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"

	plane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/api/v1/enroll", r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		fmt.Fprintf(w, `{"host_id":"h1","credential":"c1","bridge_cidr":"10.77.1.0/24","server_cert_sha256":%q}`, decoyFP)
	}))
	defer plane.Close()

	// Built through the real encoder: Encode validates the fingerprint shape,
	// so a blob a test hand-rolls would not be one an operator can produce.
	blob, err := joinblob.Encode(plane.URL, "127.0.0.1:4443", "tok", blobFP)
	require.NoError(t, err)

	st, err := state.Open(t.TempDir())
	require.NoError(t, err)
	require.NoError(t, join(st, Config{StateDir: t.TempDir()}, blob))

	id, ok := st.Identity()
	require.True(t, ok, "join must persist an identity")
	assert.Equal(t, blobFP, id.ServerCertSHA256,
		"the pin must be the join blob's fingerprint: the enroll response is server-controlled, so pinning what the server says makes the pin prove nothing — and an empty pin bricks the host, since ClientTLS fails closed on every dial")
	assert.Equal(t, "h1", id.HostID)
	assert.Equal(t, "c1", id.Credential)
	assert.Equal(t, "127.0.0.1:4443", id.ServerQUICAddr, "the QUIC address is the blob's too, not the response's")
}