ISO tweaks, email prompt on installer, improved network detection

This commit is contained in:
Ben Duncan
2026-04-20 16:56:29 +10:00
parent 27d7fdd12b
commit 3d1f8340be
14 changed files with 372 additions and 43 deletions
+3
View File
@@ -96,6 +96,9 @@ func buildConfig() (*install.Config, error) {
Disk: disk,
Hostname: hostname,
RootPassword: password,
// Optional on the headless path — empty means no --email passed to
// spx admin init, which omits the operator identity from telemetry.
Email: strings.TrimSpace(os.Getenv("SPINIFEX_EMAIL")),
WANInterface: wanIface,
WANDHCPMode: strings.ToLower(os.Getenv("SPINIFEX_WAN_MODE")) != "static",
}
+21 -2
View File
@@ -23,6 +23,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mulgadc/spinifex/cmd/installer/systemd"
)
@@ -38,6 +39,10 @@ type Config struct {
ClusterRole string
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
JoinAddr string
// Email is the operator email collected by the TUI or SPINIFEX_EMAIL on
// the headless path. Passed to `spx admin init --email=<value>` when set;
// omitted entirely when empty.
Email string
}
// Write drops the firstboot script and systemd unit into root, which should be
@@ -179,10 +184,24 @@ systemctl start spinifex.target
}
func buildClusterCmd(cfg Config) string {
emailFlag := ""
if cfg.Email != "" {
// shellEscapeSingle keeps the email safe if it ever contains a
// character shell treats specially — belt-and-braces; the regex
// validator already rejects whitespace and @-chains.
emailFlag = " --email=" + shellEscapeSingle(cfg.Email)
}
switch cfg.ClusterRole {
case "join":
return fmt.Sprintf("spx admin join --node %s --host %s", cfg.Hostname, cfg.JoinAddr)
return fmt.Sprintf("spx admin join --node %s --host %s%s", cfg.Hostname, cfg.JoinAddr, emailFlag)
default:
return fmt.Sprintf("spx admin init --node %s --nodes 1", cfg.Hostname)
return fmt.Sprintf("spx admin init --node %s --nodes 1%s", cfg.Hostname, emailFlag)
}
}
// shellEscapeSingle wraps s in single quotes with any embedded single
// quotes escaped. Minimal — we only need this because the email value is
// interpolated into a shell script written by Write().
func shellEscapeSingle(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+6
View File
@@ -61,4 +61,10 @@ type Config struct {
// RootPassword is the password to set for the root account on the installed system.
RootPassword string
// Email is the operator's email address, used by the call-home telemetry
// endpoint to notify of important system updates and security advisories.
// Required on interactive installs; may be empty on headless/CI installs
// when SPINIFEX_EMAIL is not supplied on the kernel cmdline.
Email string
}
+1
View File
@@ -568,6 +568,7 @@ func (c *Config) toFirstbootConfig() firstboot.Config {
EncapIP: encapIP,
ClusterRole: c.ClusterRole,
JoinAddr: c.JoinAddr,
Email: c.Email,
}
}
+80 -36
View File
@@ -31,6 +31,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/mulgadc/spinifex/cmd/installer/branding"
"github.com/mulgadc/spinifex/cmd/installer/install"
"github.com/mulgadc/spinifex/spinifex/admin"
)
// screen represents which step of the wizard is active.
@@ -126,10 +127,11 @@ type model struct {
joinIPInput textinput.Model
joinPortInput textinput.Model
// Password
// Credentials (email + password)
emailInput textinput.Model
passwordInput textinput.Model
passwordConfirmInput textinput.Model
passwordFocus int // 0 = password, 1 = confirm
credsFocus int // 0 = email, 1 = password, 2 = confirm
// Accumulated validation error shown on current screen
validationErr string
@@ -249,6 +251,11 @@ func newModel(disks []diskInfo, nics []nicInfo) model {
joinPortIn.Placeholder = "4432"
joinPortIn.CharLimit = 5
emailIn := textinput.New()
emailIn.Placeholder = "admin@mydomain.com"
emailIn.CharLimit = 254 // RFC 5321 upper bound
emailIn.Width = 40
passIn := textinput.New()
passIn.Placeholder = "Admin password"
passIn.EchoMode = textinput.EchoPassword
@@ -286,6 +293,7 @@ func newModel(disks []diskInfo, nics []nicInfo) model {
lanSSID: lanSSIDIn,
lanWiFiPass: lanWiFiPassIn,
hostnameInput: hostnameIn,
emailInput: emailIn,
passwordInput: passIn,
passwordConfirmInput: passConfirmIn,
joinIPInput: joinIPIn,
@@ -459,8 +467,8 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.screen = screenPassword
m.passwordInput.Focus()
m.passwordFocus = 0
m.emailInput.Focus()
m.credsFocus = 0
default:
if m.hostnameInput.Focused() {
var cmd tea.Cmd
@@ -472,36 +480,37 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case screenPassword:
switch key {
case "tab", "down":
m.passwordInput.Blur()
m.passwordConfirmInput.Blur()
if m.passwordFocus == 0 {
m.passwordConfirmInput.Focus()
m.passwordFocus = 1
} else {
m.passwordInput.Focus()
m.passwordFocus = 0
}
m = m.setCredsFocus((m.credsFocus + 1) % 3)
case "shift+tab", "up":
m.passwordInput.Blur()
m.passwordConfirmInput.Blur()
if m.passwordFocus == 1 {
m.passwordInput.Focus()
m.passwordFocus = 0
} else {
m.passwordConfirmInput.Focus()
m.passwordFocus = 1
}
m = m.setCredsFocus((m.credsFocus + 2) % 3)
case "enter":
if m.passwordFocus == 0 {
m.passwordInput.Blur()
m.passwordConfirmInput.Focus()
m.passwordFocus = 1
// On email field: validate, then advance to password.
if m.credsFocus == 0 {
if err := admin.ValidateEmail(m.emailInput.Value()); err != nil {
m.validationErr = err.Error()
return m, nil
}
m.validationErr = ""
m = m.setCredsFocus(1)
return m, nil
}
// On password field: just advance (defer validation to confirm).
if m.credsFocus == 1 {
m = m.setCredsFocus(2)
return m, nil
}
// On confirm: validate email (again, in case user tabbed past),
// then validate password + match.
if err := admin.ValidateEmail(m.emailInput.Value()); err != nil {
m.validationErr = err.Error()
m = m.setCredsFocus(0)
return m, nil
}
pw := m.passwordInput.Value()
confirm := m.passwordConfirmInput.Value()
if pw == "" {
m.validationErr = "Password is required"
m = m.setCredsFocus(1)
return m, nil
}
if pw != confirm {
@@ -516,15 +525,19 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.screen = screenConfirm
}
case "esc":
m.emailInput.Blur()
m.passwordInput.Blur()
m.passwordConfirmInput.Blur()
m.screen = screenIdentity
m.hostnameInput.Focus()
default:
var cmd tea.Cmd
if m.passwordFocus == 0 {
switch m.credsFocus {
case 0:
m.emailInput, cmd = m.emailInput.Update(msg)
case 1:
m.passwordInput, cmd = m.passwordInput.Update(msg)
} else {
default:
m.passwordConfirmInput, cmd = m.passwordConfirmInput.Update(msg)
}
return m, cmd
@@ -556,8 +569,7 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.joinIPInput.Blur()
m.joinPortInput.Blur()
m.screen = screenPassword
m.passwordConfirmInput.Focus()
m.passwordFocus = 1
m = m.setCredsFocus(2)
default:
var cmd tea.Cmd
if m.joinIPInput.Focused() {
@@ -584,8 +596,7 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.joinPortInput.Blur()
} else {
m.screen = screenPassword
m.passwordInput.Focus()
m.passwordFocus = 0
m = m.setCredsFocus(0)
}
}
}
@@ -593,6 +604,28 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// setCredsFocus moves focus among the three credential inputs (email,
// password, confirm) and ensures exactly one is focused. Returns the
// updated model — callers must reassign (m = m.setCredsFocus(...)).
// Value receiver keeps model's method set consistent with the other
// bubbletea View/buildConfig methods (avoids golangci-lint recvcheck).
func (m model) setCredsFocus(i int) model {
m.emailInput.Blur()
m.passwordInput.Blur()
m.passwordConfirmInput.Blur()
switch i {
case 0:
m.emailInput.Focus()
case 1:
m.passwordInput.Focus()
default:
m.passwordConfirmInput.Focus()
i = 2
}
m.credsFocus = i
return m
}
// ── WAN screen key handling ───────────────────────────────────────────────────
func (m model) handleWANKey(key string, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -1065,9 +1098,12 @@ func (m model) updateActiveInput(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case screenPassword:
var cmd tea.Cmd
if m.passwordFocus == 0 {
switch m.credsFocus {
case 0:
m.emailInput, cmd = m.emailInput.Update(msg)
case 1:
m.passwordInput, cmd = m.passwordInput.Update(msg)
} else {
default:
m.passwordConfirmInput, cmd = m.passwordConfirmInput.Update(msg)
}
return m, cmd
@@ -1364,12 +1400,19 @@ func (m model) viewIdentity(w int) string {
}
func (m model) viewPassword(w int) string {
title := styleTitle.Render("Root Password")
title := styleTitle.Render("Administrator Credentials")
emailLabel := styleLabel.Render("Email")
emailHelp := styleHelp.Render("Used to notify of important system updates to Spinifex or security announcements")
passLabel := styleLabel.Render("Password")
confirmLabel := styleLabel.Render("Confirm password")
var lines []string
lines = append(lines, title, "", passLabel, m.passwordInput.View(), "", confirmLabel, m.passwordConfirmInput.View())
lines = append(lines,
title, "",
emailLabel, m.emailInput.View(), emailHelp, "",
passLabel, m.passwordInput.View(), "",
confirmLabel, m.passwordConfirmInput.View(),
)
if m.validationErr != "" {
lines = append(lines, "", styleError.Render(m.validationErr))
}
@@ -1519,6 +1562,7 @@ func (m model) buildConfig() *install.Config {
cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(m.joinIPInput.Value()), port)
}
cfg.RootPassword = m.passwordInput.Value()
cfg.Email = strings.TrimSpace(m.emailInput.Value())
return cfg
}
+42 -2
View File
@@ -261,6 +261,7 @@ func init() {
adminInitCmd.Flags().String("token-ttl", "30m", "Join token validity duration (e.g. 30m, 1h, 2h)")
adminInitCmd.Flags().String("cluster-name", "spinifex", "NATS cluster name")
adminInitCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during init (default: enabled)")
adminInitCmd.Flags().String("email", "", "Operator email address (used for update and security notifications)")
adminInitCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all). Valid: nats,predastore,viperblock,daemon,awsgw,ui")
// External networking flags
@@ -286,6 +287,7 @@ func init() {
adminJoinCmd.Flags().String("token", "", "Join token from the init node (required)")
adminJoinCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all)")
adminJoinCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during join (default: enabled)")
adminJoinCmd.Flags().String("email", "", "Operator email address (used for update and security notifications)")
adminJoinCmd.MarkFlagRequired("node")
adminJoinCmd.MarkFlagRequired("host")
adminJoinCmd.MarkFlagRequired("token")
@@ -633,6 +635,19 @@ func runAdminInit(cmd *cobra.Command, args []string) {
clusterName, _ := cmd.Flags().GetString("cluster-name")
services, _ := cmd.Flags().GetStringSlice("services")
// Optional operator email — validated up-front so a bad address fails
// before we touch any config state. Empty is allowed here; reset / repeat
// inits on a box that already has an email in /etc/spinifex/spinifex.toml
// should preserve it (see config-preservation below).
email, _ := cmd.Flags().GetString("email")
email = strings.TrimSpace(email)
if email != "" {
if err := admin.ValidateEmail(email); err != nil {
fmt.Fprintf(os.Stderr, "--email: %v\n", err)
os.Exit(1)
}
}
// External networking flags
externalMode, _ := cmd.Flags().GetString("external-mode")
externalIface, _ := cmd.Flags().GetString("external-iface")
@@ -664,6 +679,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
BindIP: bindIP,
Version: Version,
ExternalMode: externalMode,
Email: email,
})
})
}
@@ -821,6 +837,13 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(0)
}
// Preserve the previously-captured operator email across --force re-inits
// when --email is omitted (e.g. reset-dev-env.sh workflows). Without this
// a reset would silently blank the address.
if email == "" && admin.FileExists(spinifexTomlPath) {
email = admin.ReadOperatorEmail(spinifexTomlPath)
}
// Create config directory
if err := os.MkdirAll(configDir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
@@ -918,7 +941,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, natsToken, clusterName,
configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind,
configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind, email,
port, nodes, formationTimeoutStr, tokenTTLStr, services, networkConfig)
return
}
@@ -1024,6 +1047,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
PoolPrefixLen: externalPrefixLen,
PoolDNSServers: dnsServers,
OperatorEmail: email,
BootstrapAccountId: admin.DefaultAccountID(),
BootstrapVpcId: bootstrapVpcId,
BootstrapSubnetId: bootstrapSubnetId,
@@ -1069,7 +1093,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// It starts a formation server, registers this node, waits for all nodes to join,
// then generates configs with complete cluster topology.
func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, natsToken, clusterName,
configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind string,
configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind, email string,
port, expectedNodes int, formationTimeoutStr, tokenTTLStr string, services []string, networkConfig *formation.NetworkConfig) {
formationTimeout, err := time.ParseDuration(formationTimeoutStr)
if err != nil {
@@ -1239,6 +1263,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
Services: services,
RemoteNodes: buildRemoteNodes(allNodes, node),
OperatorEmail: email,
// Init node runs ovn-central locally
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
@@ -1298,6 +1324,17 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
clusterBind, _ := cmd.Flags().GetString("cluster-bind")
services, _ := cmd.Flags().GetStringSlice("services")
email, _ := cmd.Flags().GetString("email")
email = strings.TrimSpace(email)
if email != "" {
if err := admin.ValidateEmail(email); err != nil {
fmt.Fprintf(os.Stderr, "--email: %v\n", err)
os.Exit(1)
}
} else if admin.FileExists(filepath.Join(configDir, "spinifex.toml")) {
email = admin.ReadOperatorEmail(filepath.Join(configDir, "spinifex.toml"))
}
// Validate required parameters
if node == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --node is required\n")
@@ -1475,6 +1512,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
Nodes: statusResp.Expected,
BindIP: bindIP,
Version: Version,
Email: email,
})
})
}
@@ -1610,6 +1648,8 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
Services: services,
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node),
OperatorEmail: email,
// Joining nodes connect to the init node's OVN NB/SB DB
OVNNBAddr: fmt.Sprintf("tcp:%s:6641", leaderIP),
OVNSBAddr: fmt.Sprintf("tcp:%s:6642", leaderIP),
+5
View File
@@ -2,6 +2,11 @@
version = "2"
epoch = 1
node = "{{.Node}}"
{{- if .OperatorEmail}}
[operator]
email = "{{.OperatorEmail}}"
{{- end}}
{{- if .ExternalMode}}
[network]
@@ -95,6 +95,14 @@ Next install the load balancer AMI images, which is used as the AMI disk image f
spx admin images import --name lb-alpine-3.21.6-x86_64
```
### Step 2.1 Install Debian AMI
Next, install the Debian 12 AMI which is used in the example to host the `nginx` webservers as an EC2 instance.
```bash
spx admin images import --name debian-12-x86_64
```
### Step 3. Deploy
```bash
+14 -1
View File
@@ -57,6 +57,7 @@ POOL_START=""
POOL_END=""
EXT_GATEWAY=""
EXT_PREFIX=""
OPERATOR_EMAIL=""
if sudo test -f "$CONFIG_FILE"; then
# Copy config to a temp file we can read without sudo for cleaner parsing.
@@ -87,8 +88,13 @@ if sudo test -f "$CONFIG_FILE"; then
POOL_END=$(toml_scalar '^\[\[network\.external_pools\]\]' 'range_end')
EXT_GATEWAY=$(toml_scalar '^\[\[network\.external_pools\]\]' 'gateway')
EXT_PREFIX=$(toml_scalar '^\[\[network\.external_pools\]\]' 'prefix_len')
OPERATOR_EMAIL=$(toml_scalar '^\[operator\]' 'email')
fi
# Allow operator to override on the command line (e.g. first reset on a box
# installed before --email existed): SPINIFEX_EMAIL=me@example.com ./reset-dev-env.sh
OPERATOR_EMAIL="${SPINIFEX_EMAIL:-$OPERATOR_EMAIL}"
# Defaults
REGION="${REGION:-ap-southeast-2}"
AZ="${AZ:-${REGION}a}"
@@ -98,6 +104,9 @@ echo "Preserving: region=$REGION az=$AZ external_mode=$EXT_MODE"
if [ "$EXT_MODE" = "pool" ] && [ -n "$POOL_START" ]; then
echo " pool: $POOL_START - $POOL_END gw=$EXT_GATEWAY prefix=$EXT_PREFIX"
fi
if [ -n "$OPERATOR_EMAIL" ]; then
echo " operator email: $OPERATOR_EMAIL"
fi
# --- Shutdown services ---
echo "==> Stopping services"
@@ -220,10 +229,14 @@ esac
# --- Initialize platform ---
echo "==> Initializing platform (region=$REGION az=$AZ)"
EMAIL_INIT_ARG=""
if [ -n "$OPERATOR_EMAIL" ]; then
EMAIL_INIT_ARG="--email=$OPERATOR_EMAIL"
fi
# shellcheck disable=SC2086 # intentional word-splitting for arg list
sudo /usr/local/bin/spx admin init --force \
--region "$REGION" --az "$AZ" --node node1 --nodes 1 \
$EXTERNAL_INIT_ARGS
$EXTERNAL_INIT_ARGS $EMAIL_INIT_ARG
# --- Install CA cert into system trust store ---
echo "==> Installing CA certificate"
+6
View File
@@ -79,6 +79,12 @@ type ConfigSettings struct {
PoolPrefixLen int // Subnet prefix length (default 24)
PoolDNSServers []string // DNS servers for VM DHCP (auto-detected from host)
// OperatorEmail is the address collected at install time (TUI, SPINIFEX_EMAIL,
// or `spx admin init --email`). Written under [operator] in spinifex.toml so
// reset-dev-env.sh can preserve it across wipes. Empty means no operator
// identity was supplied.
OperatorEmail string
// Other nodes in the cluster (for config source of truth)
RemoteNodes []RemoteNode
+60
View File
@@ -0,0 +1,60 @@
package admin
import (
"bufio"
"fmt"
"os"
"regexp"
"strings"
)
// Deliberately loose — we want to catch obvious typos (missing @, missing
// dot after @, whitespace) without pretending to validate RFC 5321.
// Identical check applied in the installer TUI and in `spx admin init --email`.
var emailRE = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
// ValidateEmail returns nil if addr is a plausible email address, or an
// error describing the failure. Empty strings fail.
func ValidateEmail(addr string) error {
addr = strings.TrimSpace(addr)
if addr == "" {
return fmt.Errorf("email is required")
}
if !emailRE.MatchString(addr) {
return fmt.Errorf("%q is not a valid email address", addr)
}
return nil
}
// ReadOperatorEmail extracts the [operator].email scalar from a spinifex.toml
// file, or returns "" if the file doesn't exist, can't be read, or has no
// [operator] section. Used by `spx admin init --force` (and reset flows) to
// preserve a previously-captured email across re-inits when --email isn't
// supplied on the new invocation. Minimal text scan — no TOML parser, since
// we only need one scalar from one section.
func ReadOperatorEmail(tomlPath string) string {
f, err := os.Open(tomlPath)
if err != nil {
return ""
}
defer f.Close()
keyRE := regexp.MustCompile(`^\s*email\s*=\s*"([^"]*)"`)
sc := bufio.NewScanner(f)
inOperator := false
for sc.Scan() {
line := sc.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") {
inOperator = trimmed == "[operator]"
continue
}
if !inOperator {
continue
}
if m := keyRE.FindStringSubmatch(line); m != nil {
return m[1]
}
}
return ""
}
+120
View File
@@ -0,0 +1,120 @@
package admin
import (
"os"
"path/filepath"
"testing"
)
func TestValidateEmail(t *testing.T) {
cases := []struct {
in string
wantErr bool
}{
{"admin@example.com", false},
{"foo.bar+tag@sub.example.co.uk", false},
{" ok@example.io ", false}, // trimmed
{"", true},
{" ", true},
{"nodomain", true},
{"missing@tld", true}, // no dot after @
{"@example.com", true}, // empty local part
{"user@.com", true}, // dot immediately after @
{"user@@example.com", true}, // double @
{"user @example.com", true}, // whitespace in local
{"user@exa mple.com", true}, // whitespace in domain
}
for _, c := range cases {
err := ValidateEmail(c.in)
if c.wantErr && err == nil {
t.Errorf("ValidateEmail(%q) = nil, want error", c.in)
}
if !c.wantErr && err != nil {
t.Errorf("ValidateEmail(%q) = %v, want nil", c.in, err)
}
}
}
func TestReadOperatorEmail(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return p
}
t.Run("nonexistent file", func(t *testing.T) {
if got := ReadOperatorEmail(filepath.Join(dir, "missing.toml")); got != "" {
t.Errorf("want empty string for missing file, got %q", got)
}
})
t.Run("no operator section", func(t *testing.T) {
p := write("none.toml", `version = "2"
[network]
external_mode = "pool"
`)
if got := ReadOperatorEmail(p); got != "" {
t.Errorf("want empty string, got %q", got)
}
})
t.Run("operator section with email", func(t *testing.T) {
p := write("ok.toml", `version = "2"
[operator]
email = "admin@example.com"
[network]
external_mode = "pool"
`)
if got := ReadOperatorEmail(p); got != "admin@example.com" {
t.Errorf("want admin@example.com, got %q", got)
}
})
t.Run("email scalar in wrong section ignored", func(t *testing.T) {
// A stray "email = ..." outside [operator] must not be returned.
p := write("stray.toml", `version = "2"
email = "wrong@example.com"
[some_other]
email = "alsowrong@example.com"
`)
if got := ReadOperatorEmail(p); got != "" {
t.Errorf("want empty (stray scalars ignored), got %q", got)
}
})
t.Run("operator section trailing", func(t *testing.T) {
// Section that appears last in the file.
p := write("last.toml", `version = "2"
[network]
external_mode = "pool"
[operator]
email = "late@example.com"
`)
if got := ReadOperatorEmail(p); got != "late@example.com" {
t.Errorf("want late@example.com, got %q", got)
}
})
t.Run("operator section first, other section after", func(t *testing.T) {
// Ensures the scanner correctly re-enters "not in operator" state on
// the next section header.
p := write("mixed.toml", `[operator]
email = "first@example.com"
[nodes.node1]
region = "ap-southeast-2"
`)
if got := ReadOperatorEmail(p); got != "first@example.com" {
t.Errorf("want first@example.com, got %q", got)
}
})
}
+1 -1
View File
@@ -55,7 +55,7 @@ func DetectNetwork() (*DetectedNetwork, error) {
}
// Get all routes to find interfaces and their subnets
allRoutes, err := exec.Command("ip", "-4", "route", "show", "proto", "kernel", "scope", "link").Output()
allRoutes, err := exec.Command("ip", "-4", "route", "show", "scope", "link").Output()
if err != nil {
return nil, fmt.Errorf("failed to get routes: %w", err)
}
+5 -1
View File
@@ -30,7 +30,11 @@ type TelemetryPayload struct {
Arch string `json:"arch"`
OS string `json:"os"`
ExternalMode string `json:"external_mode,omitempty"`
Timestamp string `json:"timestamp"`
// Email is the operator's address from `spx admin init --email`. Used
// by install.mulgadc.com to notify of updates/security advisories.
// Empty for headless installs that didn't supply SPINIFEX_EMAIL.
Email string `json:"email,omitempty"`
Timestamp string `json:"timestamp"`
// URL overrides the telemetry endpoint (for testing only). Not serialized.
URL string `json:"-"`