feat: enhance production command handling and configuration generation

- Added comprehensive tests for production command flag parsing to ensure correct handling of bootstrap, VPS IP, and peer configurations.
- Updated production command help output to clarify the usage of new flags, including `--vps-ip` and `--bootstrap-join`.
- Modified the configuration generation logic to incorporate the new `bootstrapJoin` parameter for secondary bootstrap nodes.
- Enhanced systemd service generation to include the correct advertise IP and join address for non-bootstrap nodes.
- Implemented tests for RQLite service generation to verify the inclusion of join addresses and advertise IPs in the generated units.
This commit is contained in:
anonpenguin23 2025-11-11 06:51:28 +02:00
parent ed80b5b023
commit badaa920d9
No known key found for this signature in database
GPG Key ID: 1CBB1FE35AFBEE30
9 changed files with 335 additions and 31 deletions

View File

@ -13,6 +13,40 @@ The format is based on [Keep a Changelog][keepachangelog] and adheres to [Semant
### Deprecated
### Fixed
## [0.67.0] - 2025-11-11
### Added
- Added support for joining a cluster as a secondary bootstrap node using the new `--bootstrap-join` flag.
- Added a new flag `--vps-ip` to specify the public IP address for non-bootstrap nodes, which is now required for cluster joining.
### Changed
- Updated the installation script to correctly download and install the CLI binary from the GitHub release archive.
- Improved RQLite service configuration to correctly use the public IP address (`--vps-ip`) for advertising its raft and HTTP addresses.
### Deprecated
### Removed
### Fixed
- Fixed an issue where non-bootstrap nodes could be installed without specifying the required `--vps-ip`.
## [0.67.0] - 2025-11-11
### Added
- Added support for joining a cluster as a secondary bootstrap node using the new `--bootstrap-join` flag.
- Added a new flag `--vps-ip` to specify the public IP address for non-bootstrap nodes, which is now required for cluster joining.
### Changed
- Updated the installation script to correctly download and install the CLI binary from the GitHub release archive.
- Improved RQLite service configuration to correctly use the public IP address (`--vps-ip`) for advertising its raft and HTTP addresses.
### Deprecated
### Removed
### Fixed
- Fixed an issue where non-bootstrap nodes could be installed without specifying the required `--vps-ip`.
## [0.66.1] - 2025-11-11
### Added

View File

@ -19,7 +19,7 @@ test-e2e:
.PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports install-hooks kill
VERSION := 0.66.1
VERSION := 0.67.0
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)'

View File

