From 5456d57aeb0262b9d956f869719be9ee0b6f2525 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Thu, 26 Mar 2026 17:33:19 +0200 Subject: [PATCH] feat(sandbox): add preflight checks and auto-build archive to create - validate agent, API token, archive before provisioning - auto-build archive via `make build-archive` if missing - add tests and Makefile install target --- Makefile | 5 ++ pkg/cli/sandbox/create.go | 160 ++++++++++++++++++++++++++++----- pkg/cli/sandbox/create_test.go | 158 ++++++++++++++++++++++++++++++++ scripts/install.sh | 95 ++++++++++++++++++++ 4 files changed, 394 insertions(+), 24 deletions(-) create mode 100644 pkg/cli/sandbox/create_test.go create mode 100755 scripts/install.sh diff --git a/Makefile b/Makefile index 0c84c9c..cd6d2d4 100644 --- a/Makefile +++ b/Makefile @@ -102,6 +102,10 @@ install-hooks: @echo "Installing git hooks..." @bash scripts/install-hooks.sh +# Install orama CLI to ~/.local/bin and configure PATH +install: build + @bash scripts/install.sh + # Clean build artifacts clean: @echo "Cleaning build artifacts..." @@ -142,6 +146,7 @@ health: help: @echo "Available targets:" @echo " build - Build all executables" + @echo " install - Build and install 'orama' CLI to ~/.local/bin" @echo " clean - Clean build artifacts" @echo " test - Run unit tests" @echo "" diff --git a/pkg/cli/sandbox/create.go b/pkg/cli/sandbox/create.go index b18d647..36e9bc3 100644 --- a/pkg/cli/sandbox/create.go +++ b/pkg/cli/sandbox/create.go @@ -1,6 +1,7 @@ package sandbox import ( + "context" "fmt" "os" "os/exec" @@ -10,6 +11,7 @@ import ( "github.com/DeBrosOfficial/network/pkg/cli/remotessh" "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/DeBrosOfficial/network/pkg/rwagent" ) // Create orchestrates the creation of a new sandbox cluster. @@ -19,14 +21,10 @@ func Create(name string) error { return err } - // Resolve wallet SSH key once for all phases - sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget) - if err != nil { - return fmt.Errorf("prepare SSH key: %w", err) - } - defer cleanup() + // --- Preflight: validate everything BEFORE spending money --- + fmt.Println("Preflight checks:") - // Check for existing active sandbox + // 1. Check for existing active sandbox active, err := FindActiveSandbox() if err != nil { return err @@ -35,6 +33,52 @@ func Create(name string) error { return fmt.Errorf("sandbox %q is already active (status: %s)\nDestroy it first: orama sandbox destroy --name %s", active.Name, active.Status, active.Name) } + fmt.Println(" [ok] No active sandbox") + + // 2. Check rootwallet agent is running and unlocked before the slow SSH key call + if err := checkAgentReady(); err != nil { + return err + } + fmt.Println(" [ok] Rootwallet agent running and unlocked") + + // 3. Resolve SSH key (may trigger approval prompt in RootWallet app) + fmt.Print(" [..] Resolving SSH key from vault...") + sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget) + if err != nil { + fmt.Println(" FAILED") + return fmt.Errorf("prepare SSH key: %w", err) + } + defer cleanup() + fmt.Println(" ok") + + // 4. Check binary archive — auto-build if missing + archivePath := findNewestArchive() + if archivePath == "" { + fmt.Println(" [--] No binary archive found, building...") + if err := autoBuildArchive(); err != nil { + return fmt.Errorf("auto-build archive: %w", err) + } + archivePath = findNewestArchive() + if archivePath == "" { + return fmt.Errorf("build succeeded but no archive found in /tmp/") + } + } + info, err := os.Stat(archivePath) + if err != nil { + return fmt.Errorf("stat archive %s: %w", archivePath, err) + } + fmt.Printf(" [ok] Binary archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size())) + + // 5. Verify Hetzner API token works + client := NewHetznerClient(cfg.HetznerAPIToken) + if err := client.ValidateToken(); err != nil { + return fmt.Errorf("hetzner API: %w\n Check your token in ~/.orama/sandbox.yaml", err) + } + fmt.Println(" [ok] Hetzner API token valid") + + fmt.Println() + + // --- All preflight checks passed, proceed --- // Generate name if not provided if name == "" { @@ -43,8 +87,6 @@ func Create(name string) error { fmt.Printf("Creating sandbox %q (%s, %d nodes)\n\n", name, cfg.Domain, 5) - client := NewHetznerClient(cfg.HetznerAPIToken) - state := &SandboxState{ Name: name, CreatedAt: time.Now().UTC(), @@ -58,18 +100,22 @@ func Create(name string) error { cleanupFailedCreate(client, state) return fmt.Errorf("provision servers: %w", err) } - SaveState(state) + if err := SaveState(state); err != nil { + fmt.Fprintf(os.Stderr, "Warning: save state after provisioning: %v\n", err) + } // Phase 2: Assign floating IPs fmt.Println("\nPhase 2: Assigning floating IPs...") if err := phase2AssignFloatingIPs(client, cfg, state, sshKeyPath); err != nil { return fmt.Errorf("assign floating IPs: %w", err) } - SaveState(state) + if err := SaveState(state); err != nil { + fmt.Fprintf(os.Stderr, "Warning: save state after floating IPs: %v\n", err) + } // Phase 3: Upload binary archive fmt.Println("\nPhase 3: Uploading binary archive...") - if err := phase3UploadArchive(state, sshKeyPath); err != nil { + if err := phase3UploadArchive(state, sshKeyPath, archivePath); err != nil { return fmt.Errorf("upload archive: %w", err) } @@ -77,7 +123,7 @@ func Create(name string) error { fmt.Println("\nPhase 4: Installing genesis node...") if err := phase4InstallGenesis(cfg, state, sshKeyPath); err != nil { state.Status = StatusError - SaveState(state) + _ = SaveState(state) return fmt.Errorf("install genesis: %w", err) } @@ -85,7 +131,7 @@ func Create(name string) error { fmt.Println("\nPhase 5: Joining remaining nodes...") if err := phase5JoinNodes(cfg, state, sshKeyPath); err != nil { state.Status = StatusError - SaveState(state) + _ = SaveState(state) return fmt.Errorf("join nodes: %w", err) } @@ -94,12 +140,49 @@ func Create(name string) error { phase6Verify(cfg, state, sshKeyPath) state.Status = StatusRunning - SaveState(state) + if err := SaveState(state); err != nil { + return fmt.Errorf("save final state: %w", err) + } printCreateSummary(cfg, state) return nil } +// checkAgentReady verifies the rootwallet agent is running, unlocked, and +// that the desktop app is connected (required for first-time app approval). +func checkAgentReady() error { + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + status, err := client.Status(ctx) + if err != nil { + if rwagent.IsNotRunning(err) { + return fmt.Errorf("rootwallet agent is not running\n\n Start it with:\n rw agent start && rw agent unlock") + } + return fmt.Errorf("rootwallet agent: %w", err) + } + + return validateAgentStatus(status) +} + +// validateAgentStatus checks that the agent status indicates readiness. +// Separated from checkAgentReady for testability. +func validateAgentStatus(status *rwagent.StatusResponse) error { + if status.Locked { + return fmt.Errorf("rootwallet agent is locked\n\n Unlock it with:\n rw agent unlock") + } + + if status.ConnectedApps == 0 { + fmt.Println(" [!!] RootWallet desktop app is not open") + fmt.Println(" First-time use requires the desktop app to approve access.") + fmt.Println(" Open the RootWallet app, then re-run this command.") + return fmt.Errorf("RootWallet desktop app required for approval — open it and retry") + } + + return nil +} + // resolveVaultKeyOnce resolves a wallet SSH key to a temp file. // Returns the key path, cleanup function, and any error. func resolveVaultKeyOnce(vaultTarget string) (string, func(), error) { @@ -259,17 +342,46 @@ func waitForSSH(node inspector.Node, timeout time.Duration) error { return fmt.Errorf("timeout after %s", timeout) } -// phase3UploadArchive uploads the binary archive to the genesis node, then fans out -// to the remaining nodes server-to-server (much faster than uploading from local machine). -func phase3UploadArchive(state *SandboxState, sshKeyPath string) error { - archivePath := findNewestArchive() - if archivePath == "" { - fmt.Println(" No binary archive found, run `orama build` first") - return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)") +// autoBuildArchive runs `make build-archive` from the project root. +func autoBuildArchive() error { + // Find project root by looking for go.mod + dir, err := findProjectRoot() + if err != nil { + return fmt.Errorf("find project root: %w", err) } - info, _ := os.Stat(archivePath) - fmt.Printf(" Archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size())) + cmd := exec.Command("make", "build-archive") + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("make build-archive failed: %w", err) + } + return nil +} + +// findProjectRoot walks up from the current working directory to find go.mod. +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("could not find go.mod in any parent directory") + } + dir = parent + } +} + +// phase3UploadArchive uploads the binary archive to the genesis node, then fans out +// to the remaining nodes server-to-server (much faster than uploading from local machine). +func phase3UploadArchive(state *SandboxState, sshKeyPath, archivePath string) error { + fmt.Printf(" Archive: %s\n", filepath.Base(archivePath)) if err := fanoutArchive(state.Servers, sshKeyPath, archivePath); err != nil { return err diff --git a/pkg/cli/sandbox/create_test.go b/pkg/cli/sandbox/create_test.go new file mode 100644 index 0000000..14c6d7c --- /dev/null +++ b/pkg/cli/sandbox/create_test.go @@ -0,0 +1,158 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DeBrosOfficial/network/pkg/rwagent" +) + +func TestFindProjectRoot_FromSubDir(t *testing.T) { + // Create a temp dir with go.mod (resolve symlinks for macOS /private/var) + root, _ := filepath.EvalSymlinks(t.TempDir()) + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module test"), 0644); err != nil { + t.Fatal(err) + } + + // Create a nested subdir + sub := filepath.Join(root, "pkg", "foo") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + + // Change to subdir and find root + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(sub) + + got, err := findProjectRoot() + if err != nil { + t.Fatalf("findProjectRoot() error: %v", err) + } + if got != root { + t.Errorf("findProjectRoot() = %q, want %q", got, root) + } +} + +func TestFindProjectRoot_NoGoMod(t *testing.T) { + // Create a temp dir without go.mod + dir := t.TempDir() + + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + + _, err := findProjectRoot() + if err == nil { + t.Error("findProjectRoot() should error when no go.mod exists") + } +} + +func TestFindNewestArchive_NoArchives(t *testing.T) { + // findNewestArchive scans /tmp — just verify it returns "" when + // no matching files exist (this is the normal case in CI). + // We can't fully control /tmp, but we can verify the function doesn't crash. + result := findNewestArchive() + // Result is either "" or a valid path — both are acceptable + if result != "" { + if _, err := os.Stat(result); err != nil { + t.Errorf("findNewestArchive() returned non-existent path: %s", result) + } + } +} + +func TestIsSafeDNSName(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"example.com", true}, + {"test-cluster.orama.network", true}, + {"a", true}, + {"", false}, + {"test;rm -rf /", false}, + {"test$(whoami)", false}, + {"test space", false}, + {"test_underscore", false}, + {"UPPER.case.OK", true}, + {"123.456", true}, + } + for _, tt := range tests { + got := isSafeDNSName(tt.input) + if got != tt.want { + t.Errorf("isSafeDNSName(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestIsHex(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"abcdef0123456789", true}, + {"ABCDEF", true}, + {"0", true}, + {"", true}, // vacuous truth, but guarded by len check in caller + {"xyz", false}, + {"abcg", false}, + {"abc def", false}, + } + for _, tt := range tests { + got := isHex(tt.input) + if got != tt.want { + t.Errorf("isHex(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestValidateAgentStatus_Locked(t *testing.T) { + status := &rwagent.StatusResponse{Locked: true, ConnectedApps: 1} + err := validateAgentStatus(status) + if err == nil { + t.Fatal("expected error for locked agent") + } + if !strings.Contains(err.Error(), "locked") { + t.Errorf("error should mention locked, got: %v", err) + } +} + +func TestValidateAgentStatus_NoDesktopApp(t *testing.T) { + status := &rwagent.StatusResponse{Locked: false, ConnectedApps: 0} + err := validateAgentStatus(status) + if err == nil { + t.Fatal("expected error when no desktop app connected") + } + if !strings.Contains(err.Error(), "desktop app") { + t.Errorf("error should mention desktop app, got: %v", err) + } +} + +func TestValidateAgentStatus_Ready(t *testing.T) { + status := &rwagent.StatusResponse{Locked: false, ConnectedApps: 1} + if err := validateAgentStatus(status); err != nil { + t.Errorf("expected no error for ready agent, got: %v", err) + } +} + +func TestFormatBytes(t *testing.T) { + tests := []struct { + input int64 + want string + }{ + {0, "0 B"}, + {500, "500 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1048576, "1.0 MB"}, + {1073741824, "1.0 GB"}, + } + for _, tt := range tests { + got := formatBytes(tt.input) + if got != tt.want { + t.Errorf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..30a17bf --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Orama CLI installer +# Builds the CLI and adds `orama` to your PATH. +# Usage: ./scripts/install.sh [--shell fish|zsh|bash] + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BIN_DIR="$HOME/.local/bin" +BIN_PATH="$BIN_DIR/orama" + +# --- Parse args --- +SHELL_NAME="" +while [[ $# -gt 0 ]]; do + case "$1" in + --shell) SHELL_NAME="$2"; shift 2 ;; + -h|--help) + echo "Usage: ./scripts/install.sh [--shell fish|zsh|bash]" + echo "" + echo "Builds the Orama CLI and installs 'orama' to ~/.local/bin." + echo "If --shell is not provided, auto-detects from \$SHELL." + exit 0 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# Auto-detect shell +if [[ -z "$SHELL_NAME" ]]; then + case "$SHELL" in + */fish) SHELL_NAME="fish" ;; + */zsh) SHELL_NAME="zsh" ;; + */bash) SHELL_NAME="bash" ;; + *) SHELL_NAME="unknown" ;; + esac +fi + +echo "==> Shell: $SHELL_NAME" + +# --- Build --- +echo "==> Building Orama CLI..." +(cd "$PROJECT_DIR" && make build) + +# --- Install binary --- +mkdir -p "$BIN_DIR" +cp -f "$PROJECT_DIR/bin/orama" "$BIN_PATH" +chmod +x "$BIN_PATH" +echo "==> Installed $BIN_PATH" + +# --- Ensure PATH --- +add_to_path() { + local rc_file="$1" + local line="$2" + + if [[ -f "$rc_file" ]] && grep -qF "$line" "$rc_file"; then + echo "==> PATH already configured in $rc_file" + else + echo "" >> "$rc_file" + echo "$line" >> "$rc_file" + echo "==> Added PATH to $rc_file" + fi +} + +case "$SHELL_NAME" in + fish) + FISH_CONFIG="$HOME/.config/fish/config.fish" + mkdir -p "$(dirname "$FISH_CONFIG")" + add_to_path "$FISH_CONFIG" "fish_add_path $BIN_DIR" + ;; + zsh) + add_to_path "$HOME/.zshrc" "export PATH=\"$BIN_DIR:\$PATH\"" + ;; + bash) + add_to_path "$HOME/.bashrc" "export PATH=\"$BIN_DIR:\$PATH\"" + ;; + *) + echo "==> Unknown shell. Add this to your shell config manually:" + echo " export PATH=\"$BIN_DIR:\$PATH\"" + ;; +esac + +# --- Verify --- +VERSION=$("$BIN_PATH" version 2>/dev/null || echo "unknown") +echo "" +echo "==> Orama CLI ${VERSION} installed!" +echo " Run: orama --help" +echo "" +if [[ "$SHELL_NAME" != "unknown" ]]; then + echo " Restart your terminal or run:" + case "$SHELL_NAME" in + fish) echo " source ~/.config/fish/config.fish" ;; + zsh) echo " source ~/.zshrc" ;; + bash) echo " source ~/.bashrc" ;; + esac +fi