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
}