mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 12:16:52 +00:00
feat: add install call-home telemetry to admin init and join
Sends anonymous telemetry to install.mulgadc.com/install during spx admin init and join. Reports machine_id (/etc/machine-id), region, az, node count, version, and arch. Fire-and-forget with 5-second timeout — never blocks the user. Opt out with --no-telemetry flag or SPX_NO_TELEMETRY=1 env var.
This commit is contained in:
@@ -32,6 +32,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
@@ -236,6 +237,7 @@ func init() {
|
||||
adminInitCmd.Flags().String("predastore-nodes", "", "Comma-separated IPs for multi-node Predastore cluster (e.g., 10.11.12.1,10.11.12.2,10.11.12.3). Requires >= 3 nodes.")
|
||||
adminInitCmd.Flags().String("formation-timeout", "10m", "Timeout for cluster formation (e.g., 5m, 30s)")
|
||||
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().StringSlice("services", nil, "Services this node runs (default: all). Valid: nats,predastore,viperblock,daemon,awsgw,ui")
|
||||
|
||||
// External networking flags
|
||||
@@ -259,6 +261,7 @@ func init() {
|
||||
adminJoinCmd.Flags().String("cluster-bind", "", "IP address to bind NATS cluster services to (e.g., 10.11.12.1 for multi-node)")
|
||||
adminJoinCmd.Flags().String("cluster-routes", "", "NATS cluster hosts for routing specify multiple with comma (e.g., 10.11.12.1:4248,10.11.12.2:4248 for multi-node)")
|
||||
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.MarkFlagRequired("node")
|
||||
adminJoinCmd.MarkFlagRequired("host")
|
||||
|
||||
@@ -577,6 +580,31 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
|
||||
noExternal, _ := cmd.Flags().GetBool("no-external")
|
||||
|
||||
// Fire telemetry in background (completes during init work, waited at end)
|
||||
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
|
||||
if os.Getenv("SPX_NO_TELEMETRY") == "1" {
|
||||
noTelemetry = true
|
||||
}
|
||||
var telemetryWg sync.WaitGroup
|
||||
defer telemetryWg.Wait()
|
||||
if !noTelemetry {
|
||||
telemetryWg.Go(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
admin.SendTelemetry(ctx, admin.TelemetryPayload{
|
||||
MachineID: admin.ReadMachineID(),
|
||||
Event: "init",
|
||||
Region: region,
|
||||
AZ: az,
|
||||
Node: node,
|
||||
Nodes: nodes,
|
||||
BindIP: bindIP,
|
||||
Version: Version,
|
||||
ExternalMode: externalMode,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-detect network topology
|
||||
var poolStart, poolEnd string
|
||||
var detectedNet *admin.DetectedNetwork
|
||||
@@ -1383,6 +1411,30 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
|
||||
fmt.Printf("✅ Cluster formation complete! (%d nodes)\n\n", statusResp.Expected)
|
||||
|
||||
// Fire telemetry after formation (now we know the cluster topology)
|
||||
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
|
||||
if os.Getenv("SPX_NO_TELEMETRY") == "1" {
|
||||
noTelemetry = true
|
||||
}
|
||||
var telemetryWg sync.WaitGroup
|
||||
defer telemetryWg.Wait()
|
||||
if !noTelemetry {
|
||||
telemetryWg.Go(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
admin.SendTelemetry(ctx, admin.TelemetryPayload{
|
||||
MachineID: admin.ReadMachineID(),
|
||||
Event: "join",
|
||||
Region: region,
|
||||
AZ: az,
|
||||
Node: node,
|
||||
Nodes: statusResp.Expected,
|
||||
BindIP: bindIP,
|
||||
Version: Version,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Extract credentials and CA from formation status
|
||||
creds := statusResp.Credentials
|
||||
if creds == nil {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultTelemetryURL = "https://install.mulgadc.com/install"
|
||||
|
||||
// TelemetryPayload is the JSON body sent to the telemetry endpoint.
|
||||
type TelemetryPayload struct {
|
||||
MachineID string `json:"machine_id"`
|
||||
Event string `json:"event"` // "init" or "join"
|
||||
Region string `json:"region"`
|
||||
AZ string `json:"az"`
|
||||
Node string `json:"node"`
|
||||
Nodes int `json:"nodes"`
|
||||
BindIP string `json:"bind_ip"`
|
||||
Version string `json:"version"`
|
||||
Arch string `json:"arch"`
|
||||
OS string `json:"os"`
|
||||
ExternalMode string `json:"external_mode,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
|
||||
// URL overrides the telemetry endpoint (for testing only). Not serialized.
|
||||
URL string `json:"-"`
|
||||
}
|
||||
|
||||
// SendTelemetry posts the payload to the telemetry endpoint.
|
||||
// It respects the context deadline and logs at Debug level only.
|
||||
func SendTelemetry(ctx context.Context, payload TelemetryPayload) {
|
||||
if payload.Arch == "" {
|
||||
payload.Arch = runtime.GOARCH
|
||||
}
|
||||
if payload.OS == "" {
|
||||
payload.OS = runtime.GOOS
|
||||
}
|
||||
if payload.Timestamp == "" {
|
||||
payload.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
url := payload.URL
|
||||
if url == "" {
|
||||
url = defaultTelemetryURL
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
slog.Debug("telemetry: failed to marshal payload", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
slog.Debug("telemetry: failed to create request", "error", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("telemetry: request failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
slog.Debug("telemetry: sent", "status", resp.StatusCode, "event", payload.Event)
|
||||
}
|
||||
|
||||
// ReadMachineID returns a stable anonymous identifier for this machine.
|
||||
// Reads /etc/machine-id (standard Linux UUID). Falls back to a SHA-256
|
||||
// hash of hostname + first non-loopback MAC address.
|
||||
func ReadMachineID() string {
|
||||
data, err := os.ReadFile("/etc/machine-id")
|
||||
if err == nil {
|
||||
id := strings.TrimSpace(string(data))
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hash hostname + MAC
|
||||
hostname, _ := os.Hostname()
|
||||
mac := firstMAC()
|
||||
hash := sha256.Sum256([]byte(hostname + mac))
|
||||
return fmt.Sprintf("%x", hash[:16])
|
||||
}
|
||||
|
||||
// firstMAC returns the MAC address of the first non-loopback, up interface.
|
||||
func firstMAC() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if mac := iface.HardwareAddr.String(); mac != "" {
|
||||
return mac
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadMachineID_ReturnsNonEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
id := ReadMachineID()
|
||||
assert.NotEmpty(t, id, "machine ID should never be empty")
|
||||
assert.Greater(t, len(id), 8, "machine ID should be a reasonable length")
|
||||
}
|
||||
|
||||
func TestSendTelemetry_PostsCorrectPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
var received TelemetryPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(body, &received))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
SendTelemetry(ctx, TelemetryPayload{
|
||||
MachineID: "test-machine-123",
|
||||
Event: "init",
|
||||
Region: "ap-southeast-2",
|
||||
AZ: "ap-southeast-2a",
|
||||
Node: "node1",
|
||||
Nodes: 3,
|
||||
BindIP: "10.11.12.1",
|
||||
Version: "v0.5.0",
|
||||
URL: server.URL,
|
||||
})
|
||||
|
||||
assert.Equal(t, "test-machine-123", received.MachineID)
|
||||
assert.Equal(t, "init", received.Event)
|
||||
assert.Equal(t, "ap-southeast-2", received.Region)
|
||||
assert.Equal(t, 3, received.Nodes)
|
||||
assert.Equal(t, "v0.5.0", received.Version)
|
||||
assert.NotEmpty(t, received.Arch, "arch should be auto-filled")
|
||||
assert.NotEmpty(t, received.OS, "os should be auto-filled")
|
||||
assert.NotEmpty(t, received.Timestamp, "timestamp should be auto-filled")
|
||||
}
|
||||
|
||||
func TestSendTelemetry_RespectsTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(10 * time.Second)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
SendTelemetry(ctx, TelemetryPayload{MachineID: "timeout-test", URL: server.URL})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.Less(t, elapsed, 2*time.Second, "should respect context timeout")
|
||||
}
|
||||
|
||||
func TestSendTelemetry_HandlesServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
SendTelemetry(ctx, TelemetryPayload{MachineID: "error-test", URL: server.URL})
|
||||
}
|
||||
|
||||
func TestSendTelemetry_HandlesUnreachableServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// URL pointing to a port that's not listening
|
||||
SendTelemetry(ctx, TelemetryPayload{MachineID: "unreachable-test", URL: "http://127.0.0.1:1"})
|
||||
}
|
||||
|
||||
func TestFirstMAC_ReturnsNonEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
mac := firstMAC()
|
||||
assert.NotEmpty(t, mac, "expected at least one MAC address")
|
||||
}
|
||||
Reference in New Issue
Block a user