diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index 00f18d917..6e6d7418e 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -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 { diff --git a/spinifex/admin/telemetry.go b/spinifex/admin/telemetry.go new file mode 100644 index 000000000..62d1a0b7e --- /dev/null +++ b/spinifex/admin/telemetry.go @@ -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 "" +} diff --git a/spinifex/admin/telemetry_test.go b/spinifex/admin/telemetry_test.go new file mode 100644 index 000000000..c0e045136 --- /dev/null +++ b/spinifex/admin/telemetry_test.go @@ -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") +}