Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions internal/stack/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ func Down(cfg *config.Config, u *ui.UI) error {
return fmt.Errorf("failed to load backend: %w", err)
}

// Cluster delete invalidates any active quick tunnel URL. Warn first so
// users with sellers registered against the URL aren't blindsided.
currentURL, _ := tunnel.GetTunnelURL(cfg)
if !tunnel.ConfirmQuickTunnelLoss(cfg, u, currentURL, "obol stack down") {
u.Info("Aborted.")

return nil
}

// Stop the DNS resolver container
dns.Stop()

Expand Down
51 changes: 44 additions & 7 deletions internal/tunnel/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,36 @@ func EnsureRunning(cfg *config.Config, u *ui.UI) (string, error) {
return WaitReady(cfg, u)
}

// ConfirmQuickTunnelLoss warns the user when a destructive action is about to
// invalidate an active quick tunnel URL, and asks whether to proceed. Returns
// true when the caller should continue.
//
// Quick tunnels get a fresh *.trycloudflare.com URL on every cluster recreate
// or `obol tunnel restart`, so anyone who bookmarked or registered the old URL
// will see 530 errors until they re-discover via /skill.md. Persistent (DNS)
// tunnels are stable across these events and skip the warning.
//
// Pass currentURL as discovered from the running cloudflared pod (or "" when
// none). In non-interactive sessions, Confirm returns its default (true), so
// automation and CI flows print the warning but do not block.
func ConfirmQuickTunnelLoss(cfg *config.Config, u *ui.UI, currentURL, action string) bool {
if st, _ := loadTunnelState(cfg); st != nil && st.Hostname != "" {
return true
}

if currentURL == "" {
return true
}

u.Blank()
u.Warnf("Quick tunnel URL will be invalidated: %s", currentURL)
u.Dim(fmt.Sprintf(" After `%s`, the next `obol sell http` brings up a fresh URL.", action))
u.Dim(" Buyers using the old URL will see 530 errors.")
u.Dim(" For a permanent URL: obol tunnel login --hostname stack.example.com")

return u.Confirm("Continue?", true)
}

// Restart restarts the cloudflared deployment and propagates the new tunnel
// URL to dependent resources (obol-stack-config ConfigMap, agent overlay,
// storefront HTTPRoute hostname pin). Quick tunnels get a new URL on every
Expand All @@ -303,6 +333,13 @@ func Restart(cfg *config.Config, u *ui.UI) error {
return errors.New("stack not running, use 'obol stack up' first")
}

currentURL, _ := GetTunnelURL(cfg)
if !ConfirmQuickTunnelLoss(cfg, u, currentURL, "obol tunnel restart") {
u.Info("Aborted.")

return nil
}

cmd := exec.Command(kubectlPath,
"--kubeconfig", kubeconfigPath,
"rollout", "restart", "deployment/cloudflared",
Expand Down Expand Up @@ -531,13 +568,13 @@ func CreateStorefront(cfg *config.Config, tunnelURL string) error {
},
"spec": map[string]any{
"containers": []map[string]any{
{
"name": "storefront",
"image": images.Resolve("ghcr.io/obolnetwork/obol-stack-public-storefront"),
"imagePullPolicy": "IfNotPresent",
"ports": []map[string]any{
{"containerPort": 3000, "name": "http"},
},
{
"name": "storefront",
"image": images.Resolve("ghcr.io/obolnetwork/obol-stack-public-storefront"),
"imagePullPolicy": "IfNotPresent",
"ports": []map[string]any{
{"containerPort": 3000, "name": "http"},
},
"env": []map[string]string{
{"name": "SERVICES_URL", "value": "http://obol-skill-md.x402.svc:8080"},
},
Expand Down
30 changes: 30 additions & 0 deletions internal/tunnel/tunnel_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/ui"
)

func testConfig(t *testing.T) *config.Config {
Expand Down Expand Up @@ -303,3 +304,32 @@ func TestTunnelState_UpdatedAtRefreshed(t *testing.T) {
t.Errorf("UpdatedAt %v should be after %v", got.UpdatedAt, before)
}
}

// ---------------------------------------------------------------------------
// ConfirmQuickTunnelLoss
// ---------------------------------------------------------------------------

func TestConfirmQuickTunnelLoss_PersistentSkipsWarning(t *testing.T) {
cfg := testConfig(t)
if err := saveTunnelState(cfg, &tunnelState{Mode: "dns", Hostname: "stack.example.com"}); err != nil {
t.Fatalf("save: %v", err)
}

if !ConfirmQuickTunnelLoss(cfg, ui.New(false), "https://old.trycloudflare.com", "test") {
t.Error("persistent DNS tunnel should skip the warning and return true")
}
}

func TestConfirmQuickTunnelLoss_EmptyURLSkips(t *testing.T) {
if !ConfirmQuickTunnelLoss(testConfig(t), ui.New(false), "", "test") {
t.Error("empty currentURL should skip the warning and return true")
}
}

func TestConfirmQuickTunnelLoss_NonInteractivePassesThrough(t *testing.T) {
// Tests run without a TTY, so Confirm short-circuits to its default (true).
// The helper still prints the warning; here we just verify it does not block.
if !ConfirmQuickTunnelLoss(testConfig(t), ui.New(false), "https://old.trycloudflare.com", "test") {
t.Error("non-interactive Confirm should pass through with default-yes")
}
}
Loading