@ -49,8 +49,9 @@ func showProdHelp() {
fmt.Printf(" Options:\n")
fmt.Printf(" --force - Reconfigure all settings\n")
fmt.Printf(" --bootstrap - Install as bootstrap node\n")
fmt.Printf(" --vps-ip IP - VPS public IP address (required for non-bootstrap)\n")
fmt.Printf(" --peers ADDRS - Comma-separated bootstrap peers (for non-bootstrap)\n")
fmt.Printf(" --vps-ip IP - VPS public IP address\n")
fmt.Printf(" --bootstrap-join ADDR - Bootstrap raft join address (for secondary bootstrap)\n")
fmt.Printf(" --domain DOMAIN - Domain for HTTPS (optional)\n")
fmt.Printf(" upgrade - Upgrade existing installation (requires root/sudo)\n")
fmt.Printf(" status - Show status of production services\n")
@ -59,8 +60,12 @@ func showProdHelp() {
fmt.Printf(" --follow - Follow logs in real-time\n")
fmt.Printf(" uninstall - Remove production services (requires root/sudo)\n\n")
fmt.Printf("Examples:\n")
fmt.Printf(" sudo dbn prod install --bootstrap\n")
fmt.Printf(" sudo dbn prod install --peers /ip4/1.2.3.4/tcp/4001/p2p/Qm...\n")
fmt.Printf(" # Bootstrap node\n")
fmt.Printf(" sudo dbn prod install --bootstrap\n\n")
fmt.Printf(" # Join existing cluster\n")
fmt.Printf(" sudo dbn prod install --vps-ip 10.0.0.2 --peers /ip4/10.0.0.1/tcp/4001/p2p/Qm...\n\n")
fmt.Printf(" # Secondary bootstrap joining existing cluster\n")
fmt.Printf(" sudo dbn prod install --bootstrap --vps-ip 10.0.0.2 --bootstrap-join 10.0.0.1:7001\n\n")
fmt.Printf(" dbn prod status\n")
fmt.Printf(" dbn prod logs node --follow\n")
}
@ -69,7 +74,7 @@ func handleProdInstall(args []string) {
// Parse arguments
force := false
isBootstrap := false
var vpsIP, domain, peersStr string
var vpsIP, domain, peersStr, bootstrapJoin string
for i, arg := range args {
switch arg {
@ -89,6 +94,10 @@ func handleProdInstall(args []string) {
if i+1 < len(args) {
domain = args[i+1]
}
case "--bootstrap-join":
if i+1 < len(args) {
bootstrapJoin = args[i+1]
}
}
}
@ -104,6 +113,13 @@ func handleProdInstall(args []string) {
os.Exit(1)
}
// Enforce --vps-ip for non-bootstrap nodes
if !isBootstrap && vpsIP == "" {
fmt.Fprintf(os.Stderr, "❌ --vps-ip is required for non-bootstrap nodes\n")
fmt.Fprintf(os.Stderr, " Usage: sudo dbn prod install --vps-ip <public_ip> --peers <multiaddr>\n")
os.Exit(1)
}
debrosHome := "/home/debros"
setup := production.NewProductionSetup(debrosHome, os.Stdout, force)
@ -152,14 +168,14 @@ func handleProdInstall(args []string) {
// Phase 4: Generate configs
fmt.Printf("\n⚙ Phase 4: Generating configurations...\n")
enableHTTPS := domain != ""
if err := setup.Phase4GenerateConfigs(isBootstrap, bootstrapPeers, vpsIP, enableHTTPS, domain); err != nil {
if err := setup.Phase4GenerateConfigs(isBootstrap, bootstrapPeers, vpsIP, enableHTTPS, domain, bootstrapJoin); err != nil {
fmt.Fprintf(os.Stderr, "❌ Configuration generation failed: %v\n", err)
os.Exit(1)
}
// Phase 5: Create systemd services
fmt.Printf("\n🔧 Phase 5: Creating systemd services...\n")
if err := setup.Phase5CreateSystemdServices(nodeType); err != nil {
if err := setup.Phase5CreateSystemdServices(nodeType, vpsIP); err != nil {
fmt.Fprintf(os.Stderr, "❌ Service creation failed: %v\n", err)
os.Exit(1)
}

View File

@ -0,0 +1,88 @@
package cli
import (
"testing"
)
// TestProdCommandFlagParsing verifies that prod command flags are parsed correctly
func TestProdCommandFlagParsing(t *testing.T) {
tests := []struct {
name string
args []string
expectBootstrap bool
expectVPSIP string
expectBootstrapJoin string
expectPeers string
}{
{
name: "bootstrap node",
args: []string{"install", "--bootstrap"},
expectBootstrap: true,
},
{
name: "non-bootstrap with vps-ip",
args: []string{"install", "--vps-ip", "10.0.0.2", "--peers", "multiaddr1,multiaddr2"},
expectVPSIP: "10.0.0.2",
expectPeers: "multiaddr1,multiaddr2",
},
{
name: "secondary bootstrap",
args: []string{"install", "--bootstrap", "--vps-ip", "10.0.0.3", "--bootstrap-join", "10.0.0.1:7001"},
expectBootstrap: true,
expectVPSIP: "10.0.0.3",
expectBootstrapJoin: "10.0.0.1:7001",
},
{
name: "with domain",
args: []string{"install", "--bootstrap", "--domain", "example.com"},
expectBootstrap: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Extract flags manually to verify parsing logic
force := false
isBootstrap := false
var vpsIP, domain, peersStr, bootstrapJoin string
for i, arg := range tt.args {
switch arg {
case "--force":
force = true
case "--bootstrap":
isBootstrap = true
case "--peers":
if i+1 < len(tt.args) {
peersStr = tt.args[i+1]
}
case "--vps-ip":
if i+1 < len(tt.args) {
vpsIP = tt.args[i+1]
}
case "--domain":
if i+1 < len(tt.args) {
domain = tt.args[i+1]
}
case "--bootstrap-join":
if i+1 < len(tt.args) {
bootstrapJoin = tt.args[i+1]
}
}
}
if isBootstrap != tt.expectBootstrap {
t.Errorf("expected bootstrap=%v, got %v", tt.expectBootstrap, isBootstrap)
}
if vpsIP != tt.expectVPSIP {
t.Errorf("expected vpsIP=%q, got %q", tt.expectVPSIP, vpsIP)
}
if peersStr != tt.expectPeers {
t.Errorf("expected peers=%q, got %q", tt.expectPeers, peersStr)
}
if bootstrapJoin != tt.expectBootstrapJoin {
t.Errorf("expected bootstrapJoin=%q, got %q", tt.expectBootstrapJoin, bootstrapJoin)
}
})
}
}

View File

@ -27,7 +27,7 @@ func NewConfigGenerator(debrosDir string) *ConfigGenerator {
}
// GenerateNodeConfig generates node.yaml configuration
func (cg *ConfigGenerator) GenerateNodeConfig(isBootstrap bool, bootstrapPeers []string, vpsIP string) (string, error) {
func (cg *ConfigGenerator) GenerateNodeConfig(isBootstrap bool, bootstrapPeers []string, vpsIP string, bootstrapJoin string) (string, error) {
var nodeID string
if isBootstrap {
nodeID = "bootstrap"
@ -36,19 +36,22 @@ func (cg *ConfigGenerator) GenerateNodeConfig(isBootstrap bool, bootstrapPeers [
}
if isBootstrap {
// Bootstrap node - populate peer list and optional join address
data := templates.BootstrapConfigData{
NodeID: nodeID,
P2PPort: 4001,
DataDir: filepath.Join(cg.debrosDir, "data", "bootstrap"),
RQLiteHTTPPort: 5001,
RQLiteRaftPort: 7001,
ClusterAPIPort: 9094,
IPFSAPIPort: 4501,
NodeID: nodeID,
P2PPort: 4001,
DataDir: filepath.Join(cg.debrosDir, "data", "bootstrap"),
RQLiteHTTPPort: 5001,
RQLiteRaftPort: 7001,
ClusterAPIPort: 9094,
IPFSAPIPort: 4501,
BootstrapPeers: bootstrapPeers,
RQLiteJoinAddress: bootstrapJoin,
}
return templates.RenderBootstrapConfig(data)
}
// Regular node
// Regular node - must have join address
rqliteJoinAddr := "localhost:7001"
if vpsIP != "" {
rqliteJoinAddr = vpsIP + ":7001"

View File

@ -281,11 +281,11 @@ func (ps *ProductionSetup) Phase3GenerateSecrets(isBootstrap bool) error {
}
// Phase4GenerateConfigs generates node, gateway, and service configs
func (ps *ProductionSetup) Phase4GenerateConfigs(isBootstrap bool, bootstrapPeers []string, vpsIP string, enableHTTPS bool, domain string) error {
func (ps *ProductionSetup) Phase4GenerateConfigs(isBootstrap bool, bootstrapPeers []string, vpsIP string, enableHTTPS bool, domain string, bootstrapJoin string) error {
ps.logf("Phase 4: Generating configurations...")
// Node config
nodeConfig, err := ps.configGenerator.GenerateNodeConfig(isBootstrap, bootstrapPeers, vpsIP)
nodeConfig, err := ps.configGenerator.GenerateNodeConfig(isBootstrap, bootstrapPeers, vpsIP, bootstrapJoin)
if err != nil {
return fmt.Errorf("failed to generate node config: %w", err)
}
@ -337,7 +337,7 @@ func (ps *ProductionSetup) Phase4GenerateConfigs(isBootstrap bool, bootstrapPeer
}
// Phase5CreateSystemdServices creates and enables systemd units
func (ps *ProductionSetup) Phase5CreateSystemdServices(nodeType string) error {
func (ps *ProductionSetup) Phase5CreateSystemdServices(nodeType string, vpsIP string) error {
ps.logf("Phase 5: Creating systemd services...")
// IPFS service
@ -356,8 +356,20 @@ func (ps *ProductionSetup) Phase5CreateSystemdServices(nodeType string) error {
}
ps.logf(" ✓ IPFS Cluster service created: %s", clusterUnitName)
// RQLite service (only for bootstrap in single-node, or conditionally)
rqliteUnit := ps.serviceGenerator.GenerateRQLiteService(nodeType, 5001, 7001, "")
// RQLite service with join address for non-bootstrap nodes
rqliteJoinAddr := ""
if nodeType != "bootstrap" && vpsIP != "" {
rqliteJoinAddr = vpsIP + ":7001"
}
// Log the advertise configuration for verification
advertiseIP := vpsIP
if advertiseIP == "" {
advertiseIP = "127.0.0.1"
}
ps.logf(" RQLite will advertise: %s (advertise IP: %s)", rqliteJoinAddr, advertiseIP)
rqliteUnit := ps.serviceGenerator.GenerateRQLiteService(nodeType, 5001, 7001, rqliteJoinAddr, advertiseIP)
rqliteUnitName := fmt.Sprintf("debros-rqlite-%s.service", nodeType)
if err := ps.serviceController.WriteServiceUnit(rqliteUnitName, rqliteUnit); err != nil {
return fmt.Errorf("failed to write RQLite service: %w", err)

View File

@ -104,7 +104,7 @@ WantedBy=multi-user.target
}
// GenerateRQLiteService generates the RQLite systemd unit
func (ssg *SystemdServiceGenerator) GenerateRQLiteService(nodeType string, httpPort, raftPort int, joinAddr string) string {
func (ssg *SystemdServiceGenerator) GenerateRQLiteService(nodeType string, httpPort, raftPort int, joinAddr string, advertiseIP string) string {
var dataDir string
if nodeType == "bootstrap" {
dataDir = filepath.Join(ssg.debrosDir, "data", "bootstrap", "rqlite")
@ -112,9 +112,14 @@ func (ssg *SystemdServiceGenerator) GenerateRQLiteService(nodeType string, httpP
dataDir = filepath.Join(ssg.debrosDir, "data", "node", "rqlite")
}
// Use public IP for advertise if provided, otherwise default to localhost
if advertiseIP == "" {
advertiseIP = "127.0.0.1"
}
args := fmt.Sprintf(
`-http-addr 0.0.0.0:%d -http-adv-addr 127.0.0.1:%d -raft-adv-addr 127.0.0.1:%d -raft-addr 0.0.0.0:%d`,
httpPort, httpPort, raftPort, raftPort,
`-http-addr 0.0.0.0:%d -http-adv-addr %s:%d -raft-adv-addr %s:%d -raft-addr 0.0.0.0:%d`,
httpPort, advertiseIP, httpPort, advertiseIP, raftPort, raftPort,
)
if joinAddr != "" {

View File

@ -0,0 +1,114 @@
package production
import (
"strings"
"testing"
)
// TestGenerateRQLiteService verifies RQLite service generation with advertise IP and join address
func TestGenerateRQLiteService(t *testing.T) {
tests := []struct {
name string
nodeType string
joinAddr string
advertiseIP string
expectJoinInUnit bool
expectAdvertiseIP string
}{
{
name: "bootstrap with localhost advertise",
nodeType: "bootstrap",
joinAddr: "",
advertiseIP: "",
expectJoinInUnit: false,
expectAdvertiseIP: "127.0.0.1",
},
{
name: "bootstrap with public IP advertise",
nodeType: "bootstrap",
joinAddr: "",
advertiseIP: "10.0.0.1",
expectJoinInUnit: false,
expectAdvertiseIP: "10.0.0.1",
},
{
name: "node joining cluster",
nodeType: "node",
joinAddr: "10.0.0.1:7001",
advertiseIP: "10.0.0.2",
expectJoinInUnit: true,
expectAdvertiseIP: "10.0.0.2",
},
{
name: "node with localhost (should still include join)",
nodeType: "node",
joinAddr: "localhost:7001",
advertiseIP: "127.0.0.1",
expectJoinInUnit: true,
expectAdvertiseIP: "127.0.0.1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ssg := &SystemdServiceGenerator{
debrosHome: "/home/debros",
debrosDir: "/home/debros/.debros",
}
unit := ssg.GenerateRQLiteService(tt.nodeType, 5001, 7001, tt.joinAddr, tt.advertiseIP)
// Check advertise IP is present
expectedAdvertise := tt.expectAdvertiseIP + ":5001"
if !strings.Contains(unit, expectedAdvertise) {
t.Errorf("expected advertise address %q in unit, got:\n%s", expectedAdvertise, unit)
}
// Check raft advertise IP is present
expectedRaftAdvertise := tt.expectAdvertiseIP + ":7001"
if !strings.Contains(unit, expectedRaftAdvertise) {
t.Errorf("expected raft advertise address %q in unit, got:\n%s", expectedRaftAdvertise, unit)
}
// Check join flag presence
hasJoin := strings.Contains(unit, "-join")
if hasJoin != tt.expectJoinInUnit {
t.Errorf("expected join in unit: %v, hasJoin: %v\nUnit:\n%s", tt.expectJoinInUnit, hasJoin, unit)
}
if tt.expectJoinInUnit && tt.joinAddr != "" && !strings.Contains(unit, tt.joinAddr) {
t.Errorf("expected join address %q in unit, not found", tt.joinAddr)
}
})
}
}
// TestGenerateRQLiteServiceArgs verifies the ExecStart command arguments
func TestGenerateRQLiteServiceArgs(t *testing.T) {
ssg := &SystemdServiceGenerator{
debrosHome: "/home/debros",
debrosDir: "/home/debros/.debros",
}
unit := ssg.GenerateRQLiteService("node", 5001, 7001, "10.0.0.1:7001", "10.0.0.2")
// Verify essential flags are present
if !strings.Contains(unit, "-http-addr 0.0.0.0:5001") {
t.Error("missing -http-addr 0.0.0.0:5001")
}
if !strings.Contains(unit, "-http-adv-addr 10.0.0.2:5001") {
t.Error("missing -http-adv-addr 10.0.0.2:5001")
}
if !strings.Contains(unit, "-raft-addr 0.0.0.0:7001") {
t.Error("missing -raft-addr 0.0.0.0:7001")
}
if !strings.Contains(unit, "-raft-adv-addr 10.0.0.2:7001") {
t.Error("missing -raft-adv-addr 10.0.0.2:7001")
}
if !strings.Contains(unit, "-join 10.0.0.1:7001") {
t.Error("missing -join 10.0.0.1:7001")
}
if !strings.Contains(unit, "-join-attempts 30") {
t.Error("missing -join-attempts 30")
}
}

View File

@ -101,12 +101,11 @@ get_latest_release() {
log "Fetching latest release..."
if command -v jq &>/dev/null; then
# Get the latest release (including pre-releases/nightly)
LATEST_RELEASE=$(curl -fsSL -H "Accept: application/vnd.github+json" "$GITHUB_API/releases" | \
jq -r '.[] | select(.prerelease == false and .draft == false) | .tag_name' | head -1)
jq -r '.[0] | .tag_name')
else
LATEST_RELEASE=$(curl -fsSL "$GITHUB_API/releases" | \
grep -v "prerelease.*true" | \
grep -v "draft.*true" | \
grep '"tag_name"' | \
head -1 | \
cut -d'"' -f4)
@ -121,19 +120,52 @@ get_latest_release() {
}
download_and_install_cli() {
BINARY_NAME="network-cli_${LATEST_RELEASE#v}_linux_${GITHUB_ARCH}"
BINARY_NAME="debros-network_${LATEST_RELEASE#v}_linux_${GITHUB_ARCH}.tar.gz"
DOWNLOAD_URL="$GITHUB_REPO/releases/download/$LATEST_RELEASE/$BINARY_NAME"
log "Downloading dbn from GitHub releases..."
if ! curl -fsSL -o /tmp/dbn "https://github.com/$DOWNLOAD_URL"; then
log "URL: https://github.com/$DOWNLOAD_URL"
# Clean up any stale binaries
rm -f /tmp/network-cli /tmp/dbn.tar.gz "$INSTALL_DIR/dbn"
if ! curl -fsSL -o /tmp/dbn.tar.gz "https://github.com/$DOWNLOAD_URL"; then
error "Failed to download dbn"
exit 1
fi
chmod +x /tmp/dbn
# Verify the download was successful
if [ ! -f /tmp/dbn.tar.gz ]; then
error "Download file not found"
exit 1
fi
log "Extracting dbn..."
# Extract to /tmp
tar -xzf /tmp/dbn.tar.gz -C /tmp/
# Check if binary exists after extraction (it's named network-cli in the archive)
if [ ! -f /tmp/network-cli ]; then
error "Failed to extract network-cli binary"
ls -la /tmp/ | grep -E "(network|cli|dbn)"
exit 1
fi
chmod +x /tmp/network-cli
log "Installing dbn to $INSTALL_DIR..."
mv /tmp/dbn "$INSTALL_DIR/dbn"
# Rename network-cli to dbn during installation
mv /tmp/network-cli "$INSTALL_DIR/dbn"
# Sanity check: verify the installed binary is functional and reports correct version
if ! "$INSTALL_DIR/dbn" version &>/dev/null; then
error "Installed dbn failed sanity check (version command failed)"
rm -f "$INSTALL_DIR/dbn"
exit 1
fi
# Clean up
rm -f /tmp/dbn.tar.gz
success "dbn installed successfully"
}