internal/mcpserver/tools_test.go
Ref: Size: 39.3 KiB History
package mcpserver
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io/fs"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"github.com/a73x/eitri/internal/server/api/client"
"github.com/a73x/eitri/internal/server/release"
)
type fakeToolsAPI struct {
vms []client.VM
created []client.CreateVMRequest
deleted []string
// lifecycle sequence returned across successive ListVMs calls for the
// created VM, letting tests script the create→ready wait.
phases []string
calls int
// number of leading ListVMs calls that fail (control-plane blip); after
// they are exhausted the phases sequence takes over.
listErrs int
// what the scripted VM reports about its named network once ready: the
// network it was created on, and the address that network's DHCP granted
// (empty for a lease that has not landed).
network string
networkIP string
// exposures the fake serves per VM id, and the exposure ids revoked.
exposures map[string][]client.Exposure
revoked []string
// hosts the fake's fleet reports; nil means the one-host fleet.
hosts []client.Host
// CAs registered through ca_upload, as (line, label) pairs, plus an error
// the registration can be made to fail with.
cas [][2]string
caErr error
// what tenant_info reads back
userCAs []client.UserCA
listCAErr error
delegation *client.Delegation
}
// fleet is the fake's host list, defaulted so tests that don't care about
// placement get a single online host. Its agent is current, because placement
// rules an older one out and these tests are not about that.
func (f *fakeToolsAPI) fleet() []client.Host {
if f.hosts != nil {
return f.hosts
}
return []client.Host{{ID: "h1", Name: "mewtwo", Online: true, AgentVersion: currentAgent}}
}
// currentAgent is any version placement accepts: the floor is exactly the
// question CertifiesGuestHostKeys asks, so naming it here keeps the fixture
// from pinning a release literal of its own.
const currentAgent = release.FirstCertifiedHostKeys
func (f *fakeToolsAPI) ListVMs(ctx context.Context) ([]client.VM, error) {
if f.listErrs > 0 {
f.listErrs--
return nil, fmt.Errorf("control plane unavailable")
}
if len(f.phases) > 0 {
i := f.calls
if i >= len(f.phases) {
i = len(f.phases) - 1
}
f.calls++
vm := client.VM{ID: "new1", Name: "claude-abc", Lifecycle: f.phases[i], Network: f.network}
if f.phases[i] == "ready" {
vm.AssignedIP = "10.77.1.9"
vm.NetworkIP = f.networkIP
}
return append(append([]client.VM{}, f.vms...), vm), nil
}
return f.vms, nil
}
func (f *fakeToolsAPI) CreateVM(ctx context.Context, r client.CreateVMRequest) (client.CreateVMResponse, error) {
f.created = append(f.created, r)
return client.CreateVMResponse{ID: "new1", Name: "claude-abc"}, nil
}
func (f *fakeToolsAPI) DeleteVM(ctx context.Context, id string) error {
f.deleted = append(f.deleted, id)
return nil
}
func (f *fakeToolsAPI) ListHosts(ctx context.Context) ([]client.Host, error) {
return f.fleet(), nil
}
// FirstEligibleHost runs the real placement rule over the fake's fleet, so a
// test cannot pass against a kinder stand-in than the one in production.
func (f *fakeToolsAPI) FirstEligibleHost(ctx context.Context, network string) (client.Host, error) {
return eligibleHost(f.fleet(), network)
}
// CreateExposure mirrors the control plane: host port 0 is allocated from the
// reserved range, an unnamed protocol is tcp, and the host address comes back
// with the grant.
func (f *fakeToolsAPI) CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64, protocol string) (client.Exposure, error) {
if hostPort == 0 {
hostPort = 30000 + int64(len(f.exposures[vmID]))
}
if protocol == "" {
protocol = "tcp"
}
e := client.Exposure{
ID: fmt.Sprintf("x-%d", len(f.exposures[vmID])+1), VMID: vmID, HostID: "h1",
GuestPort: guestPort, HostPort: hostPort, HostAddr: "10.0.0.4",
Protocol: protocol, Scope: "host", State: "pending",
}
if f.exposures == nil {
f.exposures = map[string][]client.Exposure{}
}
f.exposures[vmID] = append(f.exposures[vmID], e)
return e, nil
}
func (f *fakeToolsAPI) ListExposures(ctx context.Context, vmID string) ([]client.Exposure, error) {
return f.exposures[vmID], nil
}
func (f *fakeToolsAPI) DeleteExposure(ctx context.Context, id string) error {
f.revoked = append(f.revoked, id)
return nil
}
func (f *fakeToolsAPI) ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error) {
return f.userCAs, f.listCAErr
}
func (f *fakeToolsAPI) Delegation(ctx context.Context) (client.Delegation, error) {
if f.delegation == nil {
return client.Delegation{}, errors.New("no live delegation for this tenant")
}
return *f.delegation, nil
}
func (f *fakeToolsAPI) RegisterUserCA(ctx context.Context, caLine, label string) error {
if f.caErr != nil {
return f.caErr
}
f.cas = append(f.cas, [2]string{caLine, label})
return nil
}
type fakeRunner struct {
execs []string
out ExecResult
err error
// number of leading Exec calls that fail with a connection-style error (the
// guest still booting, pre-sshd); after they are exhausted the configured
// out/err takes over. Mirrors fakeToolsAPI.listErrs.
execErrs int
files map[string][]byte
reads []string // vmName+"|"+path passed to ReadFile, in order
}
func (f *fakeRunner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) {
f.execs = append(f.execs, vmName+"|"+cmd)
if f.execErrs > 0 {
f.execErrs--
return ExecResult{}, fmt.Errorf("vm %s unreachable: connect: no route to host", vmName)
}
return f.out, f.err
}
func (f *fakeRunner) WriteFile(ctx context.Context, vmName, p string, data []byte, mode fs.FileMode) error {
if f.files == nil {
f.files = map[string][]byte{}
}
f.files[p] = data
return nil
}
func (f *fakeRunner) ReadFile(ctx context.Context, vmName, p string) ([]byte, bool, error) {
f.reads = append(f.reads, vmName+"|"+p)
d, ok := f.files[p]
if !ok {
return nil, false, fmt.Errorf("open %s: not found", p)
}
return d, false, nil
}
// ConnectName mirrors GateAuth: the gate connect name is <tenant>.<vmName>, and
// the fake's tenant is "default" (as the fake CertAuthority returns).
func (f *fakeRunner) ConnectName(ctx context.Context, vmName string) (string, error) {
return "default." + vmName, nil
}
func newTestTools(api *fakeToolsAPI, r *fakeRunner) *Tools {
return &Tools{
API: api, Runner: r,
Gate: "localhost:2223",
VMUser: "ubuntu",
// Fast tests, and a budget that ENDS. At the 10-minute default a test
// whose VM never reaches the state it is waiting for does not fail —
// it hangs, and CI pays ten minutes to learn what the timeout error
// says in seconds. The two tests that assert on exhausting the budget
// shorten it further themselves.
PollEvery: time.Millisecond,
WaitTimeout: 5 * time.Second,
}
}
// Every test above hands Tools a short budget, so the shipped defaults are
// exercised nowhere else — and they are the ones production runs on: a poll
// interval and a ceiling that a real create, downloading an image over a slow
// link, spends minutes inside.
func TestUnconfiguredToolsUseTheShippedWaitBudget(t *testing.T) {
var zero Tools
assert.Equal(t, 2*time.Second, zero.pollEvery())
assert.Equal(t, 10*time.Minute, zero.waitTimeout())
set := Tools{PollEvery: time.Millisecond, WaitTimeout: time.Second}
assert.Equal(t, time.Millisecond, set.pollEvery(), "a configured interval wins over the default")
assert.Equal(t, time.Second, set.waitTimeout())
}
func TestCreateWaitsForReadyAndCloudInit(t *testing.T) {
api := &fakeToolsAPI{phases: []string{"creating", "creating", "ready"}}
run := &fakeRunner{out: ExecResult{ExitCode: 0}}
tl := newTestTools(api, run)
out, err := tl.VMCreate(t.Context(), VMCreateIn{})
require.NoError(t, err)
assert.Equal(t, "new1", out.ID)
assert.Equal(t, "10.77.1.9", out.IP)
assert.Empty(t, out.CloudInit, "a clean cloud-init (exit 0) carries no degraded warning")
assert.Contains(t, out.SSHCommand, "-J localhost:2223")
assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc",
"hint must dial the namespaced <tenant>.<name>, not the bare name the gate rejects")
require.Len(t, api.created, 1)
req := api.created[0]
// MCP pins no size defaults of its own: omitted vcpus/mem_mb/disk_gb reach
// the control plane as zero and inherit its sole named defaults
// (types.Default*, pinned in api_test.go), so the two layers cannot pin
// divergent values.
assert.Zero(t, req.VCPUs)
assert.Zero(t, req.MemMB)
assert.Zero(t, req.DiskGB)
assert.Equal(t, "h1", req.HostID)
assert.Empty(t, req.SSHAuthorizedKey, "no key injection under the CA model")
require.Len(t, run.execs, 1)
assert.Equal(t, []string{"claude-abc|cloud-init status --wait"}, run.execs, "addressed by name, not IP")
}
func TestCreateRetriesThroughPreSSHDBootWindow(t *testing.T) {
// "ready" means the IP is allocated, NOT that the guest has booted sshd, so
// the first SSH dials can fail while the guest is still coming up. Fail the
// first two Exec attempts (pre-sshd) and succeed on the third; vm_create must
// retry through the transient failures rather than give up.
api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
run := &fakeRunner{out: ExecResult{ExitCode: 0}, execErrs: 2}
tl := newTestTools(api, run)
out, err := tl.VMCreate(t.Context(), VMCreateIn{})
require.NoError(t, err)
assert.Equal(t, "10.77.1.9", out.IP)
require.Len(t, run.execs, 3, "two transient failures then a success")
}
func TestCreateRidesThroughTransientListErrors(t *testing.T) {
// First two polls fail (control-plane blip), then the VM reports ready.
api := &fakeToolsAPI{listErrs: 2, phases: []string{"creating", "ready"}}
run := &fakeRunner{out: ExecResult{ExitCode: 0}}
tl := newTestTools(api, run)
out, err := tl.VMCreate(t.Context(), VMCreateIn{})
require.NoError(t, err, "transient ListVMs errors must not abandon the wait")
assert.Equal(t, "new1", out.ID)
assert.Equal(t, "10.77.1.9", out.IP)
require.Len(t, run.execs, 1)
assert.Contains(t, run.execs[0], "cloud-init status --wait")
}
func TestCreateRetriesUntilSSHReachable(t *testing.T) {
// VM reaches "ready" (IP allocated) but sshd is not up yet: the first two SSH
// dials fail with a connection error, the third connects and cloud-init runs.
api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
run := &fakeRunner{out: ExecResult{ExitCode: 0}, execErrs: 2}
tl := newTestTools(api, run)
out, err := tl.VMCreate(t.Context(), VMCreateIn{})
require.NoError(t, err, "must ride out the pre-sshd window, not fail on the first dial")
assert.Equal(t, "10.77.1.9", out.IP)
require.Len(t, run.execs, 3, "two connection failures then a success")
assert.Contains(t, run.execs[2], "cloud-init status --wait")
assert.Empty(t, api.deleted, "spec: never auto-destroy")
}
func TestCreateSSHNeverReachableReports(t *testing.T) {
// VM is "ready" but never becomes SSH-reachable within the budget.
api := &fakeToolsAPI{phases: []string{"ready"}}
run := &fakeRunner{err: fmt.Errorf("dial tcp 10.77.1.9:22: connect: no route to host")}
tl := newTestTools(api, run)
tl.WaitTimeout = 15 * time.Millisecond
out, err := tl.VMCreate(t.Context(), VMCreateIn{})
require.Error(t, err)
assert.Contains(t, err.Error(), "never became SSH-reachable")
assert.Contains(t, err.Error(), "new1", "degraded error must name the VM id so the model can destroy it")
assert.Equal(t, "10.77.1.9", out.IP, "out stays populated so the model can act on it")
assert.Empty(t, api.deleted, "spec: never auto-destroy on unreachable")
}
func TestCreateCloudInitExit2IsDegradedSuccess(t *testing.T) {
// cloud-init exit 2 = "done, with recoverable errors": the guest is booted and
// fully usable. vm_create must SUCCEED (no error) and surface the degradation as
// a warning, so the model doesn't retry and leak the working VM.
api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
run := &fakeRunner{out: ExecResult{ExitCode: 2, Stderr: "some unit failed"}}
tl := newTestTools(api, run)
out, err := tl.VMCreate(t.Context(), VMCreateIn{})
require.NoError(t, err, "exit 2 is a usable VM, not a failed create")
assert.Equal(t, "10.77.1.9", out.IP)
assert.Contains(t, out.CloudInit, "degraded", "exit 2 must carry a degraded warning")
assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc", "a usable VM still gets its ssh hint")
assert.Empty(t, api.deleted, "spec: never auto-destroy")
require.Len(t, run.execs, 1, "a ran cloud-init must NOT be retried")
}
func TestCreateReportsCloudInitFailure(t *testing.T) {
// SSH connects and cloud-init RUNS but exits with a non-recoverable code
// (anything other than 0 or 2). Unlike a connection failure this must NOT be
// retried: cloud-init already ran, so report the degraded VM after exactly one
// Exec.
for _, code := range []int{1, 3} {
t.Run(fmt.Sprintf("exit%d", code), func(t *testing.T) {
api := &fakeToolsAPI{phases: []string{"creating", "ready"}}
run := &fakeRunner{out: ExecResult{ExitCode: code, Stderr: "boom"}}
tl := newTestTools(api, run)
out, err := tl.VMCreate(t.Context(), VMCreateIn{})
require.Error(t, err)
assert.Contains(t, err.Error(), fmt.Sprintf("cloud-init exited %d", code))
assert.Contains(t, err.Error(), "new1", "error must name the VM id")
assert.Contains(t, err.Error(), "claude-abc", "error must name the VM name")
assert.Empty(t, out.CloudInit, "a hard cloud-init failure is an error, not a warning")
assert.Equal(t, "10.77.1.9", out.IP, "out stays populated so the model can act on it")
assert.Empty(t, api.deleted, "spec: never auto-destroy")
require.Len(t, run.execs, 1, "a ran-but-failed cloud-init must NOT be retried")
})
}
}
func TestCreateNoWaitReturnsImmediately(t *testing.T) {
api := &fakeToolsAPI{}
run := &fakeRunner{}
tl := newTestTools(api, run)
no := false
out, err := tl.VMCreate(t.Context(), VMCreateIn{Wait: &no})
require.NoError(t, err)
assert.Equal(t, "new1", out.ID)
assert.Empty(t, out.IP)
assert.Empty(t, run.execs)
}
// mixedFleet is a two-host fleet with a Mac second and a decommissioned-looking
// host offline, the shape placement has to get right. Only the Linux host
// advertises a named network — a Mac cannot serve one at all.
func mixedFleet() []client.Host {
return []client.Host{
{ID: "h1", Name: "onyx", Online: true, AgentVersion: currentAgent, HostNetworks: []string{"lan", "dmz"}},
{ID: "h2", Name: "Squirtle.local", Online: true, AgentVersion: currentAgent},
{ID: "h3", Name: "charmander", Online: false, AgentVersion: currentAgent},
}
}
func TestCreatePlacesOnTheNamedHost(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet()}
tl := newTestTools(api, &fakeRunner{})
no := false
_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "Squirtle.local", Wait: &no})
require.NoError(t, err)
require.Len(t, api.created, 1)
assert.Equal(t, "h2", api.created[0].HostID, "a named host is reachable past the first online one")
}
func TestCreatePlacesOnAHostNamedByID(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet()}
tl := newTestTools(api, &fakeRunner{})
no := false
_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "h2", Wait: &no})
require.NoError(t, err)
require.Len(t, api.created, 1)
assert.Equal(t, "h2", api.created[0].HostID)
}
func TestCreateRefusesAnOfflineHost(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet()}
tl := newTestTools(api, &fakeRunner{})
no := false
_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "charmander", Wait: &no})
require.Error(t, err)
assert.ErrorContains(t, err, `host "charmander" is offline`)
assert.Empty(t, api.created, "an offline host would strand the create")
}
func TestCreateUnknownHostNamesTheFleet(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet()}
tl := newTestTools(api, &fakeRunner{})
no := false
_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "mewtwo", Wait: &no})
require.Error(t, err)
assert.ErrorContains(t, err, `no host with id or name "mewtwo"`)
assert.ErrorContains(t, err, "onyx (online)", "the error names what the fleet DOES have")
assert.ErrorContains(t, err, "Squirtle.local (online)")
assert.ErrorContains(t, err, "charmander (offline)", "with each host's state")
assert.Empty(t, api.created)
}
func TestCreateWithoutAHostTakesTheFirstOnlineOne(t *testing.T) {
// The first host is offline, so the default lands on the second.
hosts := mixedFleet()
hosts[0].Online = false
api := &fakeToolsAPI{hosts: hosts}
tl := newTestTools(api, &fakeRunner{})
no := false
_, err := tl.VMCreate(t.Context(), VMCreateIn{Wait: &no})
require.NoError(t, err)
require.Len(t, api.created, 1)
assert.Equal(t, "h2", api.created[0].HostID)
}
// The network the caller names has to reach the API as a network — it is the
// whole request, and nothing downstream can infer it.
func TestCreateAsksForTheNamedNetwork(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet()}
tl := newTestTools(api, &fakeRunner{})
no := false
out, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "lan", Wait: &no})
require.NoError(t, err)
require.Len(t, api.created, 1)
assert.Equal(t, "lan", api.created[0].Network)
assert.Equal(t, "lan", out.Network, "the accepted network is reported back without waiting for anything")
}
// A create that names no network asks for none: an empty field must stay empty
// on the wire rather than becoming some default underlay name.
func TestCreateWithoutANetworkAsksForNone(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet()}
tl := newTestTools(api, &fakeRunner{})
no := false
out, err := tl.VMCreate(t.Context(), VMCreateIn{Wait: &no})
require.NoError(t, err)
require.Len(t, api.created, 1)
assert.Empty(t, api.created[0].Network)
assert.Empty(t, out.Network)
assert.Empty(t, out.NetworkIP)
}
// Both addresses come back: the host-fabric one the ssh command uses, and the
// one the named network's own DHCP granted.
func TestCreateReportsBothAddresses(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet(), phases: []string{"creating", "ready"}, network: "lan", networkIP: "192.168.0.232"}
tl := newTestTools(api, &fakeRunner{out: ExecResult{ExitCode: 0}})
out, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "lan"})
require.NoError(t, err)
assert.Equal(t, "10.77.1.9", out.IP, "the host-fabric address is still the one ssh reaches it at")
assert.Equal(t, "lan", out.Network)
assert.Equal(t, "192.168.0.232", out.NetworkIP)
}
// Two sources can name the network: what was asked for, and what the server
// recorded. Once the record exists it wins — the echo is only what was asked,
// and it would quietly lie the day the server normalizes a stored name.
func TestCreateReportsTheServersRecordOfTheNetworkOverTheAsk(t *testing.T) {
// The host is named, so placement does not filter on the asked-for spelling
// and the request reaches the server exactly as written.
api := &fakeToolsAPI{hosts: mixedFleet(), phases: []string{"ready"}, network: "lan", networkIP: "192.168.0.232"}
tl := newTestTools(api, &fakeRunner{out: ExecResult{ExitCode: 0}})
out, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "onyx", Network: "LAN"})
require.NoError(t, err)
require.Len(t, api.created, 1)
assert.Equal(t, "LAN", api.created[0].Network, "the ask travels unaltered")
assert.Equal(t, "lan", out.Network, "but the answer is the server's record")
}
// Readiness is the guest booting. A site's DHCP server owes eitri no schedule,
// so a lease that has not landed by then must not hold the create open or fail
// it — the VM is up and usable, and network_ip is simply not known yet.
func TestCreateSucceedsWithTheNetworkLeaseStillOutstanding(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet(), phases: []string{"creating", "ready"}, network: "lan"} // networkIP never arrives
tl := newTestTools(api, &fakeRunner{out: ExecResult{ExitCode: 0}})
out, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "lan"})
require.NoError(t, err)
assert.Equal(t, "10.77.1.9", out.IP)
assert.Equal(t, "lan", out.Network)
assert.Empty(t, out.NetworkIP, "an unanswered lease is an empty field, not a failed create")
}
// Default placement is a free choice, so it is made among hosts that can
// actually serve what was asked for: picking the first eligible host and
// letting the control plane refuse it would manufacture a failure.
func TestCreateWithoutAHostPlacesOnOneServingTheNetwork(t *testing.T) {
hosts := mixedFleet()
hosts[0].HostNetworks = nil // onyx, first and otherwise eligible, serves none
hosts[1].HostNetworks = []string{"lan"} // the second one does
api := &fakeToolsAPI{hosts: hosts}
tl := newTestTools(api, &fakeRunner{})
no := false
_, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "lan", Wait: &no})
require.NoError(t, err)
require.Len(t, api.created, 1)
assert.Equal(t, "h2", api.created[0].HostID)
}
// With nothing in the fleet serving it, the refusal names what IS served —
// the one place an MCP caller learns the fleet's network names without
// provoking a refusal from the control plane.
func TestCreateWithNoHostForTheNetworkNamesWhatIsAdvertised(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet()}
tl := newTestTools(api, &fakeRunner{})
no := false
_, err := tl.VMCreate(t.Context(), VMCreateIn{Network: "storage", Wait: &no})
require.Error(t, err)
assert.ErrorContains(t, err, `no eligible host advertises network "storage"`)
assert.ErrorContains(t, err, "onyx: lan, dmz", "the error names what the fleet DOES advertise")
assert.ErrorContains(t, err, "Squirtle.local: none")
assert.NotContains(t, err.Error(), "charmander", "an offline host has not been asked what it serves")
assert.ErrorContains(t, err, "--host-network storage=<bridge>", "with the remedy in the operator's own vocabulary")
assert.ErrorContains(t, err, "NAT underlay")
assert.Empty(t, api.created, "nothing is created on a host that cannot serve the request")
}
// A caller who names both a host and a network gets that host: the control
// plane owns the pairing and refuses it in one message. Re-deciding it here
// would put a second copy of the rule in front of the first.
func TestCreateOnANamedHostLeavesTheNetworkToTheControlPlane(t *testing.T) {
api := &fakeToolsAPI{hosts: mixedFleet()}
tl := newTestTools(api, &fakeRunner{})
no := false
_, err := tl.VMCreate(t.Context(), VMCreateIn{Host: "Squirtle.local", Network: "lan", Wait: &no})
require.NoError(t, err)
require.Len(t, api.created, 1)
assert.Equal(t, "h2", api.created[0].HostID)
assert.Equal(t, "lan", api.created[0].Network, "the request travels as asked, for the server to judge")
}
func TestCreateWaitTimeoutDoesNotDestroy(t *testing.T) {
api := &fakeToolsAPI{phases: []string{"creating"}} // never ready
run := &fakeRunner{}
tl := newTestTools(api, run)
tl.WaitTimeout = 10 * time.Millisecond
_, err := tl.VMCreate(t.Context(), VMCreateIn{})
require.Error(t, err)
assert.Contains(t, err.Error(), "creating") // current phase reported
assert.Empty(t, api.deleted, "spec: never auto-destroy on timeout")
}
func TestExecResolvesNameAndFormatsResult(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
run := &fakeRunner{out: ExecResult{Stdout: "ok\n", ExitCode: 0}}
tl := newTestTools(api, run)
out, err := tl.VMExec(t.Context(), VMExecIn{VM: "web-1", Command: "echo ok"})
require.NoError(t, err)
assert.Equal(t, 0, out.ExitCode)
assert.Equal(t, "ok\n", out.Stdout)
assert.Equal(t, []string{"web-1|echo ok"}, run.execs, "addressed by name, not IP")
}
func TestExecUnknownVM(t *testing.T) {
tl := newTestTools(&fakeToolsAPI{}, &fakeRunner{})
_, err := tl.VMExec(t.Context(), VMExecIn{VM: "nope", Command: "x"})
assert.ErrorContains(t, err, "no VM with id or name")
}
func TestExecNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
for _, lifecycle := range []string{"creating", "stopped", "failed", "deleting", ""} {
t.Run(lifecycle, func(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: lifecycle}}}
run := &fakeRunner{}
tl := newTestTools(api, run)
_, err := tl.VMExec(t.Context(), VMExecIn{VM: "web-1", Command: "echo ok"})
require.Error(t, err)
assert.ErrorContains(t, err, "not ready")
assert.Empty(t, run.execs, "runner must not be called on a non-ready VM")
})
}
}
func TestWriteFileNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "creating"}}}
run := &fakeRunner{}
tl := newTestTools(api, run)
_, err := tl.VMWriteFile(t.Context(), VMWriteFileIn{VM: "web-1", Path: "/app/x", Content: "data"})
require.Error(t, err)
assert.ErrorContains(t, err, "not ready")
assert.Empty(t, run.files, "runner must not be called on a non-ready VM")
}
func TestReadFileNotReadyVMRejectedWithoutCallingRunner(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "stopped"}}}
run := &fakeRunner{files: map[string][]byte{"/app/x": []byte("data")}}
tl := newTestTools(api, run)
_, err := tl.VMReadFile(t.Context(), VMReadFileIn{VM: "web-1", Path: "/app/x"})
require.Error(t, err)
assert.ErrorContains(t, err, "not ready")
assert.Empty(t, run.reads, "runner must not be called on a non-ready VM")
}
// vm_list and vm_info hand back the API's own VM, so a guest's second address
// is already in both. This pins that: flattening either into a hand-written
// view is exactly how a field goes missing from the surface that is supposed to
// be the programmatic one.
func TestListAndInfoCarryBothAddresses(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{
ID: "abc123", Name: "web-1", Lifecycle: "ready",
AssignedIP: "10.77.1.5", Network: "lan", NetworkIP: "192.168.0.232",
}}}
tl := newTestTools(api, &fakeRunner{})
listed, err := tl.VMList(t.Context(), VMListIn{})
require.NoError(t, err)
require.Len(t, listed.VMs, 1)
assert.Equal(t, "lan", listed.VMs[0].Network)
assert.Equal(t, "192.168.0.232", listed.VMs[0].NetworkIP)
assert.Equal(t, "10.77.1.5", listed.VMs[0].AssignedIP)
info, err := tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"})
require.NoError(t, err)
assert.Equal(t, "lan", info.VM.Network)
assert.Equal(t, "192.168.0.232", info.VM.NetworkIP, "vm_info is where a caller reads a lease that landed late")
}
// TestVMInfoHintNamespacesConnectName pins that vm_info's ssh_command hint dials
// the gate by the <tenant>.<name> connect name (the gate rejects a bare name),
// and that the gateless deployment still emits the plain `ssh <user>@<name>`.
func TestVMInfoHintNamespacesConnectName(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
// Gate configured: hint must be the namespaced -J form.
tl := newTestTools(api, &fakeRunner{})
out, err := tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"})
require.NoError(t, err)
assert.Contains(t, out.SSHCommand, "-J localhost:2223")
assert.Contains(t, out.SSHCommand, "ubuntu@default.web-1",
"gate hint must dial <tenant>.<name>, not the bare name")
assert.NotContains(t, out.SSHCommand, "ubuntu@web-1",
"hint must not contain the bare name the gate rejects")
// Gateless deployment: the direct `ssh <user>@<name>` form is unchanged.
tl.Gate = ""
out, err = tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"})
require.NoError(t, err)
assert.Equal(t, "ssh ubuntu@web-1", out.SSHCommand)
}
func TestDestroyRequiresExactMatch(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1"}}}
tl := newTestTools(api, &fakeRunner{})
_, err := tl.VMDestroy(t.Context(), VMDestroyIn{VM: "web"})
require.Error(t, err)
out, err := tl.VMDestroy(t.Context(), VMDestroyIn{VM: "web-1"})
require.NoError(t, err)
assert.Equal(t, "abc123", out.ID)
assert.Equal(t, []string{"abc123"}, api.deleted)
}
func TestExposePublishesGuestPortWithDialAddress(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
tl := newTestTools(api, &fakeRunner{})
out, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "web-1", GuestPort: 8080})
require.NoError(t, err)
assert.Equal(t, int64(8080), out.Exposure.GuestPort)
assert.Equal(t, int64(30000), out.Exposure.HostPort, "an omitted host port is allocated from the reserved range")
assert.Equal(t, "10.0.0.4:30000", out.Exposure.Address, "the address joins the host's own address with the bound port")
assert.Equal(t, "pending", out.Exposure.State)
// A named host port is passed through to the control plane unchanged.
out, err = tl.VMExpose(t.Context(), VMExposeIn{VM: "abc123", GuestPort: 5432, HostPort: 31500})
require.NoError(t, err)
assert.Equal(t, int64(31500), out.Exposure.HostPort)
assert.Equal(t, "10.0.0.4:31500", out.Exposure.Address)
}
func TestExposeCarriesTheProtocolAndUnexposePicksByIt(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}}}
tl := newTestTools(api, &fakeRunner{})
tcp, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "web-1", GuestPort: 53, HostPort: 30053})
require.NoError(t, err)
assert.Equal(t, "tcp", tcp.Exposure.Protocol, "an unnamed protocol is tcp, and the view says which it got")
udp, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "web-1", GuestPort: 53, HostPort: 30053, Protocol: "udp"})
require.NoError(t, err)
assert.Equal(t, "udp", udp.Exposure.Protocol)
// One guest port, both protocols: the same ambiguity two host ports create,
// and protocol is the other way to resolve it.
_, err = tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 53})
require.Error(t, err)
assert.ErrorContains(t, err, "53:30053/udp", "the error names the protocol of what IS published")
assert.Empty(t, api.revoked)
out, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 53, Protocol: "udp"})
require.NoError(t, err)
assert.Equal(t, udp.Exposure.ID, out.ID)
assert.Equal(t, "udp", out.Protocol)
assert.Equal(t, []string{udp.Exposure.ID}, api.revoked)
}
func TestExposeUnknownVM(t *testing.T) {
tl := newTestTools(&fakeToolsAPI{}, &fakeRunner{})
_, err := tl.VMExpose(t.Context(), VMExposeIn{VM: "nope", GuestPort: 80})
assert.ErrorContains(t, err, "no VM with id or name")
}
func TestExposuresListsPublishedPorts(t *testing.T) {
api := &fakeToolsAPI{
vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}},
// A host that has not reported its uplink yields no address: the grant
// exists before there is anywhere to name.
exposures: map[string][]client.Exposure{"abc123": {
{ID: "x-1", GuestPort: 80, HostPort: 30000, HostAddr: "10.0.0.4", State: "active"},
{ID: "x-2", GuestPort: 443, HostPort: 30001, State: "failed", Reason: "address already in use"},
}},
}
tl := newTestTools(api, &fakeRunner{})
out, err := tl.VMExposures(t.Context(), VMExposuresIn{VM: "web-1"})
require.NoError(t, err)
require.Len(t, out.Exposures, 2)
assert.Equal(t, "10.0.0.4:30000", out.Exposures[0].Address)
assert.Equal(t, "active", out.Exposures[0].State)
assert.Empty(t, out.Exposures[1].Address, "no host address yet means no address to dial")
assert.Equal(t, "address already in use", out.Exposures[1].Reason)
// A VM that publishes nothing lists nothing, not an error.
api.vms = append(api.vms, client.VM{ID: "def456", Name: "quiet-1", Lifecycle: "ready"})
out, err = tl.VMExposures(t.Context(), VMExposuresIn{VM: "quiet-1"})
require.NoError(t, err)
assert.Empty(t, out.Exposures)
}
func TestUnexposeResolvesTheExposureByGuestPort(t *testing.T) {
api := &fakeToolsAPI{
vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}},
exposures: map[string][]client.Exposure{"abc123": {
{ID: "x-1", GuestPort: 80, HostPort: 30000, HostAddr: "10.0.0.4"},
{ID: "x-2", GuestPort: 443, HostPort: 30001, HostAddr: "10.0.0.4"},
}},
}
tl := newTestTools(api, &fakeRunner{})
out, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 443})
require.NoError(t, err)
assert.Equal(t, "x-2", out.ID, "the caller names a guest port; the id is resolved for it")
assert.Equal(t, int64(443), out.GuestPort)
assert.Equal(t, int64(30001), out.HostPort)
assert.Equal(t, []string{"x-2"}, api.revoked)
}
func TestUnexposeUnpublishedPortNamesWhatIsPublished(t *testing.T) {
api := &fakeToolsAPI{
vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}},
exposures: map[string][]client.Exposure{"abc123": {
{ID: "x-1", GuestPort: 80, HostPort: 30000},
}},
}
tl := newTestTools(api, &fakeRunner{})
_, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 8080})
require.Error(t, err)
assert.ErrorContains(t, err, "publishes no guest port 8080")
assert.ErrorContains(t, err, "80:30000", "the error names what IS published")
assert.Empty(t, api.revoked, "a miss revokes nothing")
// A VM publishing nothing at all says so rather than naming an empty set.
api.exposures = nil
_, err = tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80})
assert.ErrorContains(t, err, "none")
}
func TestUnexposeAmbiguousGuestPortRefusesUntilHostPortNamed(t *testing.T) {
// Nothing stops one guest port being published on two host ports. Closing
// one of them is a guess, so refuse and say how to choose.
api := &fakeToolsAPI{
vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready"}},
exposures: map[string][]client.Exposure{"abc123": {
{ID: "x-1", GuestPort: 80, HostPort: 30000},
{ID: "x-2", GuestPort: 80, HostPort: 31000},
}},
}
tl := newTestTools(api, &fakeRunner{})
_, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80})
require.Error(t, err)
assert.ErrorContains(t, err, "name host_port")
assert.Empty(t, api.revoked, "an ambiguous match closes no listener")
out, err := tl.VMUnexpose(t.Context(), VMUnexposeIn{VM: "web-1", GuestPort: 80, HostPort: 31000})
require.NoError(t, err)
assert.Equal(t, "x-2", out.ID)
assert.Equal(t, []string{"x-2"}, api.revoked)
}
func TestWriteAndReadFileTools(t *testing.T) {
api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}}
run := &fakeRunner{}
tl := newTestTools(api, run)
_, err := tl.VMWriteFile(t.Context(), VMWriteFileIn{VM: "abc123", Path: "/app/x", Content: "data"})
require.NoError(t, err)
rd, err := tl.VMReadFile(t.Context(), VMReadFileIn{VM: "web-1", Path: "/app/x"})
require.NoError(t, err)
assert.Equal(t, "data", rd.Content)
}
// ── ca_upload ────────────────────────────────────────────────────────────────
// caLine is a real ed25519 public key in authorized_keys form.
func caLine(t *testing.T) string {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
sp, err := ssh.NewPublicKey(pub)
require.NoError(t, err)
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " alex@laptop"
}
// TestCAUploadRegistersThePublicHalf: the line reaches the API unchanged, the
// label rides with it, and the answer names the CA that was registered and the
// consequence of having registered it now rather than earlier.
func TestCAUploadRegistersThePublicHalf(t *testing.T) {
api := &fakeToolsAPI{}
tools := &Tools{API: api}
line := caLine(t)
out, err := tools.CAUpload(t.Context(), CAUploadIn{PublicKey: line, Label: "laptop"})
require.NoError(t, err)
require.Len(t, api.cas, 1)
assert.Equal(t, line, api.cas[0][0], "the key must reach the API byte for byte")
assert.Equal(t, "laptop", api.cas[0][1])
assert.True(t, strings.HasPrefix(out.Fingerprint, "SHA256:"), "got %q", out.Fingerprint)
assert.Equal(t, "laptop", out.Label)
assert.Contains(t, out.Note, "created BEFORE this upload will not")
}
// TestCAUploadRefusesWhatIsNotACAPublicKey: a bad line is a tool error naming
// the problem, not a 400 the model has to interpret — and a certificate is the
// mistake worth naming, since it looks like a key and is not one.
func TestCAUploadRefusesWhatIsNotACAPublicKey(t *testing.T) {
api := &fakeToolsAPI{}
tools := &Tools{API: api}
_, err := tools.CAUpload(t.Context(), CAUploadIn{})
require.Error(t, err)
assert.Contains(t, err.Error(), "public_key is required")
_, err = tools.CAUpload(t.Context(), CAUploadIn{PublicKey: "not a key"})
require.Error(t, err)
assert.Contains(t, err.Error(), "not an SSH public key line")
_, err = tools.CAUpload(t.Context(), CAUploadIn{PublicKey: certLine(t)})
require.Error(t, err)
assert.Contains(t, err.Error(), "certificate, not a CA public key")
assert.Empty(t, api.cas, "nothing reaches the API until the line parses")
}
// certLine builds a user certificate, the thing most easily confused for a key.
func certLine(t *testing.T) string {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
signer, err := ssh.NewSignerFromSigner(priv)
require.NoError(t, err)
cert := &ssh.Certificate{Key: signer.PublicKey(), CertType: ssh.UserCert,
ValidPrincipals: []string{"ubuntu"}, ValidBefore: ssh.CertTimeInfinity}
require.NoError(t, cert.SignCert(rand.Reader, signer))
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(cert)))
}
// TestCAUploadSurfacesARefusal: the API's no reaches the model as a tool error.
func TestCAUploadSurfacesARefusal(t *testing.T) {
api := &fakeToolsAPI{caErr: errors.New("forbidden")}
tools := &Tools{API: api}
_, err := tools.CAUpload(t.Context(), CAUploadIn{PublicKey: caLine(t)})
require.Error(t, err)
assert.Contains(t, err.Error(), "registering the CA with your tenant")
}
// ── tenant_info ──────────────────────────────────────────────────────────────
// TestTenantInfoReportsSetupWithoutKeyMaterial: the answer identifies each CA
// and says whether eitri can currently reach anything — and carries no key.
func TestTenantInfoReportsSetupWithoutKeyMaterial(t *testing.T) {
api := &fakeToolsAPI{
userCAs: []client.UserCA{
{Fingerprint: "SHA256:aaa", Label: "laptop", PubKey: "ssh-ed25519 AAAASECRETLOOKING alex"},
},
delegation: &client.Delegation{
KeyID: "eitri-delegation", Principals: []string{"ubuntu"}, ExpiresAt: "2026-08-09T00:00:00Z",
},
}
tools := &Tools{API: api, Gate: "gate.eitri.sh:2222"}
out, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
require.NoError(t, err)
require.Len(t, out.CAs, 1)
assert.Equal(t, "SHA256:aaa", out.CAs[0].Fingerprint)
assert.Equal(t, "laptop", out.CAs[0].Label)
assert.True(t, out.Delegated)
require.NotNil(t, out.Delegation)
assert.Equal(t, []string{"ubuntu"}, out.Delegation.Principals)
assert.Equal(t, "2026-08-09T00:00:00Z", out.Delegation.ExpiresAt)
assert.Equal(t, "gate.eitri.sh:2222", out.Gate)
raw, err := json.Marshal(out)
require.NoError(t, err)
assert.NotContains(t, string(raw), "AAAASECRETLOOKING", "tenant_info describes CAs, it does not reproduce them")
}
// TestTenantInfoReportsNoDelegationAsData: not having delegated is the common
// state and the thing a caller most needs told, so it is a field rather than an
// error to parse.
func TestTenantInfoReportsNoDelegationAsData(t *testing.T) {
tools := &Tools{API: &fakeToolsAPI{}}
out, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
require.NoError(t, err)
assert.False(t, out.Delegated)
assert.Nil(t, out.Delegation)
assert.Empty(t, out.CAs)
}
// TestTenantInfoSurfacesAReadFailure: the CA list is the part that must work.
func TestTenantInfoSurfacesAReadFailure(t *testing.T) {
tools := &Tools{API: &fakeToolsAPI{listCAErr: errors.New("unauthorized")}}
_, err := tools.TenantInfo(t.Context(), TenantInfoIn{})
require.Error(t, err)
assert.Contains(t, err.Error(), "reading this tenant's registered CAs")
}