internal/agent/reconcile/hostkey_test.go
Ref: Size: 5.5 KiB History
package reconcile
import (
"bytes"
"os"
"testing"
"time"
"github.com/a73x/eitri/internal/agent/seed"
"github.com/a73x/eitri/internal/pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)
// needsHostCert marks a desired VM the way a fleet with an SSH CA does.
func needsHostCert(v *pb.VMSpec) { v.HostCertRequired = true }
// withHostCert supplies the certificate the control plane signed.
func withHostCert(cert string) func(*pb.VMSpec) {
return func(v *pb.VMSpec) { v.HostCertRequired = true; v.SshHostCert = cert }
}
// TestAwaitingHostCertDoesNotSpendRetryBudget is the regression that matters
// most in this exchange. Waiting for a certificate is the round trip working;
// if it were charged to the create budget, three slow ticks would terminal-fail
// every VM on the fleet at once.
func TestAwaitingHostCertDoesNotSpendRetryBudget(t *testing.T) {
f := setup(t)
for tick := uint64(1); tick <= 3; tick++ {
rep := f.step(snap(tick, vm("vm1", needsHostCert)))
row := findVM(rep, "vm1")
require.NotNil(t, row)
assert.Equal(t, "creating", row.Phase, "tick %d", tick)
assert.NotEmpty(t, row.SshHostPubkey, "the public key must ride every report")
}
rec, ok, err := f.st.Get("vm1")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, 0, rec.CreateAttempts, "waiting for a certificate is not an attempt")
// Nothing was built either: no disk, no seed, no boot.
assert.Empty(t, f.prov.prepared)
assert.Empty(t, f.prov.booted)
assert.Equal(t, 0, f.prov.prepCalls)
// And the budget is intact, so the VM still creates once the cert lands.
rep := f.step(snap(4, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host"))))
assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
}
// TestHostKeySurvivesAnAgentRestart pins load-or-create: the key the control
// plane signs must be the key this host still holds afterwards.
func TestHostKeySurvivesAnAgentRestart(t *testing.T) {
f := setup(t)
first := findVM(f.step(snap(1, vm("vm1", needsHostCert))), "vm1").GetSshHostPubkey()
require.NotEmpty(t, first)
// A restart loses every in-memory worker; the record and the key file are
// all that carry over.
f.restart(t)
second := findVM(f.step(snap(2, vm("vm1", needsHostCert))), "vm1").GetSshHostPubkey()
assert.Equal(t, first, second, "a restart must report the same key, not a new one")
}
// TestSeedReceivesTheOnDiskHostKey proves the private half goes from this
// host's disk straight into the guest's seed, and that the certificate the
// control plane sent is the one installed beside it.
func TestSeedReceivesTheOnDiskHostKey(t *testing.T) {
f := setup(t)
var got seed.Params
f.eng.Seed = func(_ string, p seed.Params) error { got = p; return nil }
f.step(snap(1, vm("vm1", needsHostCert)))
onDisk, err := os.ReadFile(f.st.HostKeyPath("vm1"))
require.NoError(t, err)
const cert = "ssh-ed25519-cert-v01@openssh.com AAAAcert host"
f.step(snap(2, vm("vm1", withHostCert(cert))))
assert.Equal(t, string(onDisk), got.SSHHostKeyPEM)
assert.Equal(t, cert, got.SSHHostCert)
assert.Contains(t, got.SSHHostKeyPEM, "OPENSSH PRIVATE KEY")
}
// TestReportCarriesNoPrivateKeyMaterial is the blunt assertion: whatever else
// changes about the report, the guest's private key must never be in it. The
// public half rides every report; the private half rides none.
func TestReportCarriesNoPrivateKeyMaterial(t *testing.T) {
f := setup(t)
f.step(snap(1, vm("vm1", needsHostCert)))
priv, err := os.ReadFile(f.st.HostKeyPath("vm1"))
require.NoError(t, err)
rep := f.step(snap(2, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host"))))
raw, err := proto.Marshal(rep)
require.NoError(t, err)
assert.False(t, bytes.Contains(raw, priv), "the private key must never reach the wire")
assert.False(t, bytes.Contains(raw, []byte("PRIVATE KEY")))
assert.Contains(t, string(raw), findVM(rep, "vm1").GetSshHostPubkey())
}
// TestHostKeyFileIsPrivate: the file the seed reads is the guest's identity.
func TestHostKeyFileIsPrivate(t *testing.T) {
f := setup(t)
f.step(snap(1, vm("vm1", needsHostCert)))
fi, err := os.Stat(f.st.HostKeyPath("vm1"))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
}
// TestReapRemovesTheHostKey: a destroyed VM leaves no key behind.
func TestReapRemovesTheHostKey(t *testing.T) {
f := setup(t)
f.step(snap(1, vm("vm1", withHostCert("ssh-ed25519-cert-v01@openssh.com AAAAcert host"))))
require.FileExists(t, f.st.HostKeyPath("vm1"))
f.step(snap(2, tombstoned(vm("vm1"))))
f.now = f.now.Add(10 * time.Minute)
f.step(snap(3, tombstoned(vm("vm1"))))
_, err := os.Stat(f.st.HostKeyPath("vm1"))
assert.True(t, os.IsNotExist(err), "destroying a VM must take its host key with it")
}
// TestGateOffGivesTheGuestNoHostKey: with no CA in the fleet there is no
// certificate to wait for and nothing that could certify a key, so the agent
// generates none and the guest falls back to the one it makes for itself.
func TestGateOffGivesTheGuestNoHostKey(t *testing.T) {
f := setup(t)
var got seed.Params
f.eng.Seed = func(_ string, p seed.Params) error { got = p; return nil }
rep := f.step(snap(1, vm("vm1")))
assert.Equal(t, "ready", findVM(rep, "vm1").Phase)
assert.Empty(t, findVM(rep, "vm1").SshHostPubkey)
assert.Empty(t, got.SSHHostKeyPEM, "there is no other place a host key could come from")
assert.Empty(t, got.SSHHostCert)
_, err := os.Stat(f.st.HostKeyPath("vm1"))
assert.True(t, os.IsNotExist(err), "no CA in the fleet means no key to generate")
}