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