a73x

internal/smoke/config.go

Ref:   Size: 6.4 KiB   History

package smoke

import (
	"fmt"
	"strconv"
	"strings"

	"github.com/a73x/eitri/internal/guest"
)

// Config holds the environment-sourced settings for one smoke run. It mirrors
// the variables the deploy boot-gate reads from $EITRI_DEPLOY_ENV.
type Config struct {
	ServerURL string
	// CIUser and CIPasswordFile drive the credential-chain proof: a headless
	// sign-in that posts a password to the issuer's login form. Both empty ⇒
	// that proof is skipped, which is what a plane fronted by a real identity
	// provider needs — a Google-backed console has no password to post, and the
	// operator PAT below carries the run instead.
	CIUser         string
	CIPasswordFile string
	// CIPATFile is an operator-minted token for the VM lifecycle. Empty ⇒ the
	// PAT minted by the sign-in above is used instead, which is right whenever
	// the identity that signs in is itself the operator.
	CIPATFile string

	// AgentUserHost/AgentPort are the ssh target coverage collection pulls the
	// agent's raw profile from (AGENT_HOSTS' first entry). Nothing the gate
	// PROVES goes near them: the run itself only ever speaks to the control
	// plane, so a plane whose hosts the runner cannot log into is still fully
	// gated — it just collects no agent-side coverage.
	AgentUserHost string
	AgentPort     int

	// MCPURLs are the origins the remote-MCP leg exercises (SMOKE_MCP_URL, a
	// space-separated list; a single ServerURL when unset). The FIRST entry gets
	// the full leg — register, create, exec, expose, banner, destroy — and every
	// later entry gets the cheap ones: an unauthenticated POST must be refused,
	// and the advertised toolset must be complete. One long call is enough to
	// observe a proxy's silent-origin timeout; a second would only double the VM
	// churn for coverage already held.
	MCPURLs []string

	// Carried for a later coverage-collection task; optional here.
	ServerGocoverdir string
	AgentGocoverdir  string
	CoverOut         string

	// ExpectAgentVersion (SMOKE_EXPECT_AGENT_VERSION) is the release this run is
	// proving. Set it and the scenario refuses to place its VM on a host whose
	// agent reports some other version — the run proves the release, not merely
	// the plane serving it, so a fleet that never converged fails the smoke
	// instead of passing it on a release-old agent. Empty ⇒ no such expectation,
	// which is what every run but a ship's has: the boot gate proves binaries
	// built from a working tree, whose stamped version is "dev".
	ExpectAgentVersion string

	// SSH-CA gate check. When SmokeGate and SmokeUserCAFile are both set, the
	// scenario proves guest access through the gate (a hard gate). Optional.
	SmokeGate       string // SMOKE_GATE, "<gate-domain>:<port>"
	SmokeVMUser     string // SMOKE_VM_USER, default guest.LoginUser
	SmokeUserCAFile string // SMOKE_USER_CA_FILE, load-or-create user CA key
}

// loadConfig reads the required smoke settings via getenv (never the real
// process environment directly, so callers can inject a fake for tests). It
// returns an error naming the first missing required variable.
func loadConfig(getenv func(string) string) (Config, error) {
	serverURL := getenv("SERVER_URL")
	ciUser := getenv("CI_USER")
	ciPasswordFile := getenv("CI_PASSWORD_FILE")
	ciPATFile := getenv("CI_PAT_FILE")
	agentHosts := getenv("AGENT_HOSTS")
	agentGocoverdir := getenv("AGENT_GOCOVERDIR")

	var missing []string
	if serverURL == "" {
		missing = append(missing, "SERVER_URL")
	}
	// CI_USER/CI_PASSWORD_FILE are deliberately absent from this list: a plane
	// whose issuer is a real IdP cannot offer a headless password sign-in, so
	// the credential-chain proof is opt-in. Setting exactly one of the pair is
	// a typo rather than a choice, and is rejected below.
	if (ciUser == "") != (ciPasswordFile == "") {
		return Config{}, fmt.Errorf("CI_USER and CI_PASSWORD_FILE must be set together (set neither to skip the credential-chain proof)")
	}
	// CI_PAT_FILE carries the VM lifecycle as an operator whose tenant owns the
	// fleet's hosts. It is required only when nothing else can produce such a
	// token: where a password issuer exists, the sign-in above mints one, and
	// on a plane whose signed-in identity IS the operator that is the whole
	// credential story — no PAT to paste, nothing to rotate by hand.
	if ciPATFile == "" && ciUser == "" {
		missing = append(missing, "CI_PAT_FILE (or CI_USER, to mint one by signing in)")
	}
	// AGENT_HOSTS is required only by coverage collection, which is the one
	// thing here that reaches past the API onto a host. A run that collects no
	// agent coverage needs no ssh target, and asking for one would be asking a
	// hosted plane for a login it has no reason to hand out.
	if agentHosts == "" && agentGocoverdir != "" {
		missing = append(missing, "AGENT_HOSTS (to pull AGENT_GOCOVERDIR from the host)")
	}
	if len(missing) > 0 {
		return Config{}, fmt.Errorf("missing required env var(s): %s", strings.Join(missing, ", "))
	}

	userHost, port := parseAgentHost(agentHosts)

	smokeVMUser := getenv("SMOKE_VM_USER")
	if smokeVMUser == "" {
		smokeVMUser = guest.LoginUser
	}

	mcpURLs := strings.Fields(getenv("SMOKE_MCP_URL"))
	if len(mcpURLs) == 0 {
		mcpURLs = []string{serverURL}
	}

	return Config{
		ServerURL:          serverURL,
		MCPURLs:            mcpURLs,
		ExpectAgentVersion: getenv("SMOKE_EXPECT_AGENT_VERSION"),
		CIUser:             ciUser,
		CIPasswordFile:     ciPasswordFile,
		CIPATFile:          ciPATFile,
		AgentUserHost:      userHost,
		AgentPort:          port,
		ServerGocoverdir:   getenv("SERVER_GOCOVERDIR"),
		AgentGocoverdir:    agentGocoverdir,
		CoverOut:           getenv("COVER_OUT"),
		SmokeGate:          getenv("SMOKE_GATE"),
		SmokeVMUser:        smokeVMUser,
		SmokeUserCAFile:    getenv("SMOKE_USER_CA_FILE"),
	}, nil
}

// parseAgentHost takes the AGENT_HOSTS value (space-separated "user@host[:port]"
// entries) and returns the first entry's user@host plus its port. The port is
// the substring after the LAST colon when that substring is entirely digits;
// otherwise there is no port and it defaults to 22. An empty value yields an
// empty target: no host was named, and none is needed.
func parseAgentHost(agentHosts string) (userHost string, port int) {
	entries := strings.Fields(agentHosts)
	if len(entries) == 0 {
		return "", 0
	}
	entry := entries[0]

	if idx := strings.LastIndex(entry, ":"); idx != -1 {
		if p, err := strconv.Atoi(entry[idx+1:]); err == nil {
			return entry[:idx], p
		}
	}
	return entry, 22
}