internal/smoke/gatecheck.go
Ref: Size: 4.9 KiB History
package smoke
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/a73x/eitri/internal/gateclient"
"golang.org/x/crypto/ssh"
)
// realGateHooks builds the live SSH-CA gate steps: register the smoke user CA
// with the tenant, and reach the guest through the gate to prove access. The
// tenant is the operator PAT's own tenant (derived via Me() by the caller), so
// the CA registration and connect names match the fleet's real partition.
func realGateHooks(cfg Config, tenant string, ca gateclient.CertAuthority, userCA ssh.Signer, now func() time.Time, sleep func(time.Duration)) *gateHooks {
auth := gateclient.NewGateAuth(ca, userCA, tenant, cfg.SmokeVMUser, now)
return &gateHooks{
register: func(ctx context.Context) error { return auth.Register(ctx) },
exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) },
run: func(ctx context.Context, vmName, cmd string) (string, error) {
return gateRun(ctx, cfg, auth, vmName, cmd, now, sleep)
},
}
}
// gateExec proves the guest is reachable through the SSH-CA gate, and that the
// seed the guest booted from survived the tenant cloud-init it was created with.
// It retries dial+login over the guest's pre-sshd boot window (a dial or session
// error mid-boot is expected, not fatal) until it logs in as cfg.SmokeVMUser,
// confirms `id -un` echoes that same user, and reads back a guest where both
// sides of the cloud-init merge landed — or the 120s deadline expires.
//
// The login itself is half the merge proof: a CA-signed certificate is accepted
// only because the seed's write_files put the trust file and the sshd drop-in on
// the guest, which is exactly what a tenant `write_files` used to replace. The
// probe supplies the other half, the root the seed's runcmd grew.
//
// The probe is inside the retry rather than after it because cloud-init writes
// guest files before it runs runcmd, so a login can land in the window between
// the two: a root that is not grown YET is an unfinished boot, not a verdict.
// Only the deadline decides, and lastErr carries the reason it ran out.
func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmName string, now func() time.Time, sleep func(time.Duration)) error {
var lastErr error
err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) {
client, dialErr := gateclient.Dial(ctx, gateclient.DialConfig{
Gate: cfg.SmokeGate,
Auth: auth,
}, vmName)
if dialErr != nil {
lastErr = dialErr
return false, nil
}
defer client.Close()
out, runErr := runGuestCommand(client, "id -un")
if runErr != nil {
lastErr = runErr
return false, nil
}
got := strings.TrimSpace(out)
if got != cfg.SmokeVMUser {
return false, fmt.Errorf("gate SSH logged into %q as %q, want %q", vmName, got, cfg.SmokeVMUser)
}
probe, probeErr := runGuestCommand(client, seedProbeCmd)
if probeErr != nil {
lastErr = probeErr
return false, nil
}
if seedErr := proveSeedSurvivedBYO(probe); seedErr != nil {
lastErr = seedErr
return false, nil
}
return true, nil
})
if err != nil {
if errors.Is(err, errPollTimeout) {
return fmt.Errorf("FAIL: guest %q did not pass the gate proof within 120s: %w", vmName, lastErr)
}
return err
}
return nil
}
// gateRun runs one command inside a guest through the SSH-CA gate and returns
// what it printed. Like gateExec it retries the DIAL over the guest's pre-sshd
// boot window, since a leg that reaches a fresh guest is racing sshd's start.
// The command itself is not retried: what the callers run is not idempotent —
// a second mount of an already-mounted volume fails on its own — so a command
// that ran and failed is the answer, not a reason to try again.
func gateRun(ctx context.Context, cfg Config, auth gateclient.Credentials, vmName, cmd string, now func() time.Time, sleep func(time.Duration)) (string, error) {
var out string
var lastErr error
err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) {
client, dialErr := gateclient.Dial(ctx, gateclient.DialConfig{
Gate: cfg.SmokeGate,
Auth: auth,
}, vmName)
if dialErr != nil {
lastErr = dialErr
return false, nil
}
defer client.Close()
got, runErr := runGuestCommand(client, cmd)
if runErr != nil {
return false, runErr
}
out = got
return true, nil
})
if errors.Is(err, errPollTimeout) {
return "", fmt.Errorf("could not reach guest %q through the gate within 120s: %w", vmName, lastErr)
}
return out, err
}
// runGuestCommand runs cmd in a new session on client and returns its stdout.
func runGuestCommand(client *ssh.Client, cmd string) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("open ssh session: %w", err)
}
defer session.Close()
out, err := session.Output(cmd)
if err != nil {
return "", fmt.Errorf("run %q: %w", cmd, err)
}
return string(out), nil
}