feat(sandbox): add reset command and interactive setup

- new `orama sandbox reset` deletes Hetzner resources (IPs, firewall, SSH key) and local config
- interactive location/server type selection during `setup`
- add Hetzner API methods for listing locations/types, deleting resources
- update defaults to nbg1/cx23
This commit is contained in:
anonpenguin23 2026-02-28 10:14:02 +02:00
parent 2f5718146a
commit a0468461ab
5 changed files with 566 additions and 42 deletions

View File

@ -23,7 +23,8 @@ Usage:
orama sandbox list List active sandboxes
orama sandbox status [--name <name>] Show cluster health
orama sandbox rollout [--name <name>] Build + push + rolling upgrade
orama sandbox ssh <node-number> SSH into a sandbox node (1-5)`,
orama sandbox ssh <node-number> SSH into a sandbox node (1-5)
orama sandbox reset Delete all infra and config to start fresh`,
}
var setupCmd = &cobra.Command{
@ -79,6 +80,19 @@ var rolloutCmd = &cobra.Command{
},
}
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Delete all sandbox infrastructure and config to start fresh",
Long: `Deletes floating IPs, firewall, and SSH key from Hetzner Cloud,
then removes the local config (~/.orama/sandbox.yaml) and SSH keys.
Use this when you need to switch datacenter locations (floating IPs are
location-bound) or to completely start over with sandbox setup.`,
RunE: func(cmd *cobra.Command, args []string) error {
return sandbox.Reset()
},
}
var sshCmd = &cobra.Command{
Use: "ssh <node-number>",
Short: "SSH into a sandbox node (1-5)",
@ -118,4 +132,5 @@ func init() {
Cmd.AddCommand(statusCmd)
Cmd.AddCommand(rolloutCmd)
Cmd.AddCommand(sshCmd)
Cmd.AddCommand(resetCmd)
}

View File

@ -123,10 +123,10 @@ func (c *Config) validate() error {
// Defaults fills in default values for optional fields.
func (c *Config) Defaults() {
if c.Location == "" {
c.Location = "fsn1"
c.Location = "nbg1"
}
if c.ServerType == "" {
c.ServerType = "cx22"
c.ServerType = "cx23"
}
}

View File

@ -344,6 +344,23 @@ func (c *HetznerClient) UploadSSHKey(name, publicKey string) (*HetznerSSHKey, er
return &resp.SSHKey, nil
}
// ListSSHKeysByFingerprint finds SSH keys matching a fingerprint.
func (c *HetznerClient) ListSSHKeysByFingerprint(fingerprint string) ([]HetznerSSHKey, error) {
body, err := c.get("/ssh_keys?fingerprint=" + fingerprint)
if err != nil {
return nil, fmt.Errorf("list SSH keys: %w", err)
}
var resp struct {
SSHKeys []HetznerSSHKey `json:"ssh_keys"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parse SSH keys response: %w", err)
}
return resp.SSHKeys, nil
}
// GetSSHKey retrieves an SSH key by ID.
func (c *HetznerClient) GetSSHKey(id int64) (*HetznerSSHKey, error) {
body, err := c.get("/ssh_keys/" + strconv.FormatInt(id, 10))
@ -408,6 +425,85 @@ func (c *HetznerClient) DeleteFirewall(id int64) error {
return c.delete("/firewalls/" + strconv.FormatInt(id, 10))
}
// DeleteFloatingIP deletes a floating IP by ID.
func (c *HetznerClient) DeleteFloatingIP(id int64) error {
return c.delete("/floating_ips/" + strconv.FormatInt(id, 10))
}
// DeleteSSHKey deletes an SSH key by ID.
func (c *HetznerClient) DeleteSSHKey(id int64) error {
return c.delete("/ssh_keys/" + strconv.FormatInt(id, 10))
}
// --- Location & Server Type operations ---
// HetznerLocation represents a Hetzner datacenter location.
type HetznerLocation struct {
ID int64 `json:"id"`
Name string `json:"name"` // e.g., "fsn1", "nbg1", "hel1"
Description string `json:"description"` // e.g., "Falkenstein DC Park 1"
City string `json:"city"`
Country string `json:"country"` // ISO 3166-1 alpha-2
}
// HetznerServerType represents a Hetzner server type with pricing.
type HetznerServerType struct {
ID int64 `json:"id"`
Name string `json:"name"` // e.g., "cx22", "cx23"
Description string `json:"description"` // e.g., "CX23"
Cores int `json:"cores"`
Memory float64 `json:"memory"` // GB
Disk int `json:"disk"` // GB
Architecture string `json:"architecture"`
Deprecation *struct {
Announced string `json:"announced"`
UnavailableAfter string `json:"unavailable_after"`
} `json:"deprecation"` // nil = not deprecated
Prices []struct {
Location string `json:"location"`
Hourly struct {
Gross string `json:"gross"`
} `json:"price_hourly"`
Monthly struct {
Gross string `json:"gross"`
} `json:"price_monthly"`
} `json:"prices"`
}
// ListLocations returns all available Hetzner datacenter locations.
func (c *HetznerClient) ListLocations() ([]HetznerLocation, error) {
body, err := c.get("/locations")
if err != nil {
return nil, fmt.Errorf("list locations: %w", err)
}
var resp struct {
Locations []HetznerLocation `json:"locations"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parse locations response: %w", err)
}
return resp.Locations, nil
}
// ListServerTypes returns all available server types.
func (c *HetznerClient) ListServerTypes() ([]HetznerServerType, error) {
body, err := c.get("/server_types?per_page=50")
if err != nil {
return nil, fmt.Errorf("list server types: %w", err)
}
var resp struct {
ServerTypes []HetznerServerType `json:"server_types"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parse server types response: %w", err)
}
return resp.ServerTypes, nil
}
// --- Validation ---
// ValidateToken checks if the API token is valid by making a simple request.

129
pkg/cli/sandbox/reset.go Normal file
View File

@ -0,0 +1,129 @@
package sandbox
import (
"bufio"
"fmt"
"os"
"strings"
)
// Reset tears down all sandbox infrastructure (floating IPs, firewall, SSH key)
// and removes the config file so the user can rerun setup from scratch.
// This is useful when switching datacenter locations (floating IPs are location-bound).
func Reset() error {
fmt.Println("Sandbox Reset")
fmt.Println("=============")
fmt.Println()
cfg, err := LoadConfig()
if err != nil {
// Config doesn't exist — just clean up any local files
fmt.Println("No sandbox config found. Cleaning up local files...")
return resetLocalFiles()
}
// Check for active sandboxes — refuse to reset if clusters are still running
active, _ := FindActiveSandbox()
if active != nil {
return fmt.Errorf("active sandbox %q exists — run 'orama sandbox destroy' first", active.Name)
}
// Show what will be deleted
fmt.Println("This will delete the following Hetzner resources:")
for i, fip := range cfg.FloatingIPs {
fmt.Printf(" Floating IP %d: %s (ID: %d)\n", i+1, fip.IP, fip.ID)
}
if cfg.FirewallID != 0 {
fmt.Printf(" Firewall ID: %d\n", cfg.FirewallID)
}
if cfg.SSHKey.HetznerID != 0 {
fmt.Printf(" SSH Key ID: %d\n", cfg.SSHKey.HetznerID)
}
fmt.Println()
fmt.Println("Local files to remove:")
fmt.Println(" ~/.orama/sandbox.yaml")
fmt.Println(" ~/.orama/sandbox_key")
fmt.Println(" ~/.orama/sandbox_key.pub")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
fmt.Print("Delete all sandbox resources? [y/N]: ")
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(strings.ToLower(choice))
if choice != "y" && choice != "yes" {
fmt.Println("Aborted.")
return nil
}
client := NewHetznerClient(cfg.HetznerAPIToken)
// Step 1: Delete floating IPs
fmt.Println()
fmt.Println("Deleting floating IPs...")
for _, fip := range cfg.FloatingIPs {
if err := client.DeleteFloatingIP(fip.ID); err != nil {
fmt.Fprintf(os.Stderr, " Warning: could not delete floating IP %s (ID %d): %v\n", fip.IP, fip.ID, err)
} else {
fmt.Printf(" Deleted %s (ID %d)\n", fip.IP, fip.ID)
}
}
// Step 2: Delete firewall
if cfg.FirewallID != 0 {
fmt.Println("Deleting firewall...")
if err := client.DeleteFirewall(cfg.FirewallID); err != nil {
fmt.Fprintf(os.Stderr, " Warning: could not delete firewall (ID %d): %v\n", cfg.FirewallID, err)
} else {
fmt.Printf(" Deleted firewall (ID %d)\n", cfg.FirewallID)
}
}
// Step 3: Delete SSH key from Hetzner
if cfg.SSHKey.HetznerID != 0 {
fmt.Println("Deleting SSH key from Hetzner...")
if err := client.DeleteSSHKey(cfg.SSHKey.HetznerID); err != nil {
fmt.Fprintf(os.Stderr, " Warning: could not delete SSH key (ID %d): %v\n", cfg.SSHKey.HetznerID, err)
} else {
fmt.Printf(" Deleted SSH key (ID %d)\n", cfg.SSHKey.HetznerID)
}
}
// Step 4: Remove local files
if err := resetLocalFiles(); err != nil {
return err
}
fmt.Println()
fmt.Println("Reset complete. All sandbox resources deleted.")
fmt.Println()
fmt.Println("Next: orama sandbox setup")
return nil
}
// resetLocalFiles removes the sandbox config and SSH key files.
func resetLocalFiles() error {
dir, err := configDir()
if err != nil {
return err
}
files := []string{
dir + "/sandbox.yaml",
dir + "/sandbox_key",
dir + "/sandbox_key.pub",
}
fmt.Println("Removing local files...")
for _, f := range files {
if err := os.Remove(f); err != nil {
if os.IsNotExist(err) {
continue
}
fmt.Fprintf(os.Stderr, " Warning: could not remove %s: %v\n", f, err)
} else {
fmt.Printf(" Removed %s\n", f)
}
}
return nil
}

View File

@ -8,7 +8,10 @@ import (
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
@ -56,9 +59,24 @@ func Setup() error {
HetznerAPIToken: token,
Domain: domain,
}
cfg.Defaults()
// Step 3: Floating IPs
// Step 3: Location selection
fmt.Println()
location, err := selectLocation(client, reader)
if err != nil {
return err
}
cfg.Location = location
// Step 4: Server type selection
fmt.Println()
serverType, err := selectServerType(client, reader, location)
if err != nil {
return err
}
cfg.ServerType = serverType
// Step 5: Floating IPs
fmt.Println()
fmt.Println("Checking floating IPs...")
floatingIPs, err := setupFloatingIPs(client, cfg.Location)
@ -67,7 +85,7 @@ func Setup() error {
}
cfg.FloatingIPs = floatingIPs
// Step 4: Firewall
// Step 6: Firewall
fmt.Println()
fmt.Println("Checking firewall...")
fwID, err := setupFirewall(client)
@ -76,7 +94,7 @@ func Setup() error {
}
cfg.FirewallID = fwID
// Step 5: SSH key
// Step 7: SSH key
fmt.Println()
fmt.Println("Setting up SSH key...")
sshKeyConfig, err := setupSSHKey(client)
@ -85,7 +103,7 @@ func Setup() error {
}
cfg.SSHKey = sshKeyConfig
// Step 6: Display DNS instructions
// Step 8: Display DNS instructions
fmt.Println()
fmt.Println("DNS Configuration")
fmt.Println("-----------------")
@ -100,12 +118,12 @@ func Setup() error {
fmt.Printf(" ns2.%s\n", domain)
fmt.Println()
// Step 7: Verify DNS (optional)
// Step 9: Verify DNS (optional)
fmt.Print("Verify DNS now? [y/N]: ")
verifyChoice, _ := reader.ReadString('\n')
verifyChoice = strings.TrimSpace(strings.ToLower(verifyChoice))
if verifyChoice == "y" || verifyChoice == "yes" {
verifyDNS(domain)
verifyDNS(domain, cfg.FloatingIPs, reader)
}
// Save config
@ -120,6 +138,180 @@ func Setup() error {
return nil
}
// selectLocation fetches available Hetzner locations and lets the user pick one.
func selectLocation(client *HetznerClient, reader *bufio.Reader) (string, error) {
fmt.Println("Fetching available locations...")
locations, err := client.ListLocations()
if err != nil {
return "", fmt.Errorf("list locations: %w", err)
}
sort.Slice(locations, func(i, j int) bool {
return locations[i].Name < locations[j].Name
})
defaultLoc := "nbg1"
fmt.Println(" Available datacenter locations:")
for i, loc := range locations {
def := ""
if loc.Name == defaultLoc {
def = " (default)"
}
fmt.Printf(" %d) %s — %s, %s%s\n", i+1, loc.Name, loc.City, loc.Country, def)
}
fmt.Printf("\n Select location [%s]: ", defaultLoc)
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
if choice == "" {
fmt.Printf(" Using %s\n", defaultLoc)
return defaultLoc, nil
}
// Try as number first
if num, err := strconv.Atoi(choice); err == nil && num >= 1 && num <= len(locations) {
loc := locations[num-1].Name
fmt.Printf(" Using %s\n", loc)
return loc, nil
}
// Try as location name
for _, loc := range locations {
if strings.EqualFold(loc.Name, choice) {
fmt.Printf(" Using %s\n", loc.Name)
return loc.Name, nil
}
}
return "", fmt.Errorf("unknown location %q", choice)
}
// selectServerType fetches available server types for a location and lets the user pick one.
func selectServerType(client *HetznerClient, reader *bufio.Reader, location string) (string, error) {
fmt.Println("Fetching available server types...")
serverTypes, err := client.ListServerTypes()
if err != nil {
return "", fmt.Errorf("list server types: %w", err)
}
// Filter to x86 shared-vCPU types available at the selected location, skip deprecated
type option struct {
name string
cores int
memory float64
disk int
hourly string
monthly string
}
var options []option
for _, st := range serverTypes {
if st.Architecture != "x86" {
continue
}
if st.Deprecation != nil {
continue
}
// Only show shared-vCPU types (cx/cpx prefixes) — skip dedicated (ccx/cx5x)
if !strings.HasPrefix(st.Name, "cx") && !strings.HasPrefix(st.Name, "cpx") {
continue
}
// Find pricing for the selected location
hourly, monthly := "", ""
for _, p := range st.Prices {
if p.Location == location {
hourly = p.Hourly.Gross
monthly = p.Monthly.Gross
break
}
}
if hourly == "" {
continue // Not available in this location
}
options = append(options, option{
name: st.Name,
cores: st.Cores,
memory: st.Memory,
disk: st.Disk,
hourly: hourly,
monthly: monthly,
})
}
if len(options) == 0 {
return "", fmt.Errorf("no server types available in %s", location)
}
// Sort by hourly price (cheapest first)
sort.Slice(options, func(i, j int) bool {
pi, _ := strconv.ParseFloat(options[i].hourly, 64)
pj, _ := strconv.ParseFloat(options[j].hourly, 64)
return pi < pj
})
defaultType := options[0].name // cheapest
fmt.Printf(" Available server types in %s:\n", location)
for i, opt := range options {
def := ""
if opt.name == defaultType {
def = " (default)"
}
fmt.Printf(" %d) %-8s %d vCPU / %4.0f GB RAM / %3d GB disk — €%s/hr (€%s/mo)%s\n",
i+1, opt.name, opt.cores, opt.memory, opt.disk, formatPrice(opt.hourly), formatPrice(opt.monthly), def)
}
fmt.Printf("\n Select server type [%s]: ", defaultType)
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
if choice == "" {
fmt.Printf(" Using %s (×5 nodes ≈ €%s/hr)\n", defaultType, multiplyPrice(options[0].hourly, 5))
return defaultType, nil
}
// Try as number
if num, err := strconv.Atoi(choice); err == nil && num >= 1 && num <= len(options) {
opt := options[num-1]
fmt.Printf(" Using %s (×5 nodes ≈ €%s/hr)\n", opt.name, multiplyPrice(opt.hourly, 5))
return opt.name, nil
}
// Try as name
for _, opt := range options {
if strings.EqualFold(opt.name, choice) {
fmt.Printf(" Using %s (×5 nodes ≈ €%s/hr)\n", opt.name, multiplyPrice(opt.hourly, 5))
return opt.name, nil
}
}
return "", fmt.Errorf("unknown server type %q", choice)
}
// formatPrice trims trailing zeros from a price string like "0.0063000000000000" → "0.0063".
func formatPrice(price string) string {
f, err := strconv.ParseFloat(price, 64)
if err != nil {
return price
}
// Use enough precision then trim trailing zeros
s := fmt.Sprintf("%.4f", f)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
return s
}
// multiplyPrice multiplies a price string by n and returns formatted.
func multiplyPrice(price string, n int) string {
f, err := strconv.ParseFloat(price, 64)
if err != nil {
return "?"
}
return formatPrice(fmt.Sprintf("%.10f", f*float64(n)))
}
// setupFloatingIPs checks for existing floating IPs or creates new ones.
func setupFloatingIPs(client *HetznerClient, location string) ([]FloatIP, error) {
existing, err := client.ListFloatingIPsByLabel("orama-sandbox-dns=true")
@ -217,26 +409,26 @@ func setupSSHKey(client *HetznerClient) (SSHKeyConfig, error) {
// Try to upload (will fail with uniqueness error if already exists)
key, err := client.UploadSSHKey("orama-sandbox", strings.TrimSpace(string(pubData)))
if err != nil {
// Key likely already exists on Hetzner — find it by listing
fmt.Printf(" SSH key may already be on Hetzner (upload: %v)\n", err)
fmt.Print(" Enter the Hetzner SSH key ID (or 0 to re-upload): ")
reader := bufio.NewReader(os.Stdin)
idStr, _ := reader.ReadString('\n')
idStr = strings.TrimSpace(idStr)
var hetznerID int64
fmt.Sscanf(idStr, "%d", &hetznerID)
if hetznerID == 0 {
return SSHKeyConfig{}, fmt.Errorf("could not resolve SSH key on Hetzner, try deleting and re-running setup")
// Key already exists on Hetzner — find it by fingerprint
sshPubKey, _, _, _, parseErr := ssh.ParseAuthorizedKey(pubData)
if parseErr != nil {
return SSHKeyConfig{}, fmt.Errorf("parse public key to find fingerprint: %w", parseErr)
}
fingerprint := ssh.FingerprintLegacyMD5(sshPubKey)
existing, listErr := client.ListSSHKeysByFingerprint(fingerprint)
if listErr == nil && len(existing) > 0 {
fmt.Printf(" Found existing SSH key on Hetzner (ID: %d)\n", existing[0].ID)
return SSHKeyConfig{
HetznerID: hetznerID,
HetznerID: existing[0].ID,
PrivateKeyPath: "~/.orama/sandbox_key",
PublicKeyPath: "~/.orama/sandbox_key.pub",
}, nil
}
return SSHKeyConfig{}, fmt.Errorf("SSH key exists locally but could not find it on Hetzner (fingerprint: %s): %w", fingerprint, err)
}
fmt.Printf(" Uploaded to Hetzner (ID: %d)\n", key.ID)
return SSHKeyConfig{
HetznerID: key.ID,
@ -294,26 +486,118 @@ func setupSSHKey(client *HetznerClient) (SSHKeyConfig, error) {
}, nil
}
// verifyDNS checks if the sandbox domain resolves.
func verifyDNS(domain string) {
fmt.Printf(" Checking NS records for %s...\n", domain)
out, err := exec.Command("dig", "+short", "NS", domain, "@8.8.8.8").Output()
// verifyDNS checks if glue records for the sandbox domain are configured.
//
// There's a chicken-and-egg problem: NS records can't fully resolve until
// CoreDNS is running on the floating IPs (which requires a sandbox cluster).
// So instead of resolving NS → A records, we check for glue records at the
// TLD level, which proves the registrar configuration is correct.
func verifyDNS(domain string, floatingIPs []FloatIP, reader *bufio.Reader) {
expectedIPs := make(map[string]bool)
for _, fip := range floatingIPs {
expectedIPs[fip.IP] = true
}
// Find the TLD nameserver to query for glue records
findTLDServer := func() string {
// For "dbrs.space", the TLD is "space." — ask the root for its NS
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return ""
}
tld := parts[len(parts)-1]
out, err := exec.Command("dig", "+short", "NS", tld+".", "@8.8.8.8").Output()
if err != nil {
fmt.Printf(" Warning: dig failed: %v\n", err)
fmt.Println(" DNS verification skipped. You can verify later with:")
fmt.Printf(" dig NS %s @8.8.8.8\n", domain)
return ""
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) > 0 && lines[0] != "" {
return strings.TrimSpace(lines[0])
}
return ""
}
check := func() (glueFound bool, foundIPs []string) {
tldNS := findTLDServer()
if tldNS == "" {
return false, nil
}
// Query the TLD nameserver for NS + glue of our domain
// dig NS domain @tld-server will include glue in ADDITIONAL section
out, err := exec.Command("dig", "NS", domain, "@"+tldNS, "+norecurse", "+additional").Output()
if err != nil {
return false, nil
}
output := string(out)
remaining := make(map[string]bool)
for k, v := range expectedIPs {
remaining[k] = v
}
// Look for our floating IPs in the ADDITIONAL section (glue records)
// or anywhere in the response
for _, fip := range floatingIPs {
if strings.Contains(output, fip.IP) {
foundIPs = append(foundIPs, fip.IP)
delete(remaining, fip.IP)
}
}
return len(remaining) == 0, foundIPs
}
fmt.Printf(" Checking glue records for %s at TLD nameserver...\n", domain)
matched, foundIPs := check()
if matched {
fmt.Println(" ✓ Glue records configured correctly:")
for i, ip := range foundIPs {
fmt.Printf(" ns%d.%s → %s\n", i+1, domain, ip)
}
fmt.Println()
fmt.Println(" Note: Full DNS resolution will work once a sandbox is running")
fmt.Println(" (CoreDNS on the floating IPs needs to be up to answer queries).")
return
}
result := strings.TrimSpace(string(out))
if result == "" {
fmt.Println(" Warning: No NS records found yet.")
fmt.Println(" DNS propagation can take up to 48 hours.")
fmt.Println(" The sandbox will still work once DNS is configured.")
if len(foundIPs) > 0 {
fmt.Println(" ⚠ Partial glue records found:")
for _, ip := range foundIPs {
fmt.Printf(" %s\n", ip)
}
fmt.Println(" Missing floating IPs in glue:")
for _, fip := range floatingIPs {
if expectedIPs[fip.IP] {
fmt.Printf(" %s\n", fip.IP)
}
}
} else {
fmt.Printf(" NS records:\n")
for _, line := range strings.Split(result, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println(" ✗ No glue records found yet.")
fmt.Println(" Make sure you configured at your registrar:")
fmt.Printf(" ns1.%s → %s\n", domain, floatingIPs[0].IP)
fmt.Printf(" ns2.%s → %s\n", domain, floatingIPs[1].IP)
}
fmt.Println()
fmt.Print(" Wait for glue propagation? (polls every 30s, Ctrl+C to stop) [y/N]: ")
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(strings.ToLower(choice))
if choice != "y" && choice != "yes" {
fmt.Println(" Skipping. You can create the sandbox now — DNS will work once glue propagates.")
return
}
fmt.Println(" Waiting for glue record propagation...")
for i := 1; ; i++ {
time.Sleep(30 * time.Second)
matched, _ = check()
if matched {
fmt.Printf("\n ✓ Glue records propagated after %d checks\n", i)
fmt.Println(" You can now create a sandbox: orama sandbox create")
return
}
fmt.Printf(" [%d] Not yet... checking again in 30s\n", i)
}
}