diff --git a/pkg/cli/cmd/sandboxcmd/sandbox.go b/pkg/cli/cmd/sandboxcmd/sandbox.go index f922053..42043a0 100644 --- a/pkg/cli/cmd/sandboxcmd/sandbox.go +++ b/pkg/cli/cmd/sandboxcmd/sandbox.go @@ -23,7 +23,8 @@ Usage: orama sandbox list List active sandboxes orama sandbox status [--name ] Show cluster health orama sandbox rollout [--name ] Build + push + rolling upgrade - orama sandbox ssh SSH into a sandbox node (1-5)`, + orama sandbox ssh 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 ", 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) } diff --git a/pkg/cli/sandbox/config.go b/pkg/cli/sandbox/config.go index f1ba9ca..11eb410 100644 --- a/pkg/cli/sandbox/config.go +++ b/pkg/cli/sandbox/config.go @@ -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" } } diff --git a/pkg/cli/sandbox/hetzner.go b/pkg/cli/sandbox/hetzner.go index 742349e..51d62a0 100644 --- a/pkg/cli/sandbox/hetzner.go +++ b/pkg/cli/sandbox/hetzner.go @@ -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. diff --git a/pkg/cli/sandbox/reset.go b/pkg/cli/sandbox/reset.go new file mode 100644 index 0000000..dbc4dae --- /dev/null +++ b/pkg/cli/sandbox/reset.go @@ -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 +} diff --git a/pkg/cli/sandbox/setup.go b/pkg/cli/sandbox/setup.go index d4d07c1..f702422 100644 --- a/pkg/cli/sandbox/setup.go +++ b/pkg/cli/sandbox/setup.go @@ -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) } }