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
116 changes: 116 additions & 0 deletions providers/app-studio/api/preview_edge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
Copyright 2026 The Faros Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0
*/

package api

import (
"context"
"fmt"
"net/http"
"sync"
"time"
)

// Edge readiness for preview URLs.
//
// A development instance's status.url exists as soon as its HTTPRoute does —
// but in production the Gateway edge (cfgate / Cloudflare Tunnel) still has
// to program DNS and provision the hostname's TLS certificate, which takes a
// minute or more. Declaring the preview Ready on status.url alone hands the
// portal a URL whose TLS handshake fails, so the user stares at a broken
// iframe with no explanation. The preview path therefore probes the URL and
// keeps reporting "provisioning" — with a reason the portal can render —
// until the edge actually serves it.
//
// Successes are cached per URL for the process lifetime: certificates and
// tunnel routes persist once provisioned, so after the first success the
// probe adds no latency to preview calls. Failures are re-probed on every
// call (that's the polling the portal already does).

const (
// previewEdgeProbeTimeout bounds one probe attempt. The portal polls, so
// a slow edge costs one bounded round-trip per poll, never a hang.
previewEdgeProbeTimeout = 4 * time.Second

// previewReasonEdgeProvisioning is the machine-readable reason the
// portal and the assistant's preview tool receive while the edge is
// still being provisioned.
previewReasonEdgeProvisioning = "edge_provisioning"

// previewEdgeProvisioningMessage is the human message for that state.
previewEdgeProvisioningMessage = "Preview is getting ready. The public URL is being provisioned at the edge (DNS + TLS certificate) — this usually takes a minute or two."
)

// previewEdgeReady reports whether url is actually served at the edge. The
// probe function is injectable for tests (s.previewEdgeProbe); the default
// performs a real HTTPS request with certificate verification ON — an
// unprovisioned certificate MUST fail this check, that is the signal.
func (s *Server) previewEdgeReady(ctx context.Context, url string) bool {
if _, ok := s.edgeReadyURLs.Load(url); ok {
return true
}
probe := s.previewEdgeProbeHook()
if err := probe(ctx, url); err != nil {
return false
}
s.edgeReadyURLs.Store(url, struct{}{})
return true
}

func (s *Server) previewEdgeProbeHook() func(context.Context, string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.previewEdgeProbe != nil {
return s.previewEdgeProbe
}
return defaultPreviewEdgeProbe
}

// SetPreviewEdgeProbe overrides the edge probe (tests).
func (s *Server) SetPreviewEdgeProbe(probe func(context.Context, string) error) {
s.mu.Lock()
defer s.mu.Unlock()
s.previewEdgeProbe = probe
}

// defaultPreviewEdgeProbe GETs the URL and decides whether the edge serves
// it. Any transport error (DNS, connection, TLS handshake — the certificate
// case) means not-yet-provisioned. An HTTP response means the edge
// terminated TLS and routed somewhere — except Cloudflare's 52x range, which
// is the edge itself reporting the origin/tunnel isn't wired yet (520-527,
// 530). Application-level statuses (200/302/404/503 from the app or the dev
// server) all count as served: the dev-runtime data plane owns app
// readiness, this probe owns the edge.
func defaultPreviewEdgeProbe(ctx context.Context, url string) error {
ctx, cancel := context.WithTimeout(ctx, previewEdgeProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{
Timeout: previewEdgeProbeTimeout,
// Don't chase the app's redirects — the first edge answer decides.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode >= 520 && resp.StatusCode <= 530 {
return fmt.Errorf("edge answered %d (origin/tunnel not provisioned)", resp.StatusCode)
}
Comment on lines +108 to +110
return nil
}

// edgeReadyURLsCache is embedded in Server; declared here so the whole edge
// concern lives in one file.
type edgeReadyURLsCache = sync.Map
93 changes: 93 additions & 0 deletions providers/app-studio/api/preview_edge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
Copyright 2026 The Faros Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0
*/

package api

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)

