internal/smoke/run.go
Ref: Size: 7.7 KiB History
// Package smoke is the deploy boot-gate harness. It drives the live eitri
// fleet through a credential-chain proof and a create -> boot-proof -> gate-SSH
// -> reap of one throwaway VM, returning an error on any failure. Everything it
// proves it proves through eitri's own front door — the API, the serial console,
// the SSH-CA gate, a published port — so it needs no login on any host in the
// fleet and embeds no control-plane or agent code. The eitri-smoke command is
// thin wiring over Run (arch R14).
package smoke
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/a73x/eitri/internal/server/api/client"
)
// Run executes one boot-gate pass, reading its settings from the process
// environment. It returns an error describing the first failed step; a nil
// return means the credential chain, VM boot-proof, and reap all passed.
func Run() error {
cfg, err := loadConfig(os.Getenv)
if err != nil {
return err
}
// Phase 1 — credential-chain proof. The admin token is gone: the boot-gate
// authenticates like a human. Read the machine identity's password (CI_USER,
// the deploy identity), sign in through the real OIDC code flow, mint a
// short-lived PAT, and confirm it resolves to a tenant (spec §3). This proves
// issuer, login form, session, and PAT mint end to end; it deliberately never
// touches VMs — the machine identity's JIT tenant owns no hosts (the fleet's
// `default` tenant is human-owned), so the lifecycle half runs as the operator
// below.
//
// A plane whose issuer is a real identity provider has no password to post,
// so the proof is opt-in: no CI_USER means the run starts at phase 2 and the
// operator PAT carries it. What that plane gives up is stated out loud
// rather than silently, because it is the one leg a hosted run cannot make.
var signedInPAT string
if cfg.CIUser == "" {
fmt.Println("credential chain: skipped (no CI_USER — this plane signs in against a real identity provider)")
} else {
pwBytes, err := os.ReadFile(cfg.CIPasswordFile)
if err != nil {
return fmt.Errorf("read CI password file %q: %w", cfg.CIPasswordFile, err)
}
password := strings.TrimRight(string(pwBytes), "\r\n")
signedInPAT, err = loginPAT(cfg.ServerURL, cfg.CIUser, password)
if err != nil {
return err
}
ciTenant, err := proveCredentialChain(cfg.ServerURL, signedInPAT)
if err != nil {
return err
}
fmt.Printf("credential chain OK (tenant %s)\n", ciTenant)
}
// The remote MCP endpoint's cheap half: it must exist and must refuse an
// unauthenticated caller. It needs no guest, so a misrouted or unprotected
// /mcp fails here in a second rather than after a VM has booted. Every
// origin in the list answers for itself — a plane fronted both through a
// proxy and directly can route one and not the other.
for _, origin := range cfg.MCPURLs {
if err := proveRemoteMCPNeedsACredential(origin); err != nil {
return err
}
}
fmt.Printf("remote MCP endpoint refuses an unauthenticated caller (%s)\n", strings.Join(cfg.MCPURLs, ", "))
// Phase 2 — VM lifecycle, on a normal tenant-scoped console PAT (spec §3's
// automation story). It comes from CI_PAT_FILE where an operator minted one
// by hand, because the fleet's hosts belong to that operator's tenant and
// the machine identity's own JIT tenant owns none. Where the plane's issuer
// is one of ours, the identity that just signed in IS the operator, so the
// PAT it minted carries this phase and there is no token to paste anywhere.
// The scenario's tenant is DERIVED via Me() either way, never assumed, and
// threaded into the user-CA registration and gate connect name below.
pat := signedInPAT
if cfg.CIPATFile != "" {
patBytes, err := os.ReadFile(cfg.CIPATFile)
if err != nil {
return fmt.Errorf("read CI PAT file %q: %w", cfg.CIPATFile, err)
}
pat = strings.TrimRight(string(patBytes), "\r\n")
} else {
fmt.Println("VM lifecycle runs on the PAT minted by the sign-in above")
}
api := &client.Client{
BaseURL: cfg.ServerURL,
Token: pat,
UserCALabel: "eitri-smoke",
HTTP: &http.Client{Timeout: 30 * time.Second},
}
me, err := api.Me()
if err != nil {
return fmt.Errorf("resolve operator PAT tenant: %w", err)
}
if me.Tenant == "" {
source := cfg.CIPATFile
if source == "" {
source = "minted by " + cfg.CIUser + "'s sign-in"
}
return fmt.Errorf("operator PAT (%s) resolved to an empty tenant", source)
}
tenant := me.Tenant
fmt.Printf("operator PAT tenant: %s\n", tenant)
vmName, err := randVMName()
if err != nil {
return fmt.Errorf("generate vm name: %w", err)
}
// The smoke's own user CA. It is the gate check's CA when one is configured,
// and it is what the MCP leg delegates with either way — so an in-memory CA
// stands in when SMOKE_USER_CA_FILE is unset, and the delegation leg never
// depends on how the gate happens to be configured.
userCA, err := smokeUserCA(cfg.SmokeUserCAFile)
if err != nil {
return err
}
var gate *gateHooks
if cfg.SmokeGate != "" && cfg.SmokeUserCAFile != "" {
gate = realGateHooks(cfg, tenant, api, userCA, time.Now, time.Sleep)
} else {
fmt.Fprintln(os.Stderr, "eitri-smoke: gate SSH check skipped (SMOKE_GATE/SMOKE_USER_CA_FILE not set)")
}
// A second CA, registered with nobody, for the negative delegation leg.
stranger, err := generateUserCA()
if err != nil {
return err
}
ctx := context.Background()
// Every origin past the first is proven routable and authenticated and then
// let go: it advertises the whole toolset over a bearer PAT. The full cycle
// below runs once, on the first origin — the entry a plane points at the
// path it most needs watched.
for _, origin := range cfg.MCPURLs[1:] {
if err := proveRemoteMCPToolset(ctx, origin, pat); err != nil {
return err
}
fmt.Printf("remote MCP toolset OK at %s\n", origin)
}
// Remote MCP, over the same PAT: no local install, no uploaded CA, nothing
// but a token and an HTTP endpoint. The session lives for the whole run.
tools, closeMCP, err := dialMCP(ctx, cfg.MCPURLs[0], pat)
if err != nil {
return err
}
defer closeMCP()
mcp := func(ctx context.Context, name string) error {
return proveMCP(ctx, tools, name, delegator{
ca: userCA,
stranger: stranger,
principal: cfg.SmokeVMUser,
now: time.Now,
}, time.Now, time.Sleep, readBanner, readEcho)
}
msg, err := runScenario(ctx, vmName, api, api.DialConsole, gate, mcp, cfg.ExpectAgentVersion, time.Now, time.Sleep, realReadPubKey, readBanner)
if err != nil {
return err
}
fmt.Println(msg)
// The boot gate has passed. Collect integration coverage as a by-product
// when COVER_OUT is set; a failure here must NOT fail the deploy, so warn
// and carry on — the gate above is the authoritative check.
if cfg.CoverOut != "" {
if err := collectCoverage(ctx, cfg); err != nil {
fmt.Fprintln(os.Stderr, "eitri-smoke: coverage collection failed (boot gate still passed):", err)
}
}
return nil
}
// randVMName generates a throwaway VM name of the form "smoke-<8 hex digits>",
// unique enough that concurrent smoke runs don't collide.
func randVMName() (string, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return "smoke-" + hex.EncodeToString(b[:]), nil
}
// realReadPubKey reads the local operator's SSH public key, trying
// ~/.ssh/id_ed25519.pub then ~/.ssh/id_rsa.pub. It returns "" (not an error)
// if neither is present — the scenario tolerates a keyless VM.
func realReadPubKey() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
for _, name := range []string{"id_ed25519.pub", "id_rsa.pub"} {
data, err := os.ReadFile(filepath.Join(home, ".ssh", name))
if err == nil {
return strings.TrimSpace(string(data))
}
}
return ""
}