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
This commit is contained in:
anonpenguin23 2026-03-26 17:33:19 +02:00
parent 8ea4499052
commit 5456d57aeb
4 changed files with 394 additions and 24 deletions

View File

@ -102,6 +102,10 @@ install-hooks:
@echo "Installing git hooks..." @echo "Installing git hooks..."
@bash scripts/install-hooks.sh @bash scripts/install-hooks.sh
# Install orama CLI to ~/.local/bin and configure PATH
install: build
@bash scripts/install.sh
# Clean build artifacts # Clean build artifacts
clean: clean:
@echo "Cleaning build artifacts..." @echo "Cleaning build artifacts..."
@ -142,6 +146,7 @@ health:
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " build - Build all executables" @echo " build - Build all executables"
@echo " install - Build and install 'orama' CLI to ~/.local/bin"
@echo " clean - Clean build artifacts" @echo " clean - Clean build artifacts"
@echo " test - Run unit tests" @echo " test - Run unit tests"
@echo "" @echo ""

View File

@ -1,6 +1,7 @@
package sandbox package sandbox
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@ -10,6 +11,7 @@ import (
"github.com/DeBrosOfficial/network/pkg/cli/remotessh" "github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/DeBrosOfficial/network/pkg/inspector" "github.com/DeBrosOfficial/network/pkg/inspector"
"github.com/DeBrosOfficial/network/pkg/rwagent"
) )
// Create orchestrates the creation of a new sandbox cluster. // Create orchestrates the creation of a new sandbox cluster.
@ -19,14 +21,10 @@ func Create(name string) error {
return err return err
} }
// Resolve wallet SSH key once for all phases // --- Preflight: validate everything BEFORE spending money ---
sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget) fmt.Println("Preflight checks:")
if err != nil {
return fmt.Errorf("prepare SSH key: %w", err)
}
defer cleanup()
// Check for existing active sandbox // 1. Check for existing active sandbox
active, err := FindActiveSandbox() active, err := FindActiveSandbox()
if err != nil { if err != nil {
return err 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", return fmt.Errorf("sandbox %q is already active (status: %s)\nDestroy it first: orama sandbox destroy --name %s",
active.Name, active.Status, active.Name) 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 // Generate name if not provided
if name == "" { 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) fmt.Printf("Creating sandbox %q (%s, %d nodes)\n\n", name, cfg.Domain, 5)
client := NewHetznerClient(cfg.HetznerAPIToken)
state := &SandboxState{ state := &SandboxState{
Name: name, Name: name,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
@ -58,18 +100,22 @@ func Create(name string) error {
cleanupFailedCreate(client, state) cleanupFailedCreate(client, state)
return fmt.Errorf("provision servers: %w", err) 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 // Phase 2: Assign floating IPs
fmt.Println("\nPhase 2: Assigning floating IPs...") fmt.Println("\nPhase 2: Assigning floating IPs...")
if err := phase2AssignFloatingIPs(client, cfg, state, sshKeyPath); err != nil { if err := phase2AssignFloatingIPs(client, cfg, state, sshKeyPath); err != nil {
return fmt.Errorf("assign floating IPs: %w", err) 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 // Phase 3: Upload binary archive
fmt.Println("\nPhase 3: Uploading 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) return fmt.Errorf("upload archive: %w", err)
} }
@ -77,7 +123,7 @@ func Create(name string) error {
fmt.Println("\nPhase 4: Installing genesis node...") fmt.Println("\nPhase 4: Installing genesis node...")
if err := phase4InstallGenesis(cfg, state, sshKeyPath); err != nil { if err := phase4InstallGenesis(cfg, state, sshKeyPath); err != nil {
state.Status = StatusError state.Status = StatusError
SaveState(state) _ = SaveState(state)
return fmt.Errorf("install genesis: %w", err) return fmt.Errorf("install genesis: %w", err)
} }
@ -85,7 +131,7 @@ func Create(name string) error {
fmt.Println("\nPhase 5: Joining remaining nodes...") fmt.Println("\nPhase 5: Joining remaining nodes...")
if err := phase5JoinNodes(cfg, state, sshKeyPath); err != nil { if err := phase5JoinNodes(cfg, state, sshKeyPath); err != nil {
state.Status = StatusError state.Status = StatusError
SaveState(state) _ = SaveState(state)
return fmt.Errorf("join nodes: %w", err) return fmt.Errorf("join nodes: %w", err)
} }
@ -94,12 +140,49 @@ func Create(name string) error {
phase6Verify(cfg, state, sshKeyPath) phase6Verify(cfg, state, sshKeyPath)
state.Status = StatusRunning state.Status = StatusRunning
SaveState(state) if err := SaveState(state); err != nil {
return fmt.Errorf("save final state: %w", err)
}
printCreateSummary(cfg, state) printCreateSummary(cfg, state)
return nil 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. // resolveVaultKeyOnce resolves a wallet SSH key to a temp file.
// Returns the key path, cleanup function, and any error. // Returns the key path, cleanup function, and any error.
func resolveVaultKeyOnce(vaultTarget string) (string, func(), 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) return fmt.Errorf("timeout after %s", timeout)
} }
// phase3UploadArchive uploads the binary archive to the genesis node, then fans out // autoBuildArchive runs `make build-archive` from the project root.
// to the remaining nodes server-to-server (much faster than uploading from local machine). func autoBuildArchive() error {
func phase3UploadArchive(state *SandboxState, sshKeyPath string) error { // Find project root by looking for go.mod
archivePath := findNewestArchive() dir, err := findProjectRoot()
if archivePath == "" { if err != nil {
fmt.Println(" No binary archive found, run `orama build` first") return fmt.Errorf("find project root: %w", err)
return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)")
} }
info, _ := os.Stat(archivePath) cmd := exec.Command("make", "build-archive")
fmt.Printf(" Archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size())) 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 { if err := fanoutArchive(state.Servers, sshKeyPath, archivePath); err != nil {
return err return err

View File

@ -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)
}
}
}

95
scripts/install.sh Executable file
View File

@ -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