mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
ISO tweaks, email prompt on installer, improved network detection
This commit is contained in:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -568,6 +568,7 @@ func (c *Config) toFirstbootConfig() firstboot.Config {
|
||||
EncapIP: encapIP,
|
||||
ClusterRole: c.ClusterRole,
|
||||
JoinAddr: c.JoinAddr,
|
||||
Email: c.Email,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+80
-36
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
Reference in New Issue
Block a user