diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f697b..b2be22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 6eec37b..1177de6 100644 --- a/Makefile +++ b/Makefile @@ -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)' diff --git a/pkg/cli/prod_commands.go b/pkg/cli/prod_commands.go index fe66b05..e082396 100644 --- a/pkg/cli/prod_commands.go +++ b/pkg/cli/prod_commands.go @@ -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 --peers \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) } diff --git a/pkg/cli/prod_commands_test.go b/pkg/cli/prod_commands_test.go new file mode 100644 index 0000000..82900d1 --- /dev/null +++ b/pkg/cli/prod_commands_test.go @@ -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) + } + }) + } +} diff --git a/pkg/environments/production/config.go b/pkg/environments/production/config.go index 68df1c3..7351e90 100644 --- a/pkg/environments/production/config.go +++ b/pkg/environments/production/config.go @@ -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" diff --git a/pkg/environments/production/orchestrator.go b/pkg/environments/production/orchestrator.go index 3b9403e..3d105de 100644 --- a/pkg/environments/production/orchestrator.go +++ b/pkg/environments/production/orchestrator.go @@ -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) diff --git a/pkg/environments/production/services.go b/pkg/environments/production/services.go index 97a26e9..79be077 100644 --- a/pkg/environments/production/services.go +++ b/pkg/environments/production/services.go @@ -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 != "" { diff --git a/pkg/environments/production/services_test.go b/pkg/environments/production/services_test.go new file mode 100644 index 0000000..80ed563 --- /dev/null +++ b/pkg/environments/production/services_test.go @@ -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") + } +} diff --git a/scripts/install-debros-network.sh b/scripts/install-debros-network.sh index fcba825..e519417 100755 --- a/scripts/install-debros-network.sh +++ b/scripts/install-debros-network.sh @@ -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" }