a73x

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