// TestPreviewEdgeReadyGatesAndCaches pins the edge-readiness contract: a
// failing probe keeps the preview not-ready, a succeeding probe flips it, and
// the success is cached — once provisioned, the edge is never probed again
// for that URL (certificates persist; polling must not pay probe latency
// forever).
func TestPreviewEdgeReadyGatesAndCaches(t *testing.T) {
s := &Server{}
probeErr := errors.New("tls handshake failure")
calls := 0
s.SetPreviewEdgeProbe(func(_ context.Context, _ string) error {
calls++
return probeErr
})

const url = "https://demo-abc.apps.example.com"
if s.previewEdgeReady(context.Background(), url) {
t.Fatal("edge reported ready while the probe fails")
}
if s.previewEdgeReady(context.Background(), url) {
t.Fatal("edge reported ready while the probe still fails")
}
if calls != 2 {
t.Fatalf("failing probe must be retried every call; got %d calls", calls)
}

// Edge provisioned: probe succeeds once, then the cache answers.
s.SetPreviewEdgeProbe(func(_ context.Context, _ string) error { return nil })
if !s.previewEdgeReady(context.Background(), url) {
t.Fatal("edge not ready after successful probe")
}
s.SetPreviewEdgeProbe(func(_ context.Context, _ string) error {
t.Fatal("probe called for a URL already known ready")
return nil
})
if !s.previewEdgeReady(context.Background(), url) {
t.Fatal("cached readiness lost")
}

// A different URL starts cold.
s.SetPreviewEdgeProbe(func(_ context.Context, _ string) error { return probeErr })
if s.previewEdgeReady(context.Background(), "https://other.apps.example.com") {
t.Fatal("readiness cache leaked across URLs")
}
}

// TestDefaultPreviewEdgeProbe pins the classification: an HTTP answer is
// served (whatever the app-level status), Cloudflare's 52x edge range is
// not-provisioned, and transport errors (connection refused — the same class
// as a TLS handshake failure) are not-provisioned.
func TestDefaultPreviewEdgeProbe(t *testing.T) {
served := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound) // app-level 404 still means the edge serves
}))
defer served.Close()
if err := defaultPreviewEdgeProbe(context.Background(), served.URL); err != nil {
t.Fatalf("app-level 404 must count as served: %v", err)
}

edge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(522) // Cloudflare: connection timed out to origin
}))
defer edge.Close()
if err := defaultPreviewEdgeProbe(context.Background(), edge.URL); err == nil {
t.Fatal("Cloudflare 522 must count as not provisioned")
}

dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
deadURL := dead.URL
dead.Close() // connection refused from here on
if err := defaultPreviewEdgeProbe(context.Background(), deadURL); err == nil {
t.Fatal("transport error must count as not provisioned")
}
Comment on lines +87 to +92
}
14 changes: 13 additions & 1 deletion providers/app-studio/api/project_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,19 @@ func (s *Server) templateDevelopmentPreview(ctx context.Context, c *asclient.Cli
Message: "Preview is getting ready. The development environment does not have a URL yet.",
}, nil
}
return projectSandboxPreviewURLResponse{Ready: true, PreviewURL: strings.TrimSpace(url)}, nil
// status.url exists as soon as the HTTPRoute does, but the Gateway edge
// (DNS + TLS certificate at Cloudflare) can take minutes to provision —
// until then the URL is a broken iframe. Keep reporting "provisioning"
// until the edge actually serves it (see preview_edge.go).
url = strings.TrimSpace(url)
if !s.previewEdgeReady(ctx, url) {
return projectSandboxPreviewURLResponse{
Ready: false,
Reason: previewReasonEdgeProvisioning,
Message: previewEdgeProvisioningMessage,
}, nil
}
return projectSandboxPreviewURLResponse{Ready: true, PreviewURL: url}, nil
}

// --- HTTP surface -----------------------------------------------------------
Expand Down
7 changes: 6 additions & 1 deletion providers/app-studio/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ limitations under the License.
package api

import (
"context"
"net/http"
"sync"

Expand Down Expand Up @@ -53,7 +54,11 @@ type Server struct {
assistantRunManager *projectAssistantRunManager
developmentSyncLocks map[string]*sync.Mutex
developmentSyncAfterMutation func(identity, *aiv1alpha1.Project, string)
mu sync.Mutex
// previewEdgeProbe + edgeReadyURLs implement the preview edge-readiness
// gate (see preview_edge.go). Nil probe → the real HTTPS probe.
previewEdgeProbe func(context.Context, string) error
edgeReadyURLs edgeReadyURLsCache
mu sync.Mutex
}

// New constructs a Server.
Expand Down
Loading