mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-17 04:33:00 +00:00
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:
parent
2f5718146a
commit
a0468461ab
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
129
pkg/cli/sandbox/reset.go
Normal 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
|
||||
}
|
||||
@ -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,24 +409,24 @@ 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)
|
||||
// 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)
|
||||
|
||||
if hetznerID == 0 {
|
||||
return SSHKeyConfig{}, fmt.Errorf("could not resolve SSH key on Hetzner, try deleting and re-running setup")
|
||||
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: existing[0].ID,
|
||||
PrivateKeyPath: "~/.orama/sandbox_key",
|
||||
PublicKeyPath: "~/.orama/sandbox_key.pub",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return SSHKeyConfig{
|
||||
HetznerID: hetznerID,
|
||||
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)
|
||||
@ -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()
|
||||
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)
|
||||
// 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 {
|
||||
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.")
|
||||
} else {
|
||||
fmt.Printf(" NS records:\n")
|
||||
for _, line := range strings.Split(result, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user