Skip to content

Commit 82bfbbf

Browse files
committed
vibe: this is an implementation spike of the planned items for review
1 parent e8e0543 commit 82bfbbf

15 files changed

Lines changed: 519 additions & 71 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,15 @@ export PSSTD_GOSSIP=":7947" # peer sync listen address, de
4848
export PSSTD_SEEDS="10.0.1.20:7946,10.0.1.21:7946" # explicit peer sync addresses
4949
export PSSTD_DB="./data" # local state directory
5050
export PSSTD_WEB="true" # set false for sync-only nodes
51+
export PSSTD_NODE_NAME="rack-a-01" # optional stable node identity override
5152
./psstd
5253
```
5354

55+
By default, psstd uses the OS hostname as the node identity. Set
56+
`PSSTD_NODE_NAME` for cloned hosts, containers, or multiple test instances that
57+
would otherwise publish the same hostname. The override must be non-empty and
58+
must not contain whitespace.
59+
5460
## Discovery
5561

5662
| Environment | How nodes find each other |

discovery.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ func discoverPeers() []string {
6363
continue
6464
}
6565
peer := fmt.Sprintf("%s:%d", addr.String(), entry.Port)
66-
log.Printf("mDNS: discovered peer %s (%s)", entry.Name, peer)
6766
peers = append(peers, peer)
6867
}
6968
return peers

gossip.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"log"
77
"sync"
8-
"time"
98

109
"github.com/cockroachdb/pebble/v2"
1110
"github.com/hashicorp/memberlist"
@@ -14,14 +13,15 @@ import (
1413
var appVersion = "dev"
1514

1615
type NodeStats struct {
17-
Name string `json:"name"`
18-
Version string `json:"version,omitempty"`
19-
WebURL string `json:"web,omitempty"`
20-
CPU []float64 `json:"cpu"`
21-
MemUsed uint64 `json:"mu"`
22-
MemTotal uint64 `json:"mt"`
23-
Load [3]float64 `json:"ld"`
24-
UpdatedAt int64 `json:"ts"` // unix nano, LWW key
16+
Name string `json:"name"`
17+
Version string `json:"version,omitempty"`
18+
WebURL string `json:"web,omitempty"`
19+
TTLSeconds int `json:"ttl,omitempty"`
20+
CPU []float64 `json:"cpu"`
21+
MemUsed uint64 `json:"mu"`
22+
MemTotal uint64 `json:"mt"`
23+
Load [3]float64 `json:"ld"`
24+
UpdatedAt int64 `json:"ts"` // unix nano, LWW key
2525
}
2626

2727
var errStaleVersion = errors.New("stale version")
@@ -107,7 +107,7 @@ func purgeOfflineDifferentVersion(db *pebble.DB, version string) error {
107107
}
108108

109109
func nodeRecordOffline(s NodeStats) bool {
110-
return s.UpdatedAt == 0 || time.Since(time.Unix(0, s.UpdatedAt)) > 15*time.Second
110+
return nodeHealth(s).State == healthOffline
111111
}
112112

113113
// ── Delegate ──────────────────────────────────────────────────────────────────

main.go

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,23 @@ import (
1616
)
1717

1818
const (
19-
envDB = "PSSTD_DB"
20-
envHTTP = "PSSTD_HTTP"
21-
envGossip = "PSSTD_GOSSIP"
22-
envSeeds = "PSSTD_SEEDS"
23-
envHTTPAd = "PSSTD_ADVERTISE_HTTP"
24-
envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true
25-
gossipPort = 7946
26-
httpPort = 8080
19+
envDB = "PSSTD_DB"
20+
envHTTP = "PSSTD_HTTP"
21+
envGossip = "PSSTD_GOSSIP"
22+
envSeeds = "PSSTD_SEEDS"
23+
envHTTPAd = "PSSTD_ADVERTISE_HTTP"
24+
envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true
25+
envNodeName = "PSSTD_NODE_NAME"
26+
gossipPort = 7946
27+
httpPort = 8080
2728
)
2829

2930
func main() {
3031
hostname, _ := os.Hostname()
32+
nodeName, err := nodeNameFromEnv(hostname)
33+
if err != nil {
34+
log.Fatalf("node name: %v", err)
35+
}
3136

3237
dbPath := envOr(envDB, "./data")
3338
httpAddr := envOr(envHTTP, fmt.Sprintf(":%d", httpPort))
@@ -47,7 +52,7 @@ func main() {
4752
if err != nil {
4853
if pebbleLockHeld(err) {
4954
log.Printf("psstd already appears to own %s; starting terminal mirror instead", dbPath)
50-
runTerminalMirror(hostname, gossipAddr, seeds)
55+
runTerminalMirror(nodeName, gossipAddr, seeds)
5156
return
5257
}
5358
log.Fatalf("pebble open: %v", err)
@@ -65,7 +70,7 @@ func main() {
6570
delegate := newKVDelegate(db, appVersion)
6671

6772
cfg := memberlist.DefaultLANConfig()
68-
cfg.Name = hostname
73+
cfg.Name = nodeName
6974
cfg.BindAddr, cfg.BindPort = splitHostPort(gossipAddr)
7075
cfg.Delegate = delegate
7176
cfg.Events = newEventDelegate(db, appVersion)
@@ -79,7 +84,7 @@ func main() {
7984
}
8085
db = nil
8186
log.Printf("psstd already appears to be listening on %s; starting terminal mirror instead", gossipAddr)
82-
runTerminalMirror(hostname, gossipAddr, seeds)
87+
runTerminalMirror(nodeName, gossipAddr, seeds)
8388
return
8489
}
8590
log.Fatalf("memberlist create: %v", err)
@@ -88,12 +93,23 @@ func main() {
8893

8994
// ── Discovery ────────────────────────────────────────────────────────────
9095
// 1. Register ourselves via mDNS so peers can find us on LAN
91-
stopMDNS := registerMDNS(hostname, cfg.BindPort)
96+
stopMDNS := registerMDNS(nodeName, cfg.BindPort)
9297
defer stopMDNS()
9398

9499
// 2. Scan for existing peers (mDNS + any explicit seeds)
95100
discovered := discoverPeers()
96101
allSeeds := append(seeds, discovered...)
102+
logStartupConfig(startupConfig{
103+
NodeName: nodeName,
104+
DBPath: dbPath,
105+
HTTPAddr: httpAddr,
106+
WebURL: webURL,
107+
GossipAddr: gossipAddr,
108+
WebEnabled: webEnabled,
109+
Version: appVersion,
110+
SeedCount: len(seeds),
111+
MDNSCount: len(discovered),
112+
})
97113
if len(allSeeds) > 0 {
98114
if n, err := list.Join(allSeeds); err != nil {
99115
log.Printf("join warning (joined %d): %v", n, err)
@@ -105,22 +121,37 @@ func main() {
105121
}
106122

107123
// ── Stats heartbeat ─────────────────────────────────────────────────────
108-
go statsLoop(hostname, webURL, appVersion, db, delegate)
124+
go statsLoop(nodeName, webURL, appVersion, db, delegate)
109125

110126
// ── HTTP ─────────────────────────────────────────────────────────────────
111127
if webEnabled {
112128
mux := http.NewServeMux()
113-
mux.HandleFunc("/", makeHandler(db, hostname))
114-
log.Printf("psstd version=%s node=%s http=%s advertise=%s gossip=%s web=true", appVersion, hostname, httpAddr, webURL, gossipAddr)
129+
mux.HandleFunc("/", makeHandler(db, nodeName))
115130
if err := http.ListenAndServe(httpAddr, mux); err != nil {
116131
log.Fatalf("http: %v", err)
117132
}
118133
} else {
119-
log.Printf("psstd version=%s node=%s gossip=%s web=false", appVersion, hostname, gossipAddr)
120134
select {} // block forever
121135
}
122136
}
123137

138+
type startupConfig struct {
139+
NodeName string
140+
DBPath string
141+
HTTPAddr string
142+
WebURL string
143+
GossipAddr string
144+
WebEnabled bool
145+
Version string
146+
SeedCount int
147+
MDNSCount int
148+
}
149+
150+
func logStartupConfig(cfg startupConfig) {
151+
log.Printf("psstd startup: version=%s node=%s db=%s web=%t http=%s advertise=%s gossip=%s seeds=%d mdns=%d",
152+
cfg.Version, cfg.NodeName, cfg.DBPath, cfg.WebEnabled, cfg.HTTPAddr, cfg.WebURL, cfg.GossipAddr, cfg.SeedCount, cfg.MDNSCount)
153+
}
154+
124155
func pebbleLockHeld(err error) bool {
125156
msg := strings.ToLower(err.Error())
126157
return strings.Contains(msg, "lock") &&
@@ -255,6 +286,23 @@ func envOr(key, def string) string {
255286
return def
256287
}
257288

289+
func nodeNameFromEnv(hostname string) (string, error) {
290+
override, ok := os.LookupEnv(envNodeName)
291+
if !ok || override == "" {
292+
if strings.TrimSpace(hostname) == "" {
293+
return "", fmt.Errorf("hostname is empty; set %s", envNodeName)
294+
}
295+
return hostname, nil
296+
}
297+
if strings.TrimSpace(override) != override || override == "" {
298+
return "", fmt.Errorf("%s must not be empty or have leading/trailing whitespace", envNodeName)
299+
}
300+
if strings.ContainsAny(override, " \t\r\n") {
301+
return "", fmt.Errorf("%s must not contain whitespace", envNodeName)
302+
}
303+
return override, nil
304+
}
305+
258306
func splitCSV(s string) []string {
259307
var out []string
260308
for _, p := range strings.Split(s, ",") {

main_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func withoutNodeNameEnv(t *testing.T) {
9+
t.Helper()
10+
old, hadOld := os.LookupEnv(envNodeName)
11+
if err := os.Unsetenv(envNodeName); err != nil {
12+
t.Fatalf("unset %s: %v", envNodeName, err)
13+
}
14+
t.Cleanup(func() {
15+
if hadOld {
16+
_ = os.Setenv(envNodeName, old)
17+
} else {
18+
_ = os.Unsetenv(envNodeName)
19+
}
20+
})
21+
}
22+
23+
func TestNodeNameFromEnvUsesHostnameByDefault(t *testing.T) {
24+
withoutNodeNameEnv(t)
25+
26+
got, err := nodeNameFromEnv("host-a")
27+
if err != nil {
28+
t.Fatalf("nodeNameFromEnv: %v", err)
29+
}
30+
if got != "host-a" {
31+
t.Fatalf("name = %q, want host-a", got)
32+
}
33+
}
34+
35+
func TestNodeNameFromEnvUsesOverride(t *testing.T) {
36+
t.Setenv(envNodeName, "test-node")
37+
38+
got, err := nodeNameFromEnv("host-a")
39+
if err != nil {
40+
t.Fatalf("nodeNameFromEnv: %v", err)
41+
}
42+
if got != "test-node" {
43+
t.Fatalf("name = %q, want test-node", got)
44+
}
45+
}
46+
47+
func TestNodeNameFromEnvRejectsWhitespaceOverride(t *testing.T) {
48+
for _, value := range []string{" test-node", "test-node ", "test node", "\t"} {
49+
t.Run(value, func(t *testing.T) {
50+
t.Setenv(envNodeName, value)
51+
if got, err := nodeNameFromEnv("host-a"); err == nil {
52+
t.Fatalf("name = %q, want error", got)
53+
}
54+
})
55+
}
56+
}

0 commit comments

Comments
 (0)