Reapply "feat: match new predastore config surface area (#591)"

This reverts commit b4701c78de.
This commit is contained in:
Julian Sommer
2026-08-04 16:13:28 +10:00
parent ef8bdce9e9
commit 871513dc6c
32 changed files with 2530 additions and 289 deletions
+19 -17
View File
@@ -1524,7 +1524,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
northstarConfigPath := filepath.Join(dirs.Northstar, "northstar.toml")
// Parse multi-node predastore configuration (legacy flag-based approach for single-node)
var predastoreNodeID int
var predastoreHostID int
if predastoreNodesStr != "" {
ips := strings.Split(predastoreNodesStr, ",")
if len(ips) < 2 {
@@ -1545,14 +1545,16 @@ func runAdminInit(cmd *cobra.Command, args []string) {
})
}
predastoreNodeID = admin.FindNodeIDByIP(predastoreNodes, bindIP)
if predastoreNodeID == 0 {
// One machine is one predastore host, so the node list index that
// matches this bind IP is this node's host ID.
predastoreHostID = admin.FindNodeIDByIP(predastoreNodes, bindIP)
if predastoreHostID == 0 {
fmt.Fprintf(os.Stderr, "❌ Error: --bind IP %s not found in --predastore-nodes list\n", bindIP)
os.Exit(1)
}
// Generate multi-node predastore.toml
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP, compactionInterval, northstarCreds)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, spxRoot, bindIP, compactionInterval, northstarCreds)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -1563,7 +1565,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Fprintf(os.Stderr, "Error writing predastore config: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Created: multi-node predastore.toml (node ID: %d)\n", predastoreNodeID)
fmt.Printf("✅ Created: multi-node predastore.toml (host ID: %d)\n", predastoreHostID)
}
// Pre-generate default VPC/subnet/IGW IDs for bootstrap config.
@@ -1593,7 +1595,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
ClusterRoutes: clusterRoutes,
ClusterName: clusterName,
PredastoreNodeID: predastoreNodeID,
PredastoreHostID: predastoreHostID,
CompactionIntervalSeconds: compactionInterval,
Services: services,
@@ -1840,10 +1842,10 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
northstarConfigPath := filepath.Join(dirs.Northstar, "northstar.toml")
// Generate multi-node predastore config
var predastoreNodeID int
var predastoreHostID int
hasPredastoreConfig := len(predastoreNodes) >= 2
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP, compactionInterval, northstarCreds)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, spxRoot, bindIP, compactionInterval, northstarCreds)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -1855,8 +1857,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
os.Exit(1)
}
predastoreNodeID = admin.FindNodeIDByIP(predastoreNodes, bindIP)
fmt.Printf("✅ Created: multi-node predastore.toml (node ID: %d)\n", predastoreNodeID)
predastoreHostID = admin.FindNodeIDByIP(predastoreNodes, bindIP)
fmt.Printf("✅ Created: multi-node predastore.toml (host ID: %d)\n", predastoreHostID)
}
spinifexTomlPath := filepath.Join(configDir, "spinifex.toml")
@@ -1880,7 +1882,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
ClusterRoutes: clusterRoutes,
ClusterName: clusterName,
PredastoreNodeID: predastoreNodeID,
PredastoreHostID: predastoreHostID,
CompactionIntervalSeconds: compactionInterval,
Services: services,
RemoteNodes: buildRemoteNodes(allNodes, node, northstarConfigPath),
@@ -2390,11 +2392,11 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
northstarCreds, northstarConfigPath := northstarFromFormation(creds, dirs)
// Generate multi-node predastore config
var predastoreNodeID int
var predastoreHostID int
hasPredastoreConfig := len(predastoreNodes) >= 2
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, bindIP, compactionInterval,
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, dataDir, bindIP, compactionInterval,
northstarCreds)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
@@ -2407,12 +2409,12 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
os.Exit(1)
}
predastoreNodeID = admin.FindNodeIDByIP(predastoreNodes, bindIP)
if predastoreNodeID == 0 {
predastoreHostID = admin.FindNodeIDByIP(predastoreNodes, bindIP)
if predastoreHostID == 0 {
fmt.Fprintf(os.Stderr, "❌ Error: bind IP %s not found in predastore node list\n", bindIP)
os.Exit(1)
}
fmt.Printf("✅ Created: multi-node predastore.toml (node ID: %d)\n", predastoreNodeID)
fmt.Printf("✅ Created: multi-node predastore.toml (host ID: %d)\n", predastoreHostID)
}
spinifexTomlPath := filepath.Join(configDir, "spinifex.toml")
@@ -2436,7 +2438,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
ClusterRoutes: clusterRoutes,
ClusterName: creds.ClusterName,
PredastoreNodeID: predastoreNodeID,
PredastoreHostID: predastoreHostID,
CompactionIntervalSeconds: compactionInterval,
Services: services,
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node, northstarConfigPath),
+6 -6
View File
@@ -316,7 +316,7 @@ func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
{ID: 3, Host: "10.0.0.3"},
}
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0", 0, admin.NorthstarCredentials{},
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "/var/lib/spinifex", "0.0.0.0", 0, admin.NorthstarCredentials{},
)
require.NoError(t, err)
assert.Contains(t, content, `nats_url = "nats://localhost:4222"`)
@@ -324,7 +324,7 @@ func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
// Specific bind IP → stays as-is.
content2, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1", 0, admin.NorthstarCredentials{},
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "/var/lib/spinifex", "10.11.12.1", 0, admin.NorthstarCredentials{},
)
require.NoError(t, err)
assert.Contains(t, content2, `nats_url = "nats://10.11.12.1:4222"`)
@@ -813,7 +813,7 @@ func (c predastoreBucketAuth) hasBucket(name string) bool {
// them.
func TestPredastoreMultinodeTemplate_NorthstarProvisioned(t *testing.T) {
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", 0,
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "/var/lib/spinifex", "10.0.0.1", 0,
admin.NorthstarCredentials{AccessKey: "NSAK", SecretKey: "NSSK", Bucket: admin.NorthstarBucketName},
)
require.NoError(t, err)
@@ -857,7 +857,7 @@ func TestPredastoreMultinodeTemplate_NorthstarProvisioned(t *testing.T) {
// zone bucket.
func TestPredastoreMultinodeTemplate_NorthstarOmittedWithoutCredentials(t *testing.T) {
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", 0,
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "/var/lib/spinifex", "10.0.0.1", 0,
admin.NorthstarCredentials{},
)
require.NoError(t, err)
@@ -1067,7 +1067,7 @@ func TestPredastoreTemplates_UnsetCompactionEmitsNoBlock(t *testing.T) {
"unset single-node tail changed: %q", single[len(single)-40:])
multi, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", 0, admin.NorthstarCredentials{},
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "/var/lib/spinifex", "10.0.0.1", 0, admin.NorthstarCredentials{},
)
require.NoError(t, err)
assert.NotContains(t, multi, "[compaction]")
@@ -1088,7 +1088,7 @@ func TestPredastoreTemplates_SetCompactionEmitsParseableBlock(t *testing.T) {
assert.Equal(t, interval, singleCfg.Compaction.IntervalSeconds)
multi, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", interval, admin.NorthstarCredentials{},
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "/var/lib/spinifex", "10.0.0.1", interval, admin.NorthstarCredentials{},
)
require.NoError(t, err)
var multiCfg compactionConfig
+5
View File
@@ -7,6 +7,11 @@ import (
"strings"
"github.com/mulgadc/spinifex/spinifex/migrate"
// Config migrations that need more than the framework register themselves
// here rather than in migrate, which every handler imports.
_ "github.com/mulgadc/spinifex/spinifex/migrate/predastoretopology"
"github.com/spf13/cobra"
)
+39
View File
@@ -0,0 +1,39 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestUpgradeSeesEveryConfigTarget pins the registrations `spx admin upgrade`
// depends on. A migration whose package is no longer linked here would leave
// the command reporting nothing pending on an install that needs migrating.
func TestUpgradeSeesEveryConfigTarget(t *testing.T) {
configDir := t.TempDir()
// Both targets must exist on disk to be reported at all.
require.NoError(t, os.MkdirAll(filepath.Join(configDir, "predastore"), 0750))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"3\"\n"), 0640))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "predastore", "predastore.toml"), []byte("version = \"4\"\n"), 0640))
versions, err := migrate.DefaultRegistry.ConfigVersions(configDir)
require.NoError(t, err)
assert.Equal(t, map[string]int{"spinifex.toml": 3, "predastore.toml": 4}, versions)
pending, err := migrate.DefaultRegistry.PendingConfig(configDir)
require.NoError(t, err)
targets := make(map[string][2]int, len(pending))
for _, p := range pending {
targets[p.Target] = [2]int{p.FromVersion, p.ToVersion}
}
assert.Equal(t, map[string][2]int{
"spinifex.toml": {3, 4},
"predastore.toml": {4, 5},
}, targets)
}
+9 -11
View File
@@ -28,7 +28,7 @@ func writeSpinifexToml(t *testing.T, body string) string {
return path
}
func TestDerivePredastoreBind_NodeIDConfigured(t *testing.T) {
func TestDerivePredastoreBind_HostIDConfigured(t *testing.T) {
resetGlobalViper(t)
path := writeSpinifexToml(t, `
@@ -45,7 +45,7 @@ az = "us-east-1a"
host = "0.0.0.0:8443"
bucket = "predastore"
region = "us-east-1"
node_id = 3
host_id = 3
`)
clusterConfig, err := config.LoadConfig(path)
@@ -55,16 +55,14 @@ node_id = 3
require.NoError(t, err)
assert.Equal(t, "0.0.0.0", bind.Host)
assert.Equal(t, 8443, bind.Port)
assert.Equal(t, 3, bind.NodeID)
assert.Equal(t, 3, bind.HostID)
}
// TestDerivePredastoreBind_NodeIDAbsentDefaultsToColocated covers the
// co-located NODE_ID=-1 rule: single-node spinifex.toml templates omit
// node_id entirely, since every configured DB peer runs in this one process.
// predastore-start.sh defaulted to -1 for exactly that reason, and predastore
// itself rejects node_id=0, so an absent key must never silently resolve to
// the Go zero value instead.
func TestDerivePredastoreBind_NodeIDAbsentDefaultsToColocated(t *testing.T) {
// TestDerivePredastoreBind_HostIDAbsentRunsWholeTopology covers the
// single-node rule: those spinifex.toml templates omit host_id entirely,
// pinning every node of the topology to one host, which only a
// whole-topology process can serve.
func TestDerivePredastoreBind_HostIDAbsentRunsWholeTopology(t *testing.T) {
resetGlobalViper(t)
path := writeSpinifexToml(t, `
@@ -90,7 +88,7 @@ region = "us-east-1"
require.NoError(t, err)
assert.Equal(t, "0.0.0.0", bind.Host)
assert.Equal(t, 8443, bind.Port)
assert.Equal(t, -1, bind.NodeID)
assert.Equal(t, 0, bind.HostID)
}
// TestDerivePredastoreBind_BindHostSurvivesLoadConfigNormalization asserts
+20 -25
View File
@@ -89,12 +89,12 @@ var spinifexUICmd = &cobra.Command{
Short: "Manage the spinifex-ui service",
}
// predastoreBind is the local predastore bind host, port and node_id derived
// predastoreBind is the local predastore bind host, port and host_id derived
// directly from spinifex.toml.
type predastoreBind struct {
Host string
Port int
NodeID int
HostID int
}
// derivePredastoreBind reads this node's [nodes.<node>.predastore] section
@@ -104,9 +104,9 @@ type predastoreBind struct {
// normalization for callers that DIAL predastore, not for the address
// predastore itself binds to.
//
// node_id defaults to -1 (co-located: every configured DB peer runs in this
// one process) when spinifex.toml omits the key. predastore rejects
// node_id=0, so an absent key must never silently resolve to that.
// host_id defaults to 0 when spinifex.toml omits the key, which runs the whole
// predastore topology in this one process — the single-node deployment. Only a
// multi-node config names a host (>= 1), selecting just that host's nodes.
func derivePredastoreBind(clusterConfig *config.ClusterConfig) (predastoreBind, error) {
node := clusterConfig.Node
bindKey := "nodes." + node + ".predastore.host"
@@ -124,12 +124,7 @@ func derivePredastoreBind(clusterConfig *config.ClusterConfig) (predastoreBind,
return predastoreBind{}, fmt.Errorf("parse nodes.%s.predastore.host port %q: %w", node, portStr, err)
}
nodeID := -1
if viper.IsSet("nodes." + node + ".predastore.node_id") {
nodeID = clusterConfig.Nodes[node].Predastore.NodeID
}
return predastoreBind{Host: host, Port: port, NodeID: nodeID}, nil
return predastoreBind{Host: host, Port: port, HostID: clusterConfig.Nodes[node].Predastore.HostID}, nil
}
var predastoreStartCmd = &cobra.Command{
@@ -144,9 +139,9 @@ var predastoreStartCmd = &cobra.Command{
host := viper.GetString("predastore-host")
basePath := viper.GetString("predastore-base-path")
debug := viper.GetBool("predastore-debug")
nodeID := viper.GetInt("predastore-node-id")
hostID := viper.GetInt("predastore-host-id")
// Derive bind host/port/node-id from spinifex.toml when its path is
// Derive bind host/port/host-id from spinifex.toml when its path is
// known and the caller hasn't explicitly overridden them — replaces
// predastore-start.sh, which used to do this derivation and exec us.
if cfgFile := viper.GetString("config"); cfgFile != "" {
@@ -166,8 +161,8 @@ var predastoreStartCmd = &cobra.Command{
if !viper.IsSet("predastore-port") {
port = bind.Port
}
if !viper.IsSet("predastore-node-id") {
nodeID = bind.NodeID
if !viper.IsSet("predastore-host-id") {
hostID = bind.HostID
}
}
@@ -221,7 +216,7 @@ var predastoreStartCmd = &cobra.Command{
EncryptionKeyFile: encryptionKeyFile,
NodeID: nodeID,
HostID: hostID,
PprofEnabled: pprofEnabled,
PprofOutputPath: pprofOutput,
@@ -1034,7 +1029,6 @@ func runNorthstarStart(options northstarStartOptions, deps northstarStartDepende
svc, err := deps.newService(&northstar.Config{
ConfigPath: configPath,
BasePath: baseDir,
NodeID: nodeConfig.Predastore.NodeID,
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
@@ -1182,7 +1176,7 @@ func bindPredastoreNamespacedEnv() {
}
// bindPredastoreCollisionEnv namespaces predastore's port, debug, tls-cert,
// tls-key, encryption-key-file and node-id keys, which nats and awsgw also
// tls-key, encryption-key-file and host-id keys, which nats and awsgw also
// bind bare. Each derived env name now matches its own BindEnv target.
func bindPredastoreCollisionEnv() {
viper.BindEnv("predastore-port", "SPINIFEX_PREDASTORE_PORT")
@@ -1200,8 +1194,8 @@ func bindPredastoreCollisionEnv() {
viper.BindEnv("predastore-encryption-key-file", "SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE")
viper.BindPFlag("predastore-encryption-key-file", predastoreCmd.PersistentFlags().Lookup("encryption-key-file"))
viper.BindEnv("predastore-node-id", "SPINIFEX_PREDASTORE_NODE_ID")
viper.BindPFlag("predastore-node-id", predastoreCmd.PersistentFlags().Lookup("node-id"))
viper.BindEnv("predastore-host-id", "SPINIFEX_PREDASTORE_HOST_ID")
viper.BindPFlag("predastore-host-id", predastoreCmd.PersistentFlags().Lookup("host-id"))
}
// bindNatsCollisionEnv namespaces nats's port, host and debug keys, which
@@ -1281,12 +1275,13 @@ func init() {
// Predastore at-rest encryption master key (per node; mode 0600)
predastoreCmd.PersistentFlags().String("encryption-key-file", "", "Path to this node's 32-byte AES-256 master key file (required)")
// Predastore Node ID. Default -1 is dev mode (launch every configured
// QUIC node in-process). Production deployments set this to the node's
// real ID (>= 1) via SPINIFEX_PREDASTORE_NODE_ID or --node-id.
predastoreCmd.PersistentFlags().Int("node-id", -1, "Predastore (S3) node ID (-1 = dev mode, >= 1 = production)")
// Predastore host ID: which [[host]] of the predastore topology this
// process is. Default 0 runs every node of the topology in this process,
// which is the single-node deployment. Multi-node deployments set the
// host's real ID (>= 1) via SPINIFEX_PREDASTORE_HOST_ID or --host-id.
predastoreCmd.PersistentFlags().Int("host-id", 0, "Predastore cluster host ID (0 = run the whole topology in this process, >= 1 = this host only)")
// Namespaced viper keys for port/debug/tls-cert/tls-key/encryption-key-file/node-id
// Namespaced viper keys for port/debug/tls-cert/tls-key/encryption-key-file/host-id
// (see bindPredastoreCollisionEnv doc comment)
bindPredastoreCollisionEnv()
@@ -1,10 +1,10 @@
# Predastore S3-compatible storage configuration file in TOML format.
# Generated for multi-node cluster
version = "4"
version = "5"
region = "{{.Region}}"
host = "0.0.0.0"
port = 8443
# The gateway listen address comes from the daemon CLI options: a top-level
# host key would collide with the [[host]] topology tables below.
debug = false
@@ -16,26 +16,28 @@ parity = 1
data = 2
parity = 1
{{- end}}
# One host per machine: a predastore process owning a socket and a data
# directory. bind_addr binds every interface; public_addr is what the other
# hosts dial.
{{- range .Nodes}}
[[db]]
[[host]]
id = {{.ID}}
host = "{{.Host}}"
port = 6660
path = "distributed/db/node-{{.ID}}/"
access_key_id = "{{$.AccessKey}}"
secret_access_key = "{{$.SecretKey}}"
{{- if eq .ID 1}}
leader = true
bind_addr = "0.0.0.0:6660"
public_addr = "{{.Host}}:6660"
data_dir = "{{$.PredastoreDataDir}}"
{{- end}}
{{- end}}
{{- range .Nodes}}
[[nodes]]
# Nodes are roles pinned to a host: each machine carries one shard-storage
# node for erasure-coded object shards and one state replica for the Raft
# quorum over global state.
{{- range .ClusterNodes}}
[[node]]
id = {{.ID}}
host = "{{.Host}}"
port = 9991
path = "distributed/nodes/node-{{.ID}}/"
host_id = {{.HostID}}
role = "{{.Role}}"
{{- end}}
[[buckets]]
+40 -43
View File
@@ -1,11 +1,10 @@
# Predastore S3-compatible storage configuration file in TOML format.
# MULTI NODE used for testing
version = "4"
version = "5"
region = "{{.Region}}"
# Optionally configure port and host, or specify via daemon CLI options
host = "0.0.0.0"
port = 8443
# The gateway listen address comes from the daemon CLI options: a top-level
# host key would collide with the [[host]] topology table below.
# Enable verbose debugging
debug = false
@@ -20,52 +19,50 @@ debug = false
data = 2
parity = 1
[[db]]
# A host is one predastore process: it owns a socket and a data directory,
# and runs the nodes pinned to it. A single-node deployment declares one host
# and colocates every node in it, so all intra-cluster traffic goes over the
# in-process pipe and no socket is bound. bind_addr/public_addr are unused
# while everything is colocated; they only matter once nodes are split across
# processes.
[[host]]
id = 1
host = "127.0.0.1"
port = 6660
#raft_port = 7660 # Optional: Raft consensus port (default: port + 1000)
#advertise_host = "" # Optional: Address to advertise to other Raft nodes
path = "distributed/db/node-1/"
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
leader = true # Bootstrap leader hint
bind_addr = "127.0.0.1:6660"
public_addr = "127.0.0.1:6660"
data_dir = "{{.PredastoreDataDir}}"
[[db]]
id = 2
host = "127.0.0.1"
port = 6661
path = "distributed/db/node-2/"
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
[[db]]
id = 3
host = "127.0.0.1"
port = 6662
path = "distributed/db/node-3/"
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
# Shard nodes
# Using distributed hash ring for object > shard placement.
[[nodes]]
# Shard nodes: the distributed hash ring places object shards across these.
# RS(2,1) needs 3.
[[node]]
id = 1
host = "127.0.0.1"
port = 9991
path = "distributed/nodes/node-1/"
host_id = 1
role = "shard-storage"
[[nodes]]
[[node]]
id = 2
host = "127.0.0.1"
port = 9992
path = "distributed/nodes/node-2/"
host_id = 1
role = "shard-storage"
[[nodes]]
[[node]]
id = 3
host = "127.0.0.1"
port = 9993
path = "distributed/nodes/node-3/"
host_id = 1
role = "shard-storage"
# State replicas: the Raft quorum over global state (buckets, object index).
[[node]]
id = 4
host_id = 1
role = "state-replica"
[[node]]
id = 5
host_id = 1
role = "state-replica"
[[node]]
id = 6
host_id = 1
role = "state-replica"
[[buckets]]
name = "predastore"
+3 -3
View File
@@ -1,5 +1,5 @@
# Domain configuration file in TOML format.
version = "3"
version = "4"
epoch = 1
node = "{{.Node}}"
{{- if .OperatorEmail}}
@@ -140,8 +140,8 @@ region = "{{.Region}}"
bucket = "predastore"
accesskey = "{{.AccessKey}}"
secretkey = "{{.SecretKey}}"
{{- if gt .PredastoreNodeID 0}}
node_id = {{.PredastoreNodeID}}
{{- if gt .PredastoreHostID 0}}
host_id = {{.PredastoreHostID}}
{{- end}}
{{- if .NorthstarConfigPath}}
+1 -1
View File
@@ -10,7 +10,7 @@ Service lifecycle commands for starting, stopping, and checking status of all Sp
| Command | Flags | Description |
|---------|-------|-------------|
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: -1), `--pprof`, `--pprof-output` | Creates predastore service instance with S3-compatible storage backend → starts service. When `--config`/`SPINIFEX_CONFIG_PATH` points at a cluster spinifex.toml, `--host`/`--port`/`--node-id` default to that node's `[nodes.<node>.predastore]` section instead of the flag defaults above; an explicit flag or `SPINIFEX_PREDASTORE_*` env var still overrides it. `node_id` absent from spinifex.toml resolves to -1 (co-located mode), never to 0. |
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--encryption-key-file`, `--host-id` (default: 0 = run the whole topology in this process), `--pprof`, `--pprof-output` | Creates predastore service instance with S3-compatible storage backend → starts service. When `--config`/`SPINIFEX_CONFIG_PATH` points at a cluster spinifex.toml, `--host`/`--port`/`--host-id` default to that node's `[nodes.<node>.predastore]` section instead of the flag defaults above; an explicit flag or `SPINIFEX_PREDASTORE_*` env var still overrides it. `host_id` absent from spinifex.toml resolves to 0, running the whole topology in this process. |
| `spx service predastore stop` | — | Stops the predastore service |
| `spx service predastore status` | — | Reports predastore service status |
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (auto-detected via `nbdkit --dump-config plugindir`; typically `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` on amd64, overridable via `SPINIFEX_VIPERBLOCK_PLUGIN_PATH` in `/etc/spinifex/systemd.env`) | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin |
+1 -1
View File
@@ -163,7 +163,7 @@ Every listener and outbound destination is controlled by one of these files. Cha
|------|------|----------|
| `/etc/spinifex/spinifex.toml` | `nodes.<node>.{awsgw,nats,predastore,daemon}.host`, `nodes.<node>.vpcd.ovn_{nb,sb}_addr`, `nodes.<node>.daemon.dev_networking` | Per-service bind addresses/ports; dev-mode QEMU port forwarding. |
| `/etc/spinifex/nats.conf` | `listen`, `cluster.listen`, `cluster.routes`, `http`, `tls`, `cluster.authorization` | NATS client/cluster/monitoring listeners, peer routes, TLS, cluster token. |
| `/etc/spinifex/predastore.toml` | `host`, `port`, `[[db]].port`, `[[nodes]].port`, `tls.*` | Predastore S3 listener, Raft ports, shard ports, TLS certs. |
| `/etc/spinifex/predastore.toml` | `[[host]].bind_addr`, `[[host]].public_addr`, `[[node]].role`, `tls.*` | Predastore intra-cluster listener (one socket per host, carrying every node pinned to it), node placement, TLS certs. The S3 listener itself is set by the service's `--host`/`--port`. |
| OVN packages (`ovn-central`, `ovn-host`) | `ovn-nb-db`, `ovn-sb-db` (via `ovs-vsctl set open_vswitch …`) | OVN DB bind addresses. |
| Spinifex UI service | Built-in defaults: `host = "0.0.0.0"`, `port = 3000`. No `spinifex.toml` block today. | UI listener. |
| `spx admin init` / `spx admin join` | `--port`, `--token-ttl`, `--no-telemetry` (or `SPX_NO_TELEMETRY=1`) | Formation port, token TTL, telemetry opt-out. |
+83 -12
View File
@@ -51,6 +51,9 @@ type ConfigSettings struct {
DataDir string
LogDir string
ConfigDir string
// PredastoreDataDir is the [[host]] data_dir rendered into predastore.toml.
// Derived from DataDir via PredastoreDataDir when left unset.
PredastoreDataDir string
Node string
Az string
@@ -65,8 +68,9 @@ type ConfigSettings struct {
ClusterRoutes []string
ClusterName string
// Predastore multi-node
PredastoreNodeID int
// Predastore multi-node: which [[host]] of the predastore topology this
// node is. Zero means single-node, where one process runs every node.
PredastoreHostID int
// CompactionIntervalSeconds gates the predastore [compaction] block. Zero
// means unset: no block is emitted and predastore keeps its built-in default.
@@ -174,6 +178,10 @@ func GenerateConfigFiles(configs []ConfigFile, configSettings ConfigSettings) er
// GenerateConfigFile creates a configuration file from a template.
func GenerateConfigFile(configPath string, configTemplate string, configSettings ConfigSettings) error {
if configSettings.PredastoreDataDir == "" {
configSettings.PredastoreDataDir = PredastoreDataDir(configSettings.DataDir)
}
tmpl, err := template.New("config").Parse(configTemplate)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
@@ -303,6 +311,15 @@ func GenerateServerCertOnly(configDir string, bindIP, awsRegion, internalSuffix
return GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, extraIPs, extraDNS)
}
// PredastoreDataDir is the root a Predastore host keeps its per-node state
// under. Each node gets a node-<id> subdirectory beneath it, created by
// predastore itself. The path is absolute because the service runs under
// systemd, where the working directory the config would otherwise resolve
// against is not ours to depend on.
func PredastoreDataDir(spxRoot string) string {
return filepath.Join(spxRoot, "predastore", "cluster")
}
func CreateServiceDirectories(spxRoot string) {
dirs := []string{
filepath.Join(spxRoot, "images"),
@@ -934,34 +951,73 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string)
return nil
}
// PredastoreClusterNode is one [[node]] entry of the Predastore topology: a
// role pinned to a host.
type PredastoreClusterNode struct {
ID int
HostID int
Role string
}
// Predastore node roles: shard storage holds erasure-coded object shards,
// state replicas form the Raft quorum over global state.
const (
predastoreRoleShardStorage = "shard-storage"
predastoreRoleStateReplica = "state-replica"
)
// PredastoreTopology derives the cluster nodes for a set of machines: each
// machine hosts one shard-storage node and one state replica. Node IDs are
// unique across roles, so storage takes 1..n and the replicas n+1..2n.
func PredastoreTopology(nodes []PredastoreNodeConfig) []PredastoreClusterNode {
out := make([]PredastoreClusterNode, 0, len(nodes)*2)
for _, n := range nodes {
out = append(out, PredastoreClusterNode{ID: n.ID, HostID: n.ID, Role: predastoreRoleShardStorage})
}
for _, n := range nodes {
out = append(out, PredastoreClusterNode{ID: n.ID + len(nodes), HostID: n.ID, Role: predastoreRoleStateReplica})
}
return out
}
// GenerateMultiNodePredastoreConfig produces a complete predastore.toml for a
// multi-node Predastore cluster. Each node gets its own DB entry (port 6660)
// and shard entry (port 9991) on a distinct IP. Node ID 1 is the bootstrap leader.
// multi-node Predastore cluster. Each machine becomes one [[host]] — a
// predastore process owning a socket on port 6660 and a data directory —
// carrying the shard-storage and state-replica nodes pinned to it.
//
// dataDir is the Spinifex data root; the hosts' data directories are absolute
// beneath it, since the service runs under systemd with no dependable working
// directory.
//
// A populated northstar credential provisions the zone bucket, grants the
// system key write access to it, and adds the read-only entry the resolver
// authenticates with. A zero value omits all three, yielding a config no
// northstar service can use.
func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNodeConfig, accessKey, secretKey, region, natsToken, configDir, bindIP string, compactionIntervalSeconds int, northstar NorthstarCredentials) (string, error) {
func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNodeConfig, accessKey, secretKey, region, natsToken, configDir, dataDir, bindIP string, compactionIntervalSeconds int, northstar NorthstarCredentials) (string, error) {
if len(nodes) < 2 {
return "", fmt.Errorf("multi-node predastore requires at least 2 nodes, got %d", len(nodes))
}
data := struct {
Nodes []PredastoreNodeConfig
ClusterNodes []PredastoreClusterNode
AccessKey string
SecretKey string
Region string
NatsToken string
ConfigDir string
PredastoreDataDir string
BindIP string
CompactionIntervalSeconds int
NorthstarAccessKey string
NorthstarSecretKey string
NorthstarBucket string
}{
Nodes: nodes, AccessKey: accessKey, SecretKey: secretKey, Region: region,
NatsToken: natsToken, ConfigDir: configDir, BindIP: bindIP,
Nodes: nodes, ClusterNodes: PredastoreTopology(nodes),
AccessKey: accessKey, SecretKey: secretKey, Region: region,
NatsToken: natsToken, ConfigDir: configDir,
PredastoreDataDir: PredastoreDataDir(dataDir),
BindIP: bindIP,
CompactionIntervalSeconds: compactionIntervalSeconds,
NorthstarAccessKey: northstar.AccessKey,
NorthstarSecretKey: northstar.SecretKey,
@@ -992,17 +1048,32 @@ func FindNodeIDByIP(nodes []PredastoreNodeConfig, ip string) int {
return 0
}
// ParsePredastoreNodeIDFromConfig parses a predastore.toml string and returns
// the node ID whose host matches the given IP, or 0 if not found.
func ParsePredastoreNodeIDFromConfig(tomlContent string, ip string) int {
// ParsePredastoreHostIDFromConfig parses a predastore.toml string and returns
// the ID of the [[host]] whose public address matches the given IP, or 0 if
// not found. The host is what an operator places on a machine; the nodes
// pinned to it follow from the topology.
func ParsePredastoreHostIDFromConfig(tomlContent string, ip string) int {
var cfg struct {
DB []PredastoreNodeConfig `toml:"db"`
Hosts []struct {
ID int `toml:"id"`
PublicAddr string `toml:"public_addr"`
} `toml:"host"`
}
if err := toml.Unmarshal([]byte(tomlContent), &cfg); err != nil {
slog.Warn("Failed to parse predastore.toml content", "error", err)
return 0
}
return FindNodeIDByIP(cfg.DB, ip)
for _, h := range cfg.Hosts {
host, _, err := net.SplitHostPort(h.PublicAddr)
if err != nil {
// A bare address without a port is still a usable match.
host = h.PublicAddr
}
if host == ip {
return h.ID
}
}
return 0
}
// SetMIGProfile idempotently writes mig_profile = "<profile>" for the given node
+77 -21
View File
@@ -800,9 +800,10 @@ func TestCreateServiceDirectories_Idempotent(t *testing.T) {
// --- Predastore multi-node config ---
func TestGenerateMultiNodePredastoreConfig_Success(t *testing.T) {
tmpl := `{{range .Nodes}}[[db]]
tmpl := `{{range .Nodes}}[[host]]
id = {{.ID}}
host = "{{.Host}}"
public_addr = "{{.Host}}:6660"
data_dir = "{{$.PredastoreDataDir}}"
{{end}}`
nodes := []PredastoreNodeConfig{
{ID: 1, Host: "10.0.0.1"},
@@ -810,10 +811,51 @@ host = "{{.Host}}"
{ID: 3, Host: "10.0.0.3"},
}
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1", 0, NorthstarCredentials{})
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{})
require.NoError(t, err)
assert.Contains(t, result, `host = "10.0.0.1"`)
assert.Contains(t, result, `host = "10.0.0.3"`)
assert.Contains(t, result, `public_addr = "10.0.0.1:6660"`)
assert.Contains(t, result, `public_addr = "10.0.0.3:6660"`)
assert.Contains(t, result, `data_dir = "/var/lib/spinifex/predastore/cluster"`)
}
// Each machine hosts one shard-storage node and one state replica, with node
// IDs unique across both roles so the topology validates.
func TestGenerateMultiNodePredastoreConfig_Topology(t *testing.T) {
tmpl := `{{range .ClusterNodes}}[[node]]
id = {{.ID}}
host_id = {{.HostID}}
role = "{{.Role}}"
{{end}}`
nodes := []PredastoreNodeConfig{
{ID: 1, Host: "10.0.0.1"},
{ID: 2, Host: "10.0.0.2"},
{ID: 3, Host: "10.0.0.3"},
}
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{})
require.NoError(t, err)
assert.Contains(t, result, "id = 1\nhost_id = 1\nrole = \"shard-storage\"")
assert.Contains(t, result, "id = 3\nhost_id = 3\nrole = \"shard-storage\"")
assert.Contains(t, result, "id = 4\nhost_id = 1\nrole = \"state-replica\"")
assert.Contains(t, result, "id = 6\nhost_id = 3\nrole = \"state-replica\"")
}
func TestPredastoreTopology_UniqueIDsAcrossRoles(t *testing.T) {
topology := PredastoreTopology([]PredastoreNodeConfig{
{ID: 1, Host: "10.0.0.1"},
{ID: 2, Host: "10.0.0.2"},
})
require.Len(t, topology, 4)
seen := map[int]bool{}
for _, n := range topology {
assert.False(t, seen[n.ID], "duplicate node id %d", n.ID)
seen[n.ID] = true
assert.Contains(t, []int{1, 2}, n.HostID)
}
assert.Equal(t, "shard-storage", topology[0].Role)
assert.Equal(t, "state-replica", topology[2].Role)
}
// The northstar template fields were declared but never assigned, so every
@@ -826,7 +868,7 @@ func TestGenerateMultiNodePredastoreConfig_NorthstarCredentialsReachTemplate(t *
{ID: 2, Host: "10.0.0.2"},
}
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1", 0,
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0,
NorthstarCredentials{AccessKey: "NSAK", SecretKey: "NSSK", Bucket: "northstar"})
require.NoError(t, err)
assert.Equal(t, `access = "NSAK" secret = "NSSK" bucket = "northstar"`, result)
@@ -841,7 +883,7 @@ func TestGenerateMultiNodePredastoreConfig_NoNorthstarCredentials(t *testing.T)
{ID: 2, Host: "10.0.0.2"},
}
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1", 0,
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0,
NorthstarCredentials{})
require.NoError(t, err)
assert.Equal(t, `access = "" secret = "" bucket = ""`, result)
@@ -852,7 +894,7 @@ func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) {
_, err := GenerateMultiNodePredastoreConfig(tmpl, []PredastoreNodeConfig{
{ID: 1, Host: "10.0.0.1"},
}, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1", 0, NorthstarCredentials{})
}, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least 2 nodes")
}
@@ -860,7 +902,7 @@ func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) {
func TestGenerateMultiNodePredastoreConfig_InvalidTemplate(t *testing.T) {
_, err := GenerateMultiNodePredastoreConfig("{{.Unclosed", []PredastoreNodeConfig{
{ID: 1, Host: "a"}, {ID: 2, Host: "b"}, {ID: 3, Host: "c"},
}, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1", 0, NorthstarCredentials{})
}, "AK", "SK", "us-east-1", "nats-token", "/config", "/var/lib/spinifex", "10.0.0.1", 0, NorthstarCredentials{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse")
}
@@ -879,26 +921,40 @@ func TestFindNodeIDByIP(t *testing.T) {
assert.Equal(t, 0, FindNodeIDByIP(nil, "10.0.0.1"))
}
// --- ParsePredastoreNodeIDFromConfig ---
// --- ParsePredastoreHostIDFromConfig ---
func TestParsePredastoreNodeIDFromConfig(t *testing.T) {
func TestParsePredastoreHostIDFromConfig(t *testing.T) {
tomlContent := `
[[db]]
[[host]]
id = 1
host = "10.0.0.1"
bind_addr = "0.0.0.0:6660"
public_addr = "10.0.0.1:6660"
[[db]]
[[host]]
id = 2
host = "10.0.0.2"
bind_addr = "0.0.0.0:6660"
public_addr = "10.0.0.2:6660"
[[db]]
[[host]]
id = 3
host = "10.0.0.3"
bind_addr = "0.0.0.0:6660"
public_addr = "10.0.0.3:6660"
`
assert.Equal(t, 2, ParsePredastoreNodeIDFromConfig(tomlContent, "10.0.0.2"))
assert.Equal(t, 0, ParsePredastoreNodeIDFromConfig(tomlContent, "10.0.0.99"))
assert.Equal(t, 0, ParsePredastoreNodeIDFromConfig("invalid toml {{{", "10.0.0.1"))
assert.Equal(t, 0, ParsePredastoreNodeIDFromConfig("", "10.0.0.1"))
assert.Equal(t, 2, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.2"))
assert.Equal(t, 0, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.99"))
assert.Equal(t, 0, ParsePredastoreHostIDFromConfig("invalid toml {{{", "10.0.0.1"))
assert.Equal(t, 0, ParsePredastoreHostIDFromConfig("", "10.0.0.1"))
}
// A public_addr without a port must still match, so a hand-edited config that
// omits it resolves to a host rather than silently to zero.
func TestParsePredastoreHostIDFromConfig_AddressWithoutPort(t *testing.T) {
tomlContent := `
[[host]]
id = 7
public_addr = "10.0.0.7"
`
assert.Equal(t, 7, ParsePredastoreHostIDFromConfig(tomlContent, "10.0.0.7"))
}
// --- Integration: Full config generation flow ---
+3 -1
View File
@@ -262,7 +262,9 @@ type PredastoreConfig struct {
AccessKey string `json:"AccessKey" mapstructure:"accesskey"`
SecretKey string `json:"SecretKey" mapstructure:"secretkey"`
BaseDir string `json:"BaseDir" mapstructure:"base_dir"`
NodeID int `json:"NodeID" mapstructure:"node_id"`
// HostID is which [[host]] of the predastore topology this node runs;
// unset means the node runs the whole topology in one process.
HostID int `json:"HostID" mapstructure:"host_id"`
}
// GPUModelOverride maps a PCI vendor/device ID to a GPU instance family for
+2 -2
View File
@@ -219,7 +219,7 @@ region = "ap-southeast-2"
accesskey = "AK"
secretkey = "SK"
base_dir = "/data"
node_id = 1
host_id = 1
[nodes.n1.awsgw]
host = "0.0.0.0:9999"
@@ -242,7 +242,7 @@ expected_nodes = 3
assert.Equal(t, "mybucket", n.Predastore.Bucket)
assert.Equal(t, "AK", n.Predastore.AccessKey)
assert.Equal(t, "/data", n.Predastore.BaseDir)
assert.Equal(t, 1, n.Predastore.NodeID)
assert.Equal(t, 1, n.Predastore.HostID)
assert.Equal(t, "0.0.0.0:9999", n.AWSGW.Host)
assert.True(t, n.AWSGW.Debug)
assert.Equal(t, 3, n.AWSGW.ExpectedNodes)
+52 -23
View File
@@ -2,8 +2,10 @@ package daemon
import (
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/nats-io/nats.go"
@@ -14,8 +16,8 @@ import (
// containing only the fields needed for storage metrics (no credentials).
type predastoreTOML struct {
RS predastoreRS `toml:"rs"`
DB []predastoreDBNode `toml:"db"`
Nodes []predastoreNode `toml:"nodes"`
Hosts []predastoreHost `toml:"host"`
Nodes []predastoreNode `toml:"node"`
Buckets []predastoreBucket `toml:"buckets"`
}
@@ -24,18 +26,27 @@ type predastoreRS struct {
Parity int `toml:"parity"`
}
type predastoreDBNode struct {
ID int `toml:"id"`
Host string `toml:"host"`
Port int `toml:"port"`
// predastoreHost is one predastore process; the nodes pinned to it are all
// reachable at its public address.
type predastoreHost struct {
ID int `toml:"id"`
PublicAddr string `toml:"public_addr"`
}
// predastoreNode is a role pinned to a host. Its address is the host's, so
// reporting a node means resolving its host_id.
type predastoreNode struct {
ID int `toml:"id"`
Host string `toml:"host"`
Port int `toml:"port"`
ID int `toml:"id"`
HostID int `toml:"host_id"`
Role string `toml:"role"`
}
// Predastore node roles, as written in the [[node]] blocks.
const (
predastoreRoleShardStorage = "shard-storage"
predastoreRoleStateReplica = "state-replica"
)
type predastoreBucket struct {
Name string `toml:"name"`
Type string `toml:"type"`
@@ -69,23 +80,26 @@ func (d *Daemon) handleStorageConfig(msg *nats.Msg) {
},
}
for _, db := range cfg.DB {
resp.DBNodes = append(resp.DBNodes, types.StorageDBNode{
ID: db.ID,
Host: db.Host,
Port: db.Port,
})
}
if resp.DBNodes == nil {
resp.DBNodes = []types.StorageDBNode{}
// A node's address is its host's: every node pinned to a host is served
// by that one process, keyed apart within it.
hostAddrs := make(map[int]predastoreHost, len(cfg.Hosts))
for _, h := range cfg.Hosts {
hostAddrs[h.ID] = h
}
for _, n := range cfg.Nodes {
resp.ShardNodes = append(resp.ShardNodes, types.StorageShardNode{
ID: n.ID,
Host: n.Host,
Port: n.Port,
})
host, port := splitPredastoreAddr(hostAddrs[n.HostID].PublicAddr)
switch n.Role {
case predastoreRoleStateReplica:
resp.DBNodes = append(resp.DBNodes, types.StorageDBNode{ID: n.ID, Host: host, Port: port})
case predastoreRoleShardStorage:
resp.ShardNodes = append(resp.ShardNodes, types.StorageShardNode{ID: n.ID, Host: host, Port: port})
default:
slog.Warn("handleStorageConfig: unknown predastore node role", "node", n.ID, "role", n.Role)
}
}
if resp.DBNodes == nil {
resp.DBNodes = []types.StorageDBNode{}
}
if resp.ShardNodes == nil {
resp.ShardNodes = []types.StorageShardNode{}
@@ -104,3 +118,18 @@ func (d *Daemon) handleStorageConfig(msg *nats.Msg) {
respondWithJSON(msg, resp)
}
// splitPredastoreAddr splits a host's "ip:port" into its parts. An address
// missing or malformed in the config yields the raw value and a zero port
// rather than dropping the node from the report entirely.
func splitPredastoreAddr(addr string) (string, int) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return addr, 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
return host, 0
}
return host, port
}
@@ -0,0 +1,82 @@
package daemon
import (
"testing"
toml "github.com/pelletier/go-toml/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The storage status report is built by parsing predastore.toml directly, so
// it degrades silently when the schema moves under it: a config that no longer
// matches yields an empty topology rather than an error. This pins the parse
// to the [[host]]/[[node]] dialect the templates actually emit.
func TestPredastoreTOML_ParsesClusterTopology(t *testing.T) {
content := `
[rs]
data = 2
parity = 1
[[host]]
id = 1
bind_addr = "0.0.0.0:6660"
public_addr = "10.0.0.1:6660"
data_dir = "/var/lib/spinifex/predastore/cluster"
[[host]]
id = 2
bind_addr = "0.0.0.0:6660"
public_addr = "10.0.0.2:6660"
data_dir = "/var/lib/spinifex/predastore/cluster"
[[node]]
id = 1
host_id = 1
role = "shard-storage"
[[node]]
id = 2
host_id = 2
role = "shard-storage"
[[node]]
id = 3
host_id = 1
role = "state-replica"
[[buckets]]
name = "predastore"
type = "distributed"
region = "ap-southeast-2"
`
var cfg predastoreTOML
require.NoError(t, toml.Unmarshal([]byte(content), &cfg))
assert.Equal(t, 2, cfg.RS.Data)
assert.Equal(t, 1, cfg.RS.Parity)
require.Len(t, cfg.Hosts, 2)
require.Len(t, cfg.Nodes, 3)
assert.Equal(t, "10.0.0.2:6660", cfg.Hosts[1].PublicAddr)
assert.Equal(t, predastoreRoleShardStorage, cfg.Nodes[0].Role)
assert.Equal(t, 1, cfg.Nodes[2].HostID)
assert.Equal(t, predastoreRoleStateReplica, cfg.Nodes[2].Role)
}
func TestSplitPredastoreAddr(t *testing.T) {
host, port := splitPredastoreAddr("10.0.0.1:6660")
assert.Equal(t, "10.0.0.1", host)
assert.Equal(t, 6660, port)
// A hand-edited address without a port must still name its node rather
// than dropping it from the report.
host, port = splitPredastoreAddr("10.0.0.1")
assert.Equal(t, "10.0.0.1", host)
assert.Equal(t, 0, port)
host, port = splitPredastoreAddr("")
assert.Empty(t, host)
assert.Equal(t, 0, port)
}
@@ -0,0 +1,91 @@
package migrate
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// The spinifex config target, relative to the Spinifex config directory.
const (
spinifexTarget = "spinifex.toml"
spinifexRelPath = "spinifex.toml"
// spinifexHostIDVersion is the schema where the predastore section names a
// cluster host rather than a node.
spinifexHostIDVersion = 4
)
// spinifexPredastoreTableRe matches the per-node predastore section header,
// [nodes.<name>.predastore], which is the only place node_id ever appeared.
var spinifexPredastoreTableRe = regexp.MustCompile(`^\[nodes\.[^\[\]]+\.predastore\]$`)
// spinifexNodeIDKeyRe matches the key to rename, preserving its indentation
// and spacing so the rest of the line is untouched.
var spinifexNodeIDKeyRe = regexp.MustCompile(`^(\s*)node_id(\s*=)`)
// spinifexTableHeaderRe captures the name of a table or table-array header.
var spinifexTableHeaderRe = regexp.MustCompile(`^\[\[?([^\[\]]+)\]?\]$`)
func init() {
DefaultRegistry.RegisterConfigTarget(spinifexTarget, spinifexRelPath, &TOMLVersionReader{})
DefaultRegistry.RegisterConfig(spinifexTarget, ConfigMigration{
FromVersion: 3,
ToVersion: spinifexHostIDVersion,
Description: "rename [nodes.*.predastore] node_id → host_id",
Run: migrateSpinifexPredastoreHostID,
})
}
// migrateSpinifexPredastoreHostID renames the predastore node selector.
//
// A machine is one predastore host now, and derivePredastoreBind reads this
// key to pick the process's host. Left as node_id, a multi-node member falls
// back to host 0 and tries to run every node in the cluster in its own process.
//
// Single-node installs never had the key — the template only emits it for
// multi-node — so for them this is a version bump and nothing else.
func migrateSpinifexPredastoreHostID(ctx ConfigContext) error {
path := filepath.Join(ctx.ConfigDir, spinifexRelPath)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
lines := strings.Split(string(data), "\n")
inPredastore := false
renamed := 0
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if spinifexTableHeaderRe.MatchString(trimmed) {
inPredastore = spinifexPredastoreTableRe.MatchString(trimmed)
continue
}
if !inPredastore {
continue
}
if spinifexNodeIDKeyRe.MatchString(line) {
lines[i] = spinifexNodeIDKeyRe.ReplaceAllString(line, "${1}host_id${2}")
renamed++
}
}
if renamed == 0 {
return nil
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat %s: %w", path, err)
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode()); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
ctx.Logger.Info("Renamed predastore node_id to host_id", "path", path, "keys", renamed)
return nil
}
@@ -0,0 +1,99 @@
package migrate
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// spinifexTomlFixture writes a spinifex.toml and returns its config directory.
func spinifexTomlFixture(t *testing.T, body string) string {
t.Helper()
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, spinifexRelPath), []byte(body), 0640))
return configDir
}
func readSpinifexToml(t *testing.T, configDir string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(configDir, spinifexRelPath))
require.NoError(t, err)
return string(data)
}
// TestSpinifexPredastoreHostIDRename covers a multi-node member, where the key
// selects which predastore host this machine runs.
func TestSpinifexPredastoreHostIDRename(t *testing.T) {
configDir := spinifexTomlFixture(t, `version = "3"
[nodes.node1]
node = "node1"
host = "10.0.0.1"
[nodes.node1.predastore]
host = "10.0.0.1:8443"
region = "ap-southeast-2"
bucket = "predastore"
node_id = 2
[nodes.node2]
node = "node2"
`)
require.NoError(t, DefaultRegistry.RunConfig(spinifexTarget, configDir, t.TempDir()))
got := readSpinifexToml(t, configDir)
assert.Contains(t, got, "host_id = 2")
assert.NotContains(t, got, "node_id")
assert.Contains(t, got, `version = "4"`)
// The surrounding tables are untouched.
assert.Contains(t, got, `host = "10.0.0.1:8443"`)
assert.Contains(t, got, "[nodes.node2]")
}
// TestSpinifexPredastoreHostIDLeavesOtherTables guards against renaming a
// node_id that belongs to something else — only the predastore section moved
// from naming a node to naming a host.
func TestSpinifexPredastoreHostIDLeavesOtherTables(t *testing.T) {
configDir := spinifexTomlFixture(t, `version = "3"
[cluster]
node_id = 7
[nodes.node1.viperblock]
node_id = 9
[nodes.node1.predastore]
node_id = 2
`)
require.NoError(t, DefaultRegistry.RunConfig(spinifexTarget, configDir, t.TempDir()))
got := readSpinifexToml(t, configDir)
assert.Contains(t, got, "[cluster]\nnode_id = 7")
assert.Contains(t, got, "[nodes.node1.viperblock]\nnode_id = 9")
assert.Contains(t, got, "[nodes.node1.predastore]\nhost_id = 2")
}
// TestSpinifexPredastoreHostIDSingleNode is the common install: the key is
// only emitted for multi-node, so there is nothing to rename.
func TestSpinifexPredastoreHostIDSingleNode(t *testing.T) {
body := `version = "3"
[nodes.node1.predastore]
host = "0.0.0.0:8443"
bucket = "predastore"
`
configDir := spinifexTomlFixture(t, body)
require.NoError(t, DefaultRegistry.RunConfig(spinifexTarget, configDir, t.TempDir()))
got := readSpinifexToml(t, configDir)
assert.Contains(t, got, `version = "4"`)
assert.Contains(t, got, `host = "0.0.0.0:8443"`)
assert.NotContains(t, got, "host_id")
}
+18 -9
View File
@@ -6,22 +6,31 @@ Single source of truth for the current schema version of every config file Spini
|---|---|---|
| `nats.conf` | `3` | `cmd/spinifex/cmd/templates/nats.conf` |
| `awsgw.toml` | `2` | `cmd/spinifex/cmd/templates/awsgw.toml` |
| `spinifex.toml` | `2` | `cmd/spinifex/cmd/templates/spinifex.toml` |
| `predastore.toml` | `4` | `cmd/spinifex/cmd/templates/predastore.toml` |
| `predastore-multinode.toml` | `4` | `cmd/spinifex/cmd/templates/predastore-multinode.toml` |
| `spinifex.toml` | `4` | `cmd/spinifex/cmd/templates/spinifex.toml` |
| `predastore.toml` | `5` | `cmd/spinifex/cmd/templates/predastore.toml` |
| `predastore-multinode.toml` | `5` | `cmd/spinifex/cmd/templates/predastore-multinode.toml` |
## How versions are stamped
1. **Fresh install (`spx admin init`)** — the version string is baked into the embedded template file and written verbatim to disk. There is no Go constant; the template is the source of truth.
2. **Upgrade (`spx admin upgrade`)** — the migration framework (`Registry.RunConfig`) calls `ConfigVersionReader.WriteVersion` after each registered migration step. With no migrations registered, the upgrade command reports "No pending config migrations" and exits cleanly.
2. **Upgrade (`spx admin upgrade`)** — the migration framework (`Registry.RunConfig`) calls `ConfigVersionReader.WriteVersion` after each registered migration step. A target with no registered migration is left alone, and the upgrade command reports nothing pending for it.
## Current state
## Registered config migrations
**No config migrations are currently registered.** Schema bumps ship via force-reinstall. To restore in-place upgrade for a given target, register a `ConfigMigration` against `DefaultRegistry` in a new file under `spinifex/migrate/` and bump the version in both the template and this document.
| Target | Migration | File |
|---|---|---|
| `predastore.toml` | `4` → `5`: `[[db]]`/`[[nodes]]` → `[[host]]`/`[[node]]`, relocate node data, recover raft | `spinifex/migrate/predastoretopology/004_predastore_topology.go` |
| `spinifex.toml` | `3` → `4`: rename `[nodes.*.predastore]` `node_id` → `host_id` | `spinifex/migrate/005_spinifex_predastore_host_id.go` |
## Where migrations live
Register a `ConfigMigration` against `DefaultRegistry` in a new numbered file under `spinifex/migrate/`, and bump the version in both the template and the table above.
A migration that needs more than the framework and the standard library goes in its own package beside `migrate` instead, blank-imported from `cmd/spinifex/cmd/admin_upgrade.go`. `migrate` is imported by every handler that stamps a KV bucket version, so a heavy dependency there reaches the whole tree — `predastoretopology` needs Predastore's cluster runtime, which is why it sits apart.
## Framework
The framework (`migrate.go`, `version_readers.go`) is intentionally preserved:
The framework (`migrate.go`, `version_readers.go`) covers both kinds of target:
- `RunKV` is still called from service-startup paths to stamp NATS KV bucket versions.
- `RunConfig` / `RunAllConfig` / `PendingConfig` remain available so a future migration can register cleanly without re-introducing the framework.
- `RunKV` is called from service-startup paths to stamp NATS KV bucket versions.
- `RunConfig` / `RunAllConfig` / `PendingConfig` back `spx admin upgrade`, which `scripts/setup.sh` runs with `--yes` while the services are stopped.
@@ -0,0 +1,447 @@
// Package predastoretopology migrates a Predastore install onto the v5 cluster
// topology: hosts owning a data directory, nodes pinned to them by role.
//
// It sits outside the migrate package because it needs Predastore's cluster
// runtime to rewrite raft state, and migrate is imported by every handler that
// stamps a KV bucket version. Only the spx binary that runs `admin upgrade`
// links this.
package predastoretopology
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
toml "github.com/pelletier/go-toml/v2"
"github.com/mulgadc/predastore/clusterrun"
"github.com/mulgadc/spinifex/spinifex/migrate"
)
// The predastore config target, relative to the Spinifex config directory.
const (
predastoreTarget = "predastore.toml"
predastoreRelPath = "predastore/predastore.toml"
// predastoreClusterVersion is the schema the cluster topology lands on;
// the install templates stamp it directly.
predastoreClusterVersion = 5
)
// predastoreHostPort is the port a v5 host binds. Every v4 [[db]] entry used
// it too, so a migrated topology keeps the same firewall surface.
const predastoreHostPort = 6660
// Predastore v5 node roles, mirroring internal/cluster.Role.
const (
predastoreRoleShard = "shard-storage"
predastoreRoleReplica = "state-replica"
)
// predastoreV4Node is the v4 shape of a [[db]] or [[nodes]] entry, reduced to
// the fields the migration needs. A frozen copy keeps the migration
// independent of predastore's config surface, which no longer parses these.
type predastoreV4Node struct {
ID int `toml:"id"`
Host string `toml:"host"`
Path string `toml:"path"`
}
// predastoreV4Config is the v4 topology. Only the two tables the migration
// rewrites are declared; every other key rides through the rewrite as text.
type predastoreV4Config struct {
DB []predastoreV4Node `toml:"db"`
Nodes []predastoreV4Node `toml:"nodes"`
}
// predastoreHost is one v5 [[host]]: a predastore process owning a socket and
// a data directory.
type predastoreHost struct {
ID int
BindAddr string
PublicAddr string
DataDir string
}
// predastoreNode is one v5 [[node]]: a role pinned to a host.
type predastoreNode struct {
ID int
HostID int
Role string
}
// predastoreMove relocates one node's state from its v4 directory to the v5
// one derived from the host data directory and the node id.
type predastoreMove struct {
NodeID int
Role string
From string
To string
}
// predastoreTopology is everything derived from a v4 config: the tables to
// write, the directories to move, and the replica set any relocated state
// replica must be recovered onto.
type predastoreTopology struct {
Hosts []predastoreHost
Nodes []predastoreNode
Moves []predastoreMove
ReplicaIDs []int
}
// predastoreHostTableRe detects a config that already carries the v5 topology,
// so a part-applied migration can be re-run.
var predastoreHostTableRe = regexp.MustCompile(`(?m)^\s*\[\[host\]\]\s*$`)
// predastoreTopLevelAddrRe matches the v4 gateway address keys, which must go:
// a scalar host cannot coexist with the [[host]] tables.
var predastoreTopLevelAddrRe = regexp.MustCompile(`^(host|port)\s*=`)
// predastoreTableHeaderRe captures the name of a table or table-array header.
var predastoreTableHeaderRe = regexp.MustCompile(`^\[\[?([^\[\]]+)\]?\]$`)
func init() {
migrate.DefaultRegistry.RegisterConfigTarget(predastoreTarget, predastoreRelPath, &migrate.TOMLVersionReader{})
migrate.DefaultRegistry.RegisterConfig(predastoreTarget, migrate.ConfigMigration{
FromVersion: 4,
ToVersion: predastoreClusterVersion,
Description: "predastore [[db]]/[[nodes]] → [[host]]/[[node]]; relocate node data and recover raft",
Run: migratePredastoreTopology,
})
}
// migratePredastoreTopology carries a v4 install onto the v5 cluster topology:
// the data moves first, since a failure there leaves a config still describing
// where the data actually is.
func migratePredastoreTopology(ctx migrate.ConfigContext) error {
path := filepath.Join(ctx.ConfigDir, predastoreRelPath)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
if predastoreHostTableRe.Match(data) {
ctx.Logger.Info("Predastore config already carries [[host]] tables, leaving it alone", "path", path)
return nil
}
var old predastoreV4Config
if err := toml.Unmarshal(data, &old); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
topo, err := predastoreTopologyFromV4(old, ctx.DataDir)
if err != nil {
return err
}
if err := relocatePredastoreData(ctx, topo); err != nil {
return err
}
out, err := rewritePredastoreConfig(data, topo)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat %s: %w", path, err)
}
if err := os.WriteFile(path, out, info.Mode()); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// predastoreTopologyFromV4 derives the v5 topology from a v4 config.
//
// A machine is one host. Shard nodes keep their ids, because object metadata
// records which node holds each shard; the state replicas are the ones that
// move, taking ids above the shard range.
func predastoreTopologyFromV4(old predastoreV4Config, spxRoot string) (*predastoreTopology, error) {
if len(old.DB) == 0 || len(old.Nodes) == 0 {
return nil, fmt.Errorf("predastore config has %d [[db]] and %d [[nodes]] entries; not a v4 distributed topology", len(old.DB), len(old.Nodes))
}
db := append([]predastoreV4Node(nil), old.DB...)
shards := append([]predastoreV4Node(nil), old.Nodes...)
sort.Slice(db, func(i, j int) bool { return db[i].ID < db[j].ID })
sort.Slice(shards, func(i, j int) bool { return shards[i].ID < shards[j].ID })
// A single-node install lists every db peer on one address; that is one
// machine, so one host carries the lot.
singleMachine := true
for _, n := range db[1:] {
if n.Host != db[0].Host {
singleMachine = false
break
}
}
dataDir := predastoreDataDir(spxRoot)
basePath := predastoreBasePath(spxRoot)
topo := &predastoreTopology{}
hostByAddr := make(map[string]int, len(db))
if singleMachine {
hostByAddr[db[0].Host] = 1
topo.Hosts = append(topo.Hosts, predastoreHost{
ID: 1,
BindAddr: fmt.Sprintf("%s:%d", db[0].Host, predastoreHostPort),
PublicAddr: fmt.Sprintf("%s:%d", db[0].Host, predastoreHostPort),
DataDir: dataDir,
})
} else {
for _, n := range db {
if _, dup := hostByAddr[n.Host]; dup {
return nil, fmt.Errorf("predastore config lists two [[db]] entries on %s; cannot derive one host per machine", n.Host)
}
hostByAddr[n.Host] = n.ID
topo.Hosts = append(topo.Hosts, predastoreHost{
ID: n.ID,
// The v4 multi-node template bound every interface; keep that
// rather than narrowing an operator's reachability on upgrade.
BindAddr: fmt.Sprintf("0.0.0.0:%d", predastoreHostPort),
PublicAddr: fmt.Sprintf("%s:%d", n.Host, predastoreHostPort),
DataDir: dataDir,
})
}
}
for _, n := range shards {
hostID, ok := hostByAddr[n.Host]
if !ok {
return nil, fmt.Errorf("predastore shard node %d is on %s, which has no [[db]] entry to place it", n.ID, n.Host)
}
topo.Nodes = append(topo.Nodes, predastoreNode{ID: n.ID, HostID: hostID, Role: predastoreRoleShard})
topo.Moves = append(topo.Moves, predastoreMove{
NodeID: n.ID,
Role: predastoreRoleShard,
From: predastoreV4Path(basePath, n.Path),
To: filepath.Join(dataDir, fmt.Sprintf("node-%d", n.ID)),
})
}
// State replica ids start above the shard range so the two never collide.
offset := shards[len(shards)-1].ID
for _, n := range db {
id := n.ID + offset
hostID := hostByAddr[n.Host]
topo.Nodes = append(topo.Nodes, predastoreNode{ID: id, HostID: hostID, Role: predastoreRoleReplica})
topo.ReplicaIDs = append(topo.ReplicaIDs, id)
topo.Moves = append(topo.Moves, predastoreMove{
NodeID: id,
Role: predastoreRoleReplica,
From: predastoreV4Path(basePath, n.Path),
To: filepath.Join(dataDir, fmt.Sprintf("node-%d", id)),
})
}
return topo, nil
}
// relocatePredastoreData moves each node's state into the v5 layout and
// rewrites the raft configuration of every state replica it moves.
//
// Only this machine's directories exist, but the config describes the whole
// cluster, so a missing source is the normal multi-node case rather than an
// error. A destination that already exists is a re-run.
func relocatePredastoreData(ctx migrate.ConfigContext, topo *predastoreTopology) error {
for _, m := range topo.Moves {
moved, err := movePredastoreNodeDir(ctx, m)
if err != nil {
return err
}
if !moved || m.Role != predastoreRoleReplica {
continue
}
// The replica set is renumbered, so the configuration persisted here
// names servers that no longer exist. Raft will not bootstrap over
// existing state to correct that; it has to be rewritten.
recovered, err := clusterrun.RecoverStateReplica(m.To, m.NodeID, topo.ReplicaIDs)
if err != nil {
return fmt.Errorf("recover raft configuration for state replica %d in %s: %w", m.NodeID, m.To, err)
}
ctx.Logger.Info("Migrated predastore state replica", "nodeID", m.NodeID, "dataDir", m.To, "raftRecovered", recovered)
}
return nil
}
// movePredastoreNodeDir moves one node directory, reporting whether the
// destination now holds state that came from a v4 layout.
func movePredastoreNodeDir(ctx migrate.ConfigContext, m predastoreMove) (bool, error) {
if _, err := os.Stat(m.From); err != nil {
if !os.IsNotExist(err) {
return false, fmt.Errorf("stat %s: %w", m.From, err)
}
// Already moved by an earlier run; the replica still needs recovering
// if that run failed before it got there.
if _, err := os.Stat(m.To); err == nil {
return true, nil
}
return false, nil
}
if _, err := os.Stat(m.To); err == nil {
return false, fmt.Errorf("cannot move %s to %s: destination already exists", m.From, m.To)
}
if err := os.MkdirAll(filepath.Dir(m.To), 0750); err != nil {
return false, fmt.Errorf("create %s: %w", filepath.Dir(m.To), err)
}
if err := os.Rename(m.From, m.To); err != nil {
return false, fmt.Errorf("move %s to %s: %w", m.From, m.To, err)
}
ctx.Logger.Info("Moved predastore node data", "nodeID", m.NodeID, "role", m.Role, "from", m.From, "to", m.To)
return true, nil
}
// rewritePredastoreConfig replaces the v4 topology tables and the gateway
// address keys with the v5 tables, leaving every other table as the operator
// left it. Re-rendering from the template would instead discard their buckets,
// credentials and rate limits.
func rewritePredastoreConfig(data []byte, topo *predastoreTopology) ([]byte, error) {
lines := strings.Split(string(data), "\n")
var out, pending []string
var table string
dropping := false
wroteTopology := false
wroteGatewayNote := false
// flush emits the buffered comment/blank block ahead of a line that keeps it.
flush := func() {
out = append(out, pending...)
pending = nil
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if m := predastoreTableHeaderRe.FindStringSubmatch(trimmed); m != nil {
table = strings.TrimSpace(m[1])
// The comment block introducing a dropped table goes with it.
if table == "db" || table == "nodes" {
pending = nil
dropping = true
if !wroteTopology {
out = append(out, predastoreTopologyBlock(topo)...)
wroteTopology = true
}
continue
}
dropping = false
flush()
out = append(out, line)
continue
}
blankOrComment := trimmed == "" || strings.HasPrefix(trimmed, "#")
if dropping {
// Buffer separators and trailing comments; a key line means the
// comments above it described the dropped table, so let them go.
if blankOrComment {
pending = append(pending, line)
} else {
pending = nil
}
continue
}
if blankOrComment {
pending = append(pending, line)
continue
}
if table == "" && predastoreTopLevelAddrRe.MatchString(trimmed) {
pending = nil
if !wroteGatewayNote {
out = append(out, predastoreGatewayNote...)
wroteGatewayNote = true
}
continue
}
flush()
out = append(out, line)
}
flush()
if !wroteTopology {
return nil, fmt.Errorf("predastore config has no [[db]] or [[nodes]] tables to replace")
}
return []byte(strings.Join(out, "\n")), nil
}
// predastoreGatewayNote replaces the top-level host/port keys, which the v5
// schema cannot accept.
var predastoreGatewayNote = []string{
"",
"# The gateway listen address comes from the daemon CLI options: a top-level",
"# host key would collide with the [[host]] topology tables below.",
}
// predastoreTopologyBlock renders the v5 tables, matching the layout the
// install templates generate so a migrated config reads like a fresh one.
func predastoreTopologyBlock(topo *predastoreTopology) []string {
out := []string{
"",
"# One host per machine: a predastore process owning a socket and a data",
"# directory. bind_addr binds every interface; public_addr is what the other",
"# hosts dial.",
}
for _, h := range topo.Hosts {
out = append(out,
"",
"[[host]]",
fmt.Sprintf("id = %d", h.ID),
fmt.Sprintf("bind_addr = %q", h.BindAddr),
fmt.Sprintf("public_addr = %q", h.PublicAddr),
fmt.Sprintf("data_dir = %q", h.DataDir),
)
}
out = append(out,
"",
"# Nodes are roles pinned to a host: shard-storage nodes hold erasure-coded",
"# object shards, state replicas form the Raft quorum over global state.",
)
for _, n := range topo.Nodes {
out = append(out,
"",
"[[node]]",
fmt.Sprintf("id = %d", n.ID),
fmt.Sprintf("host_id = %d", n.HostID),
fmt.Sprintf("role = %q", n.Role),
)
}
return out
}
// predastoreV4Path resolves a v4 node path, which is relative to the
// predastore base path unless the operator made it absolute.
func predastoreV4Path(basePath, path string) string {
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
return filepath.Join(basePath, path)
}
// predastoreBasePath mirrors SPINIFEX_PREDASTORE_BASE_PATH in
// build/systemd/spinifex-predastore.service; v4 node paths resolve against it.
func predastoreBasePath(spxRoot string) string {
return filepath.Join(spxRoot, "predastore")
}
// predastoreDataDir mirrors admin.PredastoreDataDir. Duplicated to keep the
// migrate package free of the admin dependency; if either moves, both must.
func predastoreDataDir(spxRoot string) string {
return filepath.Join(spxRoot, "predastore", "cluster")
}
@@ -0,0 +1,265 @@
package predastoretopology
import (
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mulgadc/predastore/s3"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var updateGolden = flag.Bool("update-golden", false, "rewrite the predastore migration golden files")
// predastoreFixture lays out a v4 install under a temporary root: the config
// in place, and a data directory for each node this machine owns.
func predastoreFixture(t *testing.T, name string, nodeDirs []string) (configDir, dataDir string) {
t.Helper()
root := t.TempDir()
configDir = filepath.Join(root, "etc")
dataDir = filepath.Join(root, "var")
require.NoError(t, os.MkdirAll(filepath.Join(configDir, "predastore"), 0750))
src, err := os.ReadFile(filepath.Join("testdata", name))
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(configDir, predastoreRelPath), src, 0640))
for _, dir := range nodeDirs {
full := filepath.Join(dataDir, "predastore", dir)
require.NoError(t, os.MkdirAll(full, 0750))
// A marker per directory proves the contents travelled, not just the path.
require.NoError(t, os.WriteFile(filepath.Join(full, "marker"), []byte(dir), 0640))
}
return configDir, dataDir
}
// runPredastoreMigration drives the real registry path, so registration, the
// backup and the version stamp are covered alongside the rewrite itself.
func runPredastoreMigration(t *testing.T, configDir, dataDir string) {
t.Helper()
require.NoError(t, migrate.DefaultRegistry.RunConfig(predastoreTarget, configDir, dataDir))
}
// dataDirPlaceholder stands in for the test's temporary root, which is
// rendered into data_dir and cannot be checked in.
const dataDirPlaceholder = "{{DATA_DIR}}"
// assertGolden compares the migrated config against its golden file, which is
// checked in so a review sees the exact config an operator ends up with.
func assertGolden(t *testing.T, configDir, dataDir, golden string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join(configDir, predastoreRelPath))
require.NoError(t, err)
got := strings.ReplaceAll(string(raw), dataDir, dataDirPlaceholder)
path := filepath.Join("testdata", golden)
if *updateGolden {
require.NoError(t, os.WriteFile(path, []byte(got), 0640))
return
}
want, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, string(want), got)
}
// readMigratedConfig parses the migrated config the way predastore does, so
// the test fails on anything predastore would reject rather than on text.
func readMigratedConfig(t *testing.T, configDir, dataDir string) *s3.Config {
t.Helper()
cfg := &s3.Config{
ConfigPath: filepath.Join(configDir, predastoreRelPath),
BasePath: filepath.Join(dataDir, "predastore"),
}
require.NoError(t, cfg.ReadConfig())
return cfg
}
// TestPredastoreTopologySingleNode covers the common install: three db peers
// and three shard nodes on one machine collapse to one host running all six.
func TestPredastoreTopologySingleNode(t *testing.T) {
configDir, dataDir := predastoreFixture(t, "predastore_v4_singlenode.toml", []string{
"distributed/db/node-1", "distributed/db/node-2", "distributed/db/node-3",
"distributed/nodes/node-1", "distributed/nodes/node-2", "distributed/nodes/node-3",
})
runPredastoreMigration(t, configDir, dataDir)
assertGolden(t, configDir, dataDir, "predastore_v5_singlenode.golden")
cfg := readMigratedConfig(t, configDir, dataDir)
require.Len(t, cfg.Hosts, 1)
assert.Equal(t, 1, cfg.Hosts[0].ID)
assert.Equal(t, "127.0.0.1:6660", cfg.Hosts[0].BindAddr)
assert.Equal(t, "127.0.0.1:6660", cfg.Hosts[0].PublicAddr)
assert.Equal(t, filepath.Join(dataDir, "predastore", "cluster"), cfg.Hosts[0].DataDir)
require.Len(t, cfg.ClusterNodes, 6)
for i, n := range cfg.ClusterNodes {
assert.Equal(t, i+1, n.ID)
assert.Equal(t, 1, n.HostID)
if i < 3 {
assert.Equal(t, "shard-storage", string(n.Role))
} else {
assert.Equal(t, "state-replica", string(n.Role))
}
}
// Shard nodes keep their ids because object metadata records which node
// holds each shard; the state replicas are the ones that move.
cluster := filepath.Join(dataDir, "predastore", "cluster")
assertNodeDirContents(t, cluster, map[int]string{
1: "distributed/nodes/node-1",
2: "distributed/nodes/node-2",
3: "distributed/nodes/node-3",
4: "distributed/db/node-1",
5: "distributed/db/node-2",
6: "distributed/db/node-3",
})
assert.NoDirExists(t, filepath.Join(dataDir, "predastore", "distributed", "db", "node-1"))
assert.NoDirExists(t, filepath.Join(dataDir, "predastore", "distributed", "nodes", "node-1"))
// Everything outside the topology is the operator's, and must survive.
raw, err := os.ReadFile(filepath.Join(configDir, predastoreRelPath))
require.NoError(t, err)
for _, keep := range []string{
`[ratelimit.action."s3:PutObject"]`,
"access_keys_bucket = \"spinifex-iam-access-keys\"",
"interval_seconds = 300",
`{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject",`,
} {
assert.Contains(t, string(raw), keep)
}
assert.NotContains(t, string(raw), "\nhost = \"0.0.0.0\"")
assert.NotContains(t, string(raw), "[[db]]")
assert.NotContains(t, string(raw), "[[nodes]]")
}
// TestPredastoreTopologyMultiNode covers a three-machine cluster from the
// perspective of one member, which only holds its own node directories.
func TestPredastoreTopologyMultiNode(t *testing.T) {
configDir, dataDir := predastoreFixture(t, "predastore_v4_multinode.toml", []string{
"distributed/db/node-2", "distributed/nodes/node-2",
})
runPredastoreMigration(t, configDir, dataDir)
assertGolden(t, configDir, dataDir, "predastore_v5_multinode.golden")
cfg := readMigratedConfig(t, configDir, dataDir)
require.Len(t, cfg.Hosts, 3)
for i, h := range cfg.Hosts {
assert.Equal(t, i+1, h.ID)
// Binding every interface preserves the v4 template's reachability;
// only the advertised address is per machine.
assert.Equal(t, "0.0.0.0:6660", h.BindAddr)
assert.Equal(t, fmt.Sprintf("10.0.0.%d:6660", h.ID), h.PublicAddr)
assert.Equal(t, filepath.Join(dataDir, "predastore", "cluster"), h.DataDir)
}
require.Len(t, cfg.ClusterNodes, 6)
byID := map[int]struct {
host int
role string
}{}
for _, n := range cfg.ClusterNodes {
byID[n.ID] = struct {
host int
role string
}{n.HostID, string(n.Role)}
}
for id, want := range map[int]struct {
host int
role string
}{
1: {1, "shard-storage"}, 2: {2, "shard-storage"}, 3: {3, "shard-storage"},
4: {1, "state-replica"}, 5: {2, "state-replica"}, 6: {3, "state-replica"},
} {
assert.Equal(t, want, byID[id], "node %d", id)
}
// Only this machine's directories exist; the rest belong to its peers.
cluster := filepath.Join(dataDir, "predastore", "cluster")
assertNodeDirContents(t, cluster, map[int]string{
2: "distributed/nodes/node-2",
5: "distributed/db/node-2",
})
assert.NoDirExists(t, filepath.Join(cluster, "node-1"))
assert.NoDirExists(t, filepath.Join(cluster, "node-4"))
}
// TestPredastoreTopologyRerunIsNoop covers a run that failed after writing the
// config but before the version was stamped: the migration is invoked directly,
// so the version gate cannot mask a missing guard on the already-migrated file.
func TestPredastoreTopologyRerunIsNoop(t *testing.T) {
configDir, dataDir := predastoreFixture(t, "predastore_v4_singlenode.toml", []string{
"distributed/db/node-1", "distributed/nodes/node-1",
})
ctx := migrate.ConfigContext{ConfigDir: configDir, DataDir: dataDir, Logger: slog.Default()}
require.NoError(t, migratePredastoreTopology(ctx))
first, err := os.ReadFile(filepath.Join(configDir, predastoreRelPath))
require.NoError(t, err)
require.NoError(t, migratePredastoreTopology(ctx))
second, err := os.ReadFile(filepath.Join(configDir, predastoreRelPath))
require.NoError(t, err)
assert.Equal(t, string(first), string(second))
assertNodeDirContents(t, filepath.Join(dataDir, "predastore", "cluster"), map[int]string{
1: "distributed/nodes/node-1",
4: "distributed/db/node-1",
})
}
// TestPredastoreTopologyRejectsNonV4Config fails loudly rather than writing a
// config with no topology at all.
func TestPredastoreTopologyRejectsNonV4Config(t *testing.T) {
configDir := filepath.Join(t.TempDir(), "etc")
require.NoError(t, os.MkdirAll(filepath.Join(configDir, "predastore"), 0750))
require.NoError(t, os.WriteFile(filepath.Join(configDir, predastoreRelPath),
[]byte("version = \"4\"\nregion = \"ap-southeast-2\"\n"), 0640))
err := migratePredastoreTopology(migrate.ConfigContext{
ConfigDir: configDir,
DataDir: t.TempDir(),
Logger: slog.Default(),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "not a v4 distributed topology")
}
// TestPredastoreVersionReaderReadsFixtures ties the migration's FromVersion to
// what the reader actually finds in a shipped config.
func TestPredastoreVersionReaderReadsFixtures(t *testing.T) {
r := &migrate.TOMLVersionReader{}
for _, name := range []string{"predastore_v4_singlenode.toml", "predastore_v4_multinode.toml"} {
v, err := r.ReadVersion(filepath.Join("testdata", name))
require.NoError(t, err)
assert.Equal(t, 4, v, name)
}
}
// assertNodeDirContents checks that each node directory holds the marker
// written into the v4 directory it came from.
func assertNodeDirContents(t *testing.T, clusterDir string, want map[int]string) {
t.Helper()
for id, origin := range want {
marker := filepath.Join(clusterDir, fmt.Sprintf("node-%d", id), "marker")
got, err := os.ReadFile(marker)
if !assert.NoError(t, err, "node-%d", id) {
continue
}
assert.Equal(t, origin, string(got), "node-%d", id)
}
}
@@ -0,0 +1,97 @@
# Predastore S3-compatible storage configuration file in TOML format.
# Generated for multi-node cluster
version = "4"
region = "ap-southeast-2"
host = "0.0.0.0"
port = 8443
debug = false
[rs]
data = 2
parity = 1
[[db]]
id = 1
host = "10.0.0.1"
port = 6660
path = "distributed/db/node-1/"
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
leader = true
[[db]]
id = 2
host = "10.0.0.2"
port = 6660
path = "distributed/db/node-2/"
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
[[db]]
id = 3
host = "10.0.0.3"
port = 6660
path = "distributed/db/node-3/"
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
[[nodes]]
id = 1
host = "10.0.0.1"
port = 9991
path = "distributed/nodes/node-1/"
[[nodes]]
id = 2
host = "10.0.0.2"
port = 9991
path = "distributed/nodes/node-2/"
[[nodes]]
id = 3
host = "10.0.0.3"
port = 9991
path = "distributed/nodes/node-3/"
[[buckets]]
name = "predastore"
region = "ap-southeast-2"
type = "distributed"
public = false
encryption = ""
account_id = "000000000000"
# Northstar DNS zone storage. Read-only for the northstar service; the control
# plane writes zone files with the system credentials.
[[buckets]]
name = "northstar-zones"
region = "ap-southeast-2"
type = "distributed"
public = false
encryption = ""
account_id = "000000000000"
[[auth]]
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
account_id = "000000000000"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
{ bucket = "northstar-zones", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"] },
]
# API request throttling (per-account, per-action token bucket)
[ratelimit]
enabled = false
rate = 100 # Default sustained requests per second per account per action
burst = 500 # Default maximum burst capacity
# IAM authentication via NATS KV (enables multi-account S3 access)
[iam]
nats_url = "nats://10.0.0.2:4222"
nats_token = "s3cr3t"
nats_ca_cert = "/etc/spinifex/ca.pem"
master_key_path = "/etc/spinifex/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
@@ -0,0 +1,109 @@
# Predastore S3-compatible storage configuration file in TOML format.
# MULTI NODE used for testing
version = "4"
region = "ap-southeast-2"
# Optionally configure port and host, or specify via daemon CLI options
host = "0.0.0.0"
port = 8443
# Enable verbose debugging
debug = false
# Optionally configure base-path if full directory path not defined for "fs" buckets
# base_path = "/home/predastore/data/"
# Specify reed solomon encoding -
# RS(2,1), 2 data, 1 parity. Minimum
# RS(3,2), 3 data, 2 parity. Recommended for production
[rs]
data = 2
parity = 1
[[db]]
id = 1
host = "127.0.0.1"
port = 6660
#raft_port = 7660 # Optional: Raft consensus port (default: port + 1000)
#advertise_host = "" # Optional: Address to advertise to other Raft nodes
path = "distributed/db/node-1/"
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
leader = true # Bootstrap leader hint
[[db]]
id = 2
host = "127.0.0.1"
port = 6661
path = "distributed/db/node-2/"
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
[[db]]
id = 3
host = "127.0.0.1"
port = 6662
path = "distributed/db/node-3/"
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
# Shard nodes
# Using distributed hash ring for object > shard placement.
[[nodes]]
id = 1
host = "127.0.0.1"
port = 9991
path = "distributed/nodes/node-1/"
[[nodes]]
id = 2
host = "127.0.0.1"
port = 9992
path = "distributed/nodes/node-2/"
[[nodes]]
id = 3
host = "127.0.0.1"
port = 9993
path = "distributed/nodes/node-3/"
[[buckets]]
name = "predastore"
region = "ap-southeast-2"
type = "distributed"
public = false
encryption = ""
account_id = "000000000000"
[[auth]]
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
account_id = "000000000000"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
# API request throttling (per-account, per-action token bucket)
[ratelimit]
enabled = false
rate = 100 # Default sustained requests per second per account per action
burst = 500 # Default maximum burst capacity
[ratelimit.action."s3:PutObject"]
rate = 50
burst = 200
[ratelimit.action."s3:DeleteObject"]
rate = 50
burst = 200
# IAM authentication via NATS KV (enables multi-account S3 access)
[iam]
nats_url = "nats://localhost:4222"
nats_token = "s3cr3t"
nats_ca_cert = "/etc/spinifex/ca.pem"
master_key_path = "/etc/spinifex/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
[compaction]
interval_seconds = 300
@@ -0,0 +1,109 @@
# Predastore S3-compatible storage configuration file in TOML format.
# Generated for multi-node cluster
version = "5"
region = "ap-southeast-2"
# The gateway listen address comes from the daemon CLI options: a top-level
# host key would collide with the [[host]] topology tables below.
debug = false
[rs]
data = 2
parity = 1
# One host per machine: a predastore process owning a socket and a data
# directory. bind_addr binds every interface; public_addr is what the other
# hosts dial.
[[host]]
id = 1
bind_addr = "0.0.0.0:6660"
public_addr = "10.0.0.1:6660"
data_dir = "{{DATA_DIR}}/predastore/cluster"
[[host]]
id = 2
bind_addr = "0.0.0.0:6660"
public_addr = "10.0.0.2:6660"
data_dir = "{{DATA_DIR}}/predastore/cluster"
[[host]]
id = 3
bind_addr = "0.0.0.0:6660"
public_addr = "10.0.0.3:6660"
data_dir = "{{DATA_DIR}}/predastore/cluster"
# Nodes are roles pinned to a host: shard-storage nodes hold erasure-coded
# object shards, state replicas form the Raft quorum over global state.
[[node]]
id = 1
host_id = 1
role = "shard-storage"
[[node]]
id = 2
host_id = 2
role = "shard-storage"
[[node]]
id = 3
host_id = 3
role = "shard-storage"
[[node]]
id = 4
host_id = 1
role = "state-replica"
[[node]]
id = 5
host_id = 2
role = "state-replica"
[[node]]
id = 6
host_id = 3
role = "state-replica"
[[buckets]]
name = "predastore"
region = "ap-southeast-2"
type = "distributed"
public = false
encryption = ""
account_id = "000000000000"
# Northstar DNS zone storage. Read-only for the northstar service; the control
# plane writes zone files with the system credentials.
[[buckets]]
name = "northstar-zones"
region = "ap-southeast-2"
type = "distributed"
public = false
encryption = ""
account_id = "000000000000"
[[auth]]
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
account_id = "000000000000"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
{ bucket = "northstar-zones", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"] },
]
# API request throttling (per-account, per-action token bucket)
[ratelimit]
enabled = false
rate = 100 # Default sustained requests per second per account per action
burst = 500 # Default maximum burst capacity
# IAM authentication via NATS KV (enables multi-account S3 access)
[iam]
nats_url = "nats://10.0.0.2:4222"
nats_token = "s3cr3t"
nats_ca_cert = "/etc/spinifex/ca.pem"
master_key_path = "/etc/spinifex/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
@@ -0,0 +1,104 @@
# Predastore S3-compatible storage configuration file in TOML format.
# MULTI NODE used for testing
version = "5"
region = "ap-southeast-2"
# The gateway listen address comes from the daemon CLI options: a top-level
# host key would collide with the [[host]] topology tables below.
# Enable verbose debugging
debug = false
# Optionally configure base-path if full directory path not defined for "fs" buckets
# base_path = "/home/predastore/data/"
# Specify reed solomon encoding -
# RS(2,1), 2 data, 1 parity. Minimum
# RS(3,2), 3 data, 2 parity. Recommended for production
[rs]
data = 2
parity = 1
# One host per machine: a predastore process owning a socket and a data
# directory. bind_addr binds every interface; public_addr is what the other
# hosts dial.
[[host]]
id = 1
bind_addr = "127.0.0.1:6660"
public_addr = "127.0.0.1:6660"
data_dir = "{{DATA_DIR}}/predastore/cluster"
# Nodes are roles pinned to a host: shard-storage nodes hold erasure-coded
# object shards, state replicas form the Raft quorum over global state.
[[node]]
id = 1
host_id = 1
role = "shard-storage"
[[node]]
id = 2
host_id = 1
role = "shard-storage"
[[node]]
id = 3
host_id = 1
role = "shard-storage"
[[node]]
id = 4
host_id = 1
role = "state-replica"
[[node]]
id = 5
host_id = 1
role = "state-replica"
[[node]]
id = 6
host_id = 1
role = "state-replica"
[[buckets]]
name = "predastore"
region = "ap-southeast-2"
type = "distributed"
public = false
encryption = ""
account_id = "000000000000"
[[auth]]
access_key_id = "AKIAIOSFODNN7EXAMPLE"
secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
account_id = "000000000000"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
# API request throttling (per-account, per-action token bucket)
[ratelimit]
enabled = false
rate = 100 # Default sustained requests per second per account per action
burst = 500 # Default maximum burst capacity
[ratelimit.action."s3:PutObject"]
rate = 50
burst = 200
[ratelimit.action."s3:DeleteObject"]
rate = 50
burst = 200
# IAM authentication via NATS KV (enables multi-account S3 access)
[iam]
nats_url = "nats://localhost:4222"
nats_token = "s3cr3t"
nats_ca_cert = "/etc/spinifex/ca.pem"
master_key_path = "/etc/spinifex/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
[compaction]
interval_seconds = 300
-1
View File
@@ -26,7 +26,6 @@ type Config struct {
ConfigPath string
// BasePath is the node base dir where the PID file is written.
BasePath string
NodeID int
NatsHost string
NatsToken string
NatsCACert string
+108 -22
View File
@@ -6,11 +6,15 @@ import (
"log/slog"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/mulgadc/predastore/clusterrun"
"github.com/mulgadc/predastore/pkg/masterkey"
"github.com/mulgadc/predastore/s3"
"github.com/mulgadc/spinifex/spinifex/utils"
"golang.org/x/sync/errgroup"
)
var serviceName = "predastore"
@@ -30,17 +34,27 @@ type Config struct {
// fragments are only ever opened on the node that sealed them.
EncryptionKeyFile string
NodeID int
// HostID selects which host of the predastore topology this process is.
// A host owns one socket and one data directory, and runs the nodes
// pinned to it. Zero runs every node in the topology in this process,
// which is the single-node deployment and needs no intra-cluster network.
HostID int
// Profiling
PprofEnabled bool
PprofOutputPath string
}
// Service wraps the predastore S3 server.
// Service wraps the predastore S3 gateway and the cluster runtime beneath it.
type Service struct {
Config *Config
server *s3.Server
// stop cancels the process context Start runs under, so Shutdown can
// drain the gateway and the nodes together. Guarded because Shutdown is
// called from a different goroutine than Start.
mu sync.Mutex
stop context.CancelFunc
}
// New creates a new predastore service.
@@ -65,42 +79,109 @@ func (svc *Service) Start() (int, error) {
return 0, fmt.Errorf("write pid file: %w", err)
}
rt, err := svc.buildRuntime()
if err != nil {
return 0, err
}
server, err := s3.NewServer(
s3.WithConfigPath(svc.Config.ConfigPath),
s3.WithAddress(svc.Config.Host, svc.Config.Port),
s3.WithTLS(svc.Config.TlsCert, svc.Config.TlsKey),
s3.WithBasePath(svc.Config.BasePath),
s3.WithDebug(svc.Config.Debug),
s3.WithNodeID(svc.Config.NodeID),
s3.WithPprof(svc.Config.PprofEnabled, svc.Config.PprofOutputPath),
s3.WithEncryptionKeyFile(svc.Config.EncryptionKeyFile),
s3.WithPreparedBackend(rt.Backend),
)
if err != nil {
rt.Close()
slog.Error("Failed to create predastore server", "error", err)
return 0, err
}
svc.server = server
// One context for the whole service: a signal, a fatal gateway error or
// Shutdown all stop the cluster nodes and the gateway together.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := server.ListenAndServeAsync(); err != nil {
slog.Error("Failed to start predastore server", "error", err)
svc.mu.Lock()
svc.server = server
svc.stop = stop
svc.mu.Unlock()
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return rt.Run(gctx) })
g.Go(func() error {
// Serving before consensus settles would fail writes that would have
// succeeded a moment later; a timeout degrades rather than aborts,
// since the state client retries.
if err := rt.WaitReady(30 * time.Second); err != nil {
slog.Warn("No predastore leader elected within timeout, serving anyway", "error", err)
}
return svc.serve(gctx, server)
})
if err := g.Wait(); err != nil {
slog.Error("Predastore service exited", "error", err)
return 0, err
}
// Wait for shutdown signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
return os.Getpid(), nil
}
slog.Info("Shutting down predastore service")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("Error during shutdown", "error", err)
// buildRuntime reads the predastore topology and assembles the cluster nodes
// this process is responsible for, returning the backend the S3 gateway runs
// on top of.
func (svc *Service) buildRuntime() (*clusterrun.Runtime, error) {
cfg := &s3.Config{ConfigPath: svc.Config.ConfigPath, BasePath: svc.Config.BasePath}
if err := cfg.ReadConfig(); err != nil {
return nil, fmt.Errorf("read predastore config %s: %w", svc.Config.ConfigPath, err)
}
return os.Getpid(), nil
// An unset host means this process is the whole cluster, so it runs every
// node over the in-process pipe and opens no intra-cluster socket.
nodeIDs := clusterrun.AllNodeIDs(cfg)
if svc.Config.HostID > 0 {
nodeIDs = clusterrun.NodeIDsForHost(cfg, svc.Config.HostID)
if len(nodeIDs) == 0 {
return nil, fmt.Errorf("predastore host %d has no nodes in %s", svc.Config.HostID, svc.Config.ConfigPath)
}
}
key, err := masterkey.Load(svc.Config.EncryptionKeyFile)
if err != nil {
return nil, fmt.Errorf("load predastore master key: %w", err)
}
rt, err := clusterrun.Build(cfg, nodeIDs, svc.Config.TlsCert, svc.Config.TlsKey, key)
if err != nil {
return nil, fmt.Errorf("build predastore cluster runtime: %w", err)
}
return rt, nil
}
// serve runs the S3 gateway until the context is cancelled, then drains it
// within a bounded grace period. A gateway that cannot bind is fatal: leaving
// the cluster running headless would look healthy while serving nothing.
func (svc *Service) serve(ctx context.Context, server *s3.Server) error {
if err := server.ListenAndServeAsync(); err != nil {
return fmt.Errorf("start predastore s3 gateway: %w", err)
}
select {
case <-ctx.Done():
case err := <-server.ServeError():
return fmt.Errorf("predastore s3 gateway: %w", err)
}
slog.Info("Shutting down predastore service")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shut down predastore s3 gateway: %w", err)
}
return nil
}
// Stop stops the predastore service.
@@ -113,12 +194,17 @@ func (svc *Service) Status() (string, error) {
return utils.ServiceStatus(svc.Config.BasePath, serviceName)
}
// Shutdown gracefully shuts down the predastore service.
// Shutdown gracefully shuts down the predastore service. When this process is
// the one running the cluster it cancels the service context, which drains the
// gateway and every local node; otherwise it signals the running process.
func (svc *Service) Shutdown() error {
if svc.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return svc.server.Shutdown(ctx)
svc.mu.Lock()
stop := svc.stop
svc.mu.Unlock()
if stop != nil {
stop()
return nil
}
return svc.Stop()
}
@@ -0,0 +1,504 @@
package predastore
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/mulgadc/predastore/clusterrun"
"github.com/mulgadc/predastore/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
roleShardStorage = "shard-storage"
roleStateReplica = "state-replica"
)
// nodeIDSeq hands out node ids that are unique within this test binary. The
// pipe transport's listener registry is process-wide, and this package's
// integration tests hold a fixture daemon on the low ids for the life of the
// binary, so topologies here must not reuse them.
var nodeIDSeq atomic.Int64
func nextNodeIDs(n int) []int {
ids := make([]int, n)
for i := range ids {
ids[i] = 100 + int(nodeIDSeq.Add(1))
}
return ids
}
// hostSpec and nodeSpec are one entry each of a topology's [[host]] and
// [[node]] tables.
type hostSpec struct {
id int
dataDir string
}
type nodeSpec struct {
id int
hostID int
role string
}
// writeTopology writes a predastore config for hosts and nodes, returning its
// path. Hosts bind an ephemeral port so several topologies can coexist in one
// test binary.
func writeTopology(t *testing.T, dir string, hosts []hostSpec, nodes []nodeSpec) string {
t.Helper()
var b strings.Builder
fmt.Fprintf(&b, "version = \"1.0\"\nregion = \"us-east-1\"\nbase_path = %q\n\n[rs]\ndata = 1\nparity = 1\n", dir)
for _, h := range hosts {
fmt.Fprintf(&b, "\n[[host]]\nid = %d\nbind_addr = \"127.0.0.1:0\"\npublic_addr = \"127.0.0.1:0\"\ndata_dir = %q\n", h.id, h.dataDir)
}
for _, n := range nodes {
fmt.Fprintf(&b, "\n[[node]]\nid = %d\nhost_id = %d\nrole = %q\n", n.id, n.hostID, n.role)
}
path := filepath.Join(dir, "predastore.toml")
require.NoError(t, os.WriteFile(path, []byte(b.String()), 0600))
return path
}
// writeMasterKey writes a 32-byte master key at mode. The mode matters:
// masterkey.Load is fail-closed on anything group- or other-readable.
func writeMasterKey(t *testing.T, dir string, mode os.FileMode) string {
t.Helper()
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
path := filepath.Join(dir, "master.key")
require.NoError(t, os.WriteFile(path, key, mode))
return path
}
// writeCertificate writes a self-signed P-256 certificate serving both the
// intra-cluster QUIC socket and the S3 gateway. Nothing verifies it: the peers
// are this process and the tests' own dialers.
func writeCertificate(t *testing.T, dir string) (certPath, keyPath string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
require.NoError(t, err)
certPath = filepath.Join(dir, "tls.crt")
require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600))
keyDER, err := x509.MarshalECPrivateKey(priv)
require.NoError(t, err)
keyPath = filepath.Join(dir, "tls.key")
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0600))
return certPath, keyPath
}
// freePort reserves and releases a loopback port, so a gateway under test
// binds somewhere the fixture daemon and other packages are not.
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
// newTwoHostConfig returns the config for the first host of a two-host
// topology: the shard stores run here, the state replica belongs to the other
// host's process. Returns the local node ids and the remote one.
func newTwoHostConfig(t *testing.T) (cfg *Config, local []int, remote int) {
t.Helper()
dir := t.TempDir()
certPath, keyPath := writeCertificate(t, dir)
ids := nextNodeIDs(3)
hosts := []hostSpec{
{id: 1, dataDir: filepath.Join(dir, "host-1")},
{id: 2, dataDir: filepath.Join(dir, "host-2")},
}
nodes := []nodeSpec{
{id: ids[0], hostID: 1, role: roleShardStorage},
{id: ids[1], hostID: 1, role: roleShardStorage},
{id: ids[2], hostID: 2, role: roleStateReplica},
}
cfg = &Config{
ConfigPath: writeTopology(t, dir, hosts, nodes),
Host: "127.0.0.1",
Port: freePort(t),
BasePath: dir,
TlsCert: certPath,
TlsKey: keyPath,
EncryptionKeyFile: writeMasterKey(t, dir, 0600),
HostID: 1,
}
return cfg, ids[:2], ids[2]
}
// newSingleHostConfig returns the single-node deployment's config: one host
// running every node in this process, over the in-process pipe.
func newSingleHostConfig(t *testing.T) *Config {
t.Helper()
dir := t.TempDir()
certPath, keyPath := writeCertificate(t, dir)
ids := nextNodeIDs(3)
hosts := []hostSpec{{id: 1, dataDir: filepath.Join(dir, "host-1")}}
nodes := []nodeSpec{
{id: ids[0], hostID: 1, role: roleShardStorage},
{id: ids[1], hostID: 1, role: roleShardStorage},
{id: ids[2], hostID: 1, role: roleStateReplica},
}
// HostID is left zero: this process is the whole cluster.
return &Config{
ConfigPath: writeTopology(t, dir, hosts, nodes),
Host: "127.0.0.1",
Port: freePort(t),
BasePath: dir,
TlsCert: certPath,
TlsKey: keyPath,
EncryptionKeyFile: writeMasterKey(t, dir, 0600),
}
}
// newGateway builds the S3 server serve runs, on top of an already-built
// cluster runtime.
func newGateway(t *testing.T, cfg *Config, rt *clusterrun.Runtime) *s3.Server {
t.Helper()
server, err := s3.NewServer(
s3.WithConfigPath(cfg.ConfigPath),
s3.WithAddress(cfg.Host, cfg.Port),
s3.WithTLS(cfg.TlsCert, cfg.TlsKey),
s3.WithBasePath(cfg.BasePath),
s3.WithEncryptionKeyFile(cfg.EncryptionKeyFile),
s3.WithPreparedBackend(rt.Backend),
)
require.NoError(t, err)
return server
}
// requireGatewayAccepts waits for the S3 gateway to accept connections, which
// is also the point at which Start has published the cancel func Shutdown
// needs.
func requireGatewayAccepts(t *testing.T, cfg *Config) {
t.Helper()
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
require.Eventually(t, func() bool {
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
return false
}
conn.Close()
return true
}, 30*time.Second, 100*time.Millisecond, "gateway never accepted connections on %s", addr)
}
// TestBuildRuntimeErrors covers every way assembling the cluster runtime can
// fail before a single node is running.
func TestBuildRuntimeErrors(t *testing.T) {
testCases := []struct {
name string
config func(t *testing.T, dir string) *Config
wantErr string
}{
{
name: "Unreadable Config",
config: func(t *testing.T, dir string) *Config {
return &Config{
ConfigPath: filepath.Join(dir, "absent.toml"),
BasePath: dir,
EncryptionKeyFile: writeMasterKey(t, dir, 0600),
}
},
wantErr: "read predastore config",
},
{
name: "Host Owns No Nodes",
config: func(t *testing.T, dir string) *Config {
ids := nextNodeIDs(1)
return &Config{
ConfigPath: writeTopology(t, dir,
[]hostSpec{{id: 1, dataDir: filepath.Join(dir, "host-1")}},
[]nodeSpec{{id: ids[0], hostID: 1, role: roleStateReplica}}),
BasePath: dir,
EncryptionKeyFile: writeMasterKey(t, dir, 0600),
HostID: 9,
}
},
wantErr: "predastore host 9 has no nodes in",
},
{
name: "Missing Master Key",
config: func(t *testing.T, dir string) *Config {
ids := nextNodeIDs(1)
return &Config{
ConfigPath: writeTopology(t, dir,
[]hostSpec{{id: 1, dataDir: filepath.Join(dir, "host-1")}},
[]nodeSpec{{id: ids[0], hostID: 1, role: roleStateReplica}}),
BasePath: dir,
EncryptionKeyFile: filepath.Join(dir, "absent.key"),
}
},
wantErr: "load predastore master key",
},
{
name: "World Readable Master Key",
config: func(t *testing.T, dir string) *Config {
ids := nextNodeIDs(1)
return &Config{
ConfigPath: writeTopology(t, dir,
[]hostSpec{{id: 1, dataDir: filepath.Join(dir, "host-1")}},
[]nodeSpec{{id: ids[0], hostID: 1, role: roleStateReplica}}),
BasePath: dir,
EncryptionKeyFile: writeMasterKey(t, dir, 0644),
}
},
wantErr: "load predastore master key",
},
{
name: "Remote Nodes Without TLS Material",
config: func(t *testing.T, dir string) *Config {
ids := nextNodeIDs(2)
return &Config{
ConfigPath: writeTopology(t, dir,
[]hostSpec{
{id: 1, dataDir: filepath.Join(dir, "host-1")},
{id: 2, dataDir: filepath.Join(dir, "host-2")},
},
[]nodeSpec{
{id: ids[0], hostID: 1, role: roleShardStorage},
{id: ids[1], hostID: 2, role: roleStateReplica},
}),
BasePath: dir,
EncryptionKeyFile: writeMasterKey(t, dir, 0600),
HostID: 1,
// TlsCert and TlsKey deliberately left empty: reaching the
// other host needs the intra-cluster socket.
}
},
wantErr: "build predastore cluster runtime",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
svc, err := New(tc.config(t, t.TempDir()))
require.NoError(t, err)
rt, err := svc.buildRuntime()
require.Error(t, err)
assert.Nil(t, rt)
assert.Contains(t, err.Error(), tc.wantErr)
})
}
}
// TestBuildRuntimeRunsOnlyThisHostsNodes verifies host selection: a host runs
// the nodes pinned to it and leaves the rest to the processes that own them.
func TestBuildRuntimeRunsOnlyThisHostsNodes(t *testing.T) {
cfg, local, remote := newTwoHostConfig(t)
svc, err := New(cfg)
require.NoError(t, err)
rt, err := svc.buildRuntime()
require.NoError(t, err)
t.Cleanup(rt.Close)
assert.NotNil(t, rt.Backend)
for _, id := range local {
assert.DirExists(t, filepath.Join(cfg.BasePath, "host-1", fmt.Sprintf("node-%d", id)))
}
assert.NoDirExists(t, filepath.Join(cfg.BasePath, "host-2", fmt.Sprintf("node-%d", remote)))
}
// TestServeReportsGatewayFailures covers the two ways the S3 gateway can fail
// the service: refusing to start, and dying once started.
func TestServeReportsGatewayFailures(t *testing.T) {
testCases := []struct {
name string
tls func(cfg *Config)
wantErr string
}{
{
name: "Missing TLS Material",
tls: func(cfg *Config) { cfg.TlsCert, cfg.TlsKey = "", "" },
wantErr: "start predastore s3 gateway",
},
{
name: "Unloadable Certificate",
tls: func(cfg *Config) {
cfg.TlsCert = filepath.Join(cfg.BasePath, "absent.crt")
cfg.TlsKey = filepath.Join(cfg.BasePath, "absent.key")
},
wantErr: "predastore s3 gateway",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cfg, _, _ := newTwoHostConfig(t)
svc, err := New(cfg)
require.NoError(t, err)
rt, err := svc.buildRuntime()
require.NoError(t, err)
t.Cleanup(rt.Close)
// The runtime is built first: reaching the other host's node needs
// the certificate this case is about to take away.
tc.tls(cfg)
gateway := newGateway(t, cfg, rt)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err = svc.serve(ctx, gateway)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
})
}
}
// TestServeDrainsOnContextCancel verifies the gateway stops serving when the
// service context is cancelled, rather than outliving the cluster beneath it.
func TestServeDrainsOnContextCancel(t *testing.T) {
cfg, _, _ := newTwoHostConfig(t)
svc, err := New(cfg)
require.NoError(t, err)
rt, err := svc.buildRuntime()
require.NoError(t, err)
t.Cleanup(rt.Close)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
gateway := newGateway(t, cfg, rt)
served := make(chan error, 1)
go func() { served <- svc.serve(ctx, gateway) }()
requireGatewayAccepts(t, cfg)
cancel()
select {
case err := <-served:
assert.NoError(t, err)
case <-time.After(30 * time.Second):
t.Fatal("serve did not return after the context was cancelled")
}
}
// TestStartFailsWhenGatewayCannotServe verifies a fatal gateway error takes
// the whole service down: a cluster left running headless would look healthy
// while serving nothing.
func TestStartFailsWhenGatewayCannotServe(t *testing.T) {
cfg := newSingleHostConfig(t)
cfg.TlsCert = filepath.Join(cfg.BasePath, "absent.crt")
cfg.TlsKey = filepath.Join(cfg.BasePath, "absent.key")
svc, err := New(cfg)
require.NoError(t, err)
type result struct {
pid int
err error
}
done := make(chan result, 1)
go func() {
pid, err := svc.Start()
done <- result{pid: pid, err: err}
}()
select {
case res := <-done:
require.Error(t, res.err)
assert.Zero(t, res.pid)
assert.Contains(t, res.err.Error(), "predastore s3 gateway")
case <-time.After(60 * time.Second):
t.Fatal("Start did not return after the gateway failed")
}
}
// TestStartServesUntilShutdown covers the service lifecycle end to end: Start
// brings the cluster nodes and the gateway up together, and Shutdown drains
// both by cancelling the context they share.
func TestStartServesUntilShutdown(t *testing.T) {
cfg := newSingleHostConfig(t)
svc, err := New(cfg)
require.NoError(t, err)
type result struct {
pid int
err error
}
done := make(chan result, 1)
go func() {
pid, err := svc.Start()
done <- result{pid: pid, err: err}
}()
// Shutdown must not run before Start publishes its cancel func, or it
// falls through to the pid file and signals this test binary.
requireGatewayAccepts(t, cfg)
require.NoError(t, svc.Shutdown())
select {
case res := <-done:
require.NoError(t, res.err)
assert.Equal(t, os.Getpid(), res.pid)
case <-time.After(60 * time.Second):
t.Fatal("Start did not return after Shutdown")
}
}
// TestShutdownWithoutRunningCluster verifies the fallback path: with no
// cluster in this process there is no context to cancel, so Shutdown goes
// through the pid file instead.
func TestShutdownWithoutRunningCluster(t *testing.T) {
svc, err := New(&Config{BasePath: t.TempDir()})
require.NoError(t, err)
// No pid file was ever written, so this reports rather than signals.
assert.Error(t, svc.Shutdown())
}
+3 -3
View File
@@ -13,9 +13,9 @@
// mulga, the module that contains it, without a repo-level cycle. It is
// deliberately its own leaf package, not part of the shared spinifex/testutil
// package that ~80 test files import — see tests/fixtures/predastore's package
// comment for why that matters (goleak poisoning via a transitively-linked
// init-time goroutine). vbscan does not import predastore/quic/quicclient,
// directly or transitively, and must stay that way.
// comment for why that matters (goleak poisoning via transitively-linked
// long-lived goroutines). vbscan does not pull in a predastore cluster
// runtime, directly or transitively, and must stay that way.
//
// # Scope: no chunk-reference-integrity scan
//
+112 -67
View File
@@ -2,16 +2,17 @@
// exercise an actual S3-compatible backend rather than a mock. It is
// deliberately its own leaf package (not folded into spinifex/testutil,
// which is imported by most of the module's test files for lightweight NATS
// helpers): this package transitively imports predastore/quic/quicclient,
// whose package-level DefaultPool starts a permanent cleanup goroutine at
// init time. Folding this file into the shared testutil package would pull
// that goroutine into every test binary that imports testutil, and trip up
// any unrelated goroutine-leak check (go.uber.org/goleak) running in that
// same binary. Only callers that actually need a real predastore should pay
// for this import.
// helpers): starting a daemon here builds a whole predastore cluster runtime
// — shard stores, Raft replicas and their transports — whose goroutines run
// for the life of the test binary. Folding this file into the shared testutil
// package would pull all of that into every test binary that imports
// testutil, and trip up any unrelated goroutine-leak check (go.uber.org/goleak)
// running in that same binary. Only callers that actually need a real
// predastore should pay for it.
package predastore
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
@@ -19,6 +20,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"log/slog"
"math/big"
"net"
"net/http"
@@ -33,14 +35,15 @@ import (
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/predastore/quic/quicclient"
"github.com/mulgadc/predastore/clusterrun"
"github.com/mulgadc/predastore/pkg/masterkey"
predastoreserver "github.com/mulgadc/predastore/s3"
"github.com/mulgadc/spinifex/tests/fixtures/scratch"
)
// Fixed connection details for the shared predastore fixture daemon started
// by Start. NodeID -1 (dev mode) runs every QUIC shard node in-process, so
// these values never need to vary per caller or per test run.
// by Start. Every cluster node runs in-process, so these values never need to
// vary per caller or per test run.
const (
Host = "127.0.0.1:18443"
Region = "us-east-1"
@@ -65,8 +68,8 @@ const (
)
// Fixture describes a running predastore daemon ready for real
// S3/viperblock clients: a reachable endpoint, TLS material already trusted
// process-wide, and its default buckets created.
// S3/viperblock clients: a reachable endpoint and its default buckets
// created. Its TLS cert is self-signed, so clients skip verification.
type Fixture struct {
Host string
Region string
@@ -128,21 +131,22 @@ func Start(t *testing.T) *Fixture {
certPath := filepath.Join(certDir, "test.crt")
keyPath := filepath.Join(certDir, "test.key")
caPool, err := generateCertificate(certPath, keyPath)
if err != nil {
// The cert serves the S3 HTTPS frontend only. Intra-cluster traffic never
// leaves the process here, so it needs no TLS material of its own.
if err := generateCertificate(certPath, keyPath); err != nil {
t.Fatalf("predastore fixture: generate certificate: %v", err)
}
// Inject the ephemeral cert for the QUIC client (s3d -> shard nodes).
quicclient.SetDefaultRootCAs(caPool)
// SSL_CERT_FILE injects the cert for the s3db REST client's OS trust
// store (sync.Once-cached there, so it must be set before that client's
// first dial). Scoped to this call via t.Setenv, but since the daemon and
// its first client dial both happen inside this same call, the cache is
// warm before this function returns — later tests in the process don't
// need the env var set again.
// SSL_CERT_FILE injects the cert into the OS trust store for clients that
// verify it rather than skipping verification (objectstore's S3 client,
// for one). t.Setenv reverts when this test ends, while the daemon lives
// on for the whole binary, so the pool is loaded here and now: crypto/x509
// caches it behind a sync.Once, and this call is what fixes our cert in it
// for every later test in the process.
t.Setenv("SSL_CERT_FILE", certPath)
if _, err := x509.SystemCertPool(); err != nil {
t.Fatalf("predastore fixture: load system cert pool: %v", err)
}
// Predastore mandates a 32-byte master key at mode 0600 (rejected
// otherwise by internal/keyfile.Load).
@@ -155,12 +159,12 @@ func Start(t *testing.T) *Fixture {
t.Fatalf("predastore fixture: write encryption key: %v", err)
}
// Five nodes trigger dev-mode: all QUIC shards start as local goroutines.
// One host carrying every node, so the whole cluster runs in this process
// over the in-process pipe: no sockets to bind, no intra-cluster certs.
// RS(3,2) needs 5 shard-storage nodes; 3 state replicas form the quorum.
configPath := filepath.Join(testDir, "predastore_test.toml")
configContent := `version = "1.0"
region = "us-east-1"
host = "127.0.0.1"
port = 18443
debug = false
disable_logging = false
base_path = "` + testDir + `/"
@@ -169,35 +173,51 @@ base_path = "` + testDir + `/"
data = 3
parity = 2
[[nodes]]
[[host]]
id = 1
host = "127.0.0.1"
port = 19991
path = "store/node-1/"
bind_addr = "127.0.0.1:16660"
public_addr = "127.0.0.1:16660"
data_dir = "` + filepath.Join(testDir, "cluster") + `"
[[nodes]]
[[node]]
id = 1
host_id = 1
role = "shard-storage"
[[node]]
id = 2
host = "127.0.0.1"
port = 19992
path = "store/node-2/"
host_id = 1
role = "shard-storage"
[[nodes]]
[[node]]
id = 3
host = "127.0.0.1"
port = 19993
path = "store/node-3/"
host_id = 1
role = "shard-storage"
[[nodes]]
[[node]]
id = 4
host = "127.0.0.1"
port = 19994
path = "store/node-4/"
host_id = 1
role = "shard-storage"
[[nodes]]
[[node]]
id = 5
host = "127.0.0.1"
port = 19995
path = "store/node-5/"
host_id = 1
role = "shard-storage"
[[node]]
id = 6
host_id = 1
role = "state-replica"
[[node]]
id = 7
host_id = 1
role = "state-replica"
[[node]]
id = 8
host_id = 1
role = "state-replica"
[[auth]]
access_key_id = "` + AccessKey + `"
@@ -209,6 +229,22 @@ account_id = "123456789012"
t.Fatalf("predastore fixture: write config: %v", err)
}
// The S3 gateway runs on top of a backend the caller wires: the cluster
// runtime owns the shard stores and the Raft replicas, and this process
// runs all of them.
cfg := &predastoreserver.Config{ConfigPath: configPath, BasePath: testDir}
if err := cfg.ReadConfig(); err != nil {
t.Fatalf("predastore fixture: read config: %v", err)
}
key, err := masterkey.Load(encryptionKeyPath)
if err != nil {
t.Fatalf("predastore fixture: load master key: %v", err)
}
rt, err := clusterrun.Build(cfg, clusterrun.AllNodeIDs(cfg), certPath, keyPath, key)
if err != nil {
t.Fatalf("predastore fixture: build cluster runtime: %v", err)
}
// Built directly against predastore/s3 rather than spinifex's own
// services/predastore wrapper: that wrapper pulls in spinifex/utils,
// and spinifex/utils' own tests import spinifex/testutil, which would be
@@ -222,13 +258,29 @@ account_id = "123456789012"
predastoreserver.WithTLS(certPath, keyPath),
predastoreserver.WithBasePath(testDir),
predastoreserver.WithDebug(false),
predastoreserver.WithNodeID(-1),
predastoreserver.WithPprof(false, ""),
predastoreserver.WithEncryptionKeyFile(encryptionKeyPath),
predastoreserver.WithPreparedBackend(rt.Backend),
)
if err != nil {
t.Fatalf("predastore fixture: create server: %v", err)
}
// The runtime is as permanent as the daemon it backs: an uncancellable
// context, left running until the test process exits. A t.Fatalf below
// takes the binary down with it, so nothing is left orphaned.
go func() {
if err := rt.Run(context.Background()); err != nil {
slog.Error("predastore fixture: cluster runtime exited", "error", err)
}
}()
// Writes need a committed leader; serving before one exists would fail
// the bucket creation below for no reason other than timing.
if err := rt.WaitReady(30 * time.Second); err != nil {
t.Fatalf("predastore fixture: no leader elected: %v", err)
}
if err := server.ListenAndServeAsync(); err != nil {
t.Fatalf("predastore fixture: start server: %v", err)
}
@@ -259,14 +311,13 @@ account_id = "123456789012"
return fixture
}
// generateCertificate generates a self-signed TLS certificate for the
// fixture daemon and returns a CertPool containing it, for injection into
// whichever client trust store a caller needs (see quicclient.SetDefaultRootCAs
// and the SSL_CERT_FILE handling in Start).
func generateCertificate(certPath, keyPath string) (*x509.CertPool, error) {
// generateCertificate writes a self-signed TLS certificate and key for the
// fixture daemon's S3 HTTPS frontend. Clients reach it with
// InsecureSkipVerify, so nothing needs the cert in a trust store.
func generateCertificate(certPath, keyPath string) error {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
return err
}
notBefore := time.Now()
@@ -274,7 +325,7 @@ func generateCertificate(certPath, keyPath string) (*x509.CertPool, error) {
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, err
return err
}
template := x509.Certificate{
@@ -294,40 +345,34 @@ func generateCertificate(certPath, keyPath string) (*x509.CertPool, error) {
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, err
return err
}
certOut, err := os.Create(certPath)
if err != nil {
return nil, err
return err
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return nil, err
return err
}
keyOut, err := os.Create(keyPath)
if err != nil {
return nil, err
return err
}
defer keyOut.Close()
privBytes, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, err
return err
}
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}); err != nil {
return nil, err
return err
}
parsed, err := x509.ParseCertificate(derBytes)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
pool.AddCert(parsed)
return pool, nil
return nil
}
// waitForReady polls the fixture daemon's HTTPS endpoint until it accepts
+3 -4
View File
@@ -83,10 +83,9 @@ func TestPredastoreObjectLifecycle(t *testing.T) {
}
// predastoreS3Client builds an aws-sdk-go v1 S3 client against the shared
// predastore fixture daemon. TLS verification is skipped: the fixture's
// self-signed certificate is trusted process-wide for quicclient/the s3db
// REST client (see testpredastore.Start), but a plain s3.S3 built here has
// its own independent HTTP transport.
// predastore fixture daemon. TLS verification is skipped: the fixture serves
// the S3 API under a self-signed certificate it generates for itself (see
// testpredastore.Start), which no trust store knows about.
func predastoreS3Client(t *testing.T, fixture *testpredastore.Fixture) *s3.S3 {
t.Helper()
httpClient := &http.Client{