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
16 changes: 15 additions & 1 deletion internal/serviceoffercontroller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ type Controller struct {
agentQueue workqueue.TypedRateLimitingInterface[string]
staticSiteMu sync.Mutex

// upstreamOpenAPICache is populated from each offer's own reconcile
// (refresh, outside staticSiteMu) and only ever read by
// reconcileStaticSite's buildOfferBundles call (under staticSiteMu) —
// see upstream_openapi.go.
upstreamOpenAPICache upstreamOpenAPICache

pendingAuths sync.Map // key: "ns/name" → []map[string]string

httpClient *http.Client
Expand Down Expand Up @@ -470,6 +476,7 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error {
if err := c.reconcileStaticSite(ctx, &tombstone); err != nil {
return err
}
c.upstreamOpenAPICache.forget(offer.UID)
return c.removeFinalizer(ctx, raw, serviceOfferFinalizer)
}

Expand Down Expand Up @@ -661,6 +668,13 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error {
// requiring a spec mutation or unrelated ConfigMap update.
c.offerQueue.AddAfter(offer.Namespace+"/"+offer.Name, 5*time.Second)
}
// Refresh this offer's upstream OpenAPI cache from its own reconcile,
// outside staticSiteMu, at most once per generation — see
// upstreamOpenAPICache and buildOfferBundles for why the rebuild below
// must never fetch live.
if offer.Spec.Hostname != "" {
c.upstreamOpenAPICache.refresh(offer, tryUpstreamOpenAPI)
}
// Rebuild the static site on every reconcile so tunnel URL changes and
// offer status updates propagate immediately. reconcileStaticSite skips
// ConfigMap/Deployment writes when the rendered hash is unchanged.
Expand Down Expand Up @@ -1339,7 +1353,7 @@ func (c *Controller) reconcileStaticSite(ctx context.Context, override *monetize
// when tunnelURL changes — see enqueueDiscoveryRefresh).
openAPIJSON := buildOpenAPIDocument(offers, baseURL, resolvedProfile)
apiDocsHTML := scalarHTML(resolvedProfile)
bundles := buildOfferBundles(offers, resolvedProfile)
bundles := buildOfferBundles(offers, resolvedProfile, c.upstreamOpenAPICache.get)
contentHash := computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)

unchanged, err := c.staticSiteContentUnchanged(ctx, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)
Expand Down
29 changes: 11 additions & 18 deletions internal/serviceoffercontroller/hostoffer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ func hostnameOffer() *monetizeapi.ServiceOffer {
return offer
}

// noUpstreamOpenAPI is the buildOfferBundles cache-lookup stub for tests
// that don't exercise the upstream-OpenAPI path.
func noUpstreamOpenAPI(*monetizeapi.ServiceOffer) map[string]any { return nil }

// TestBuildHostHTTPRoute pins the dedicated-origin route topology: Exact
// discovery rules rewriting into the offer's bundle files on the catalog
// httpd, and a PathPrefix / rule rewriting the public path-world into
Expand Down Expand Up @@ -105,15 +109,12 @@ func TestBuildHostHTTPRoute(t *testing.T) {
func TestBuildOfferBundles(t *testing.T) {
profile := schemas.StorefrontProfile{DisplayName: "Acme", ContactEmail: "ops@acme.example"}
offer := hostnameOffer()
prev := tryUpstreamOpenAPI
tryUpstreamOpenAPI = func(*monetizeapi.ServiceOffer) map[string]any { return nil }
defer func() { tryUpstreamOpenAPI = prev }()

if got := buildOfferBundles([]*monetizeapi.ServiceOffer{routeTableOffer()}, profile); len(got) != 0 {
if got := buildOfferBundles([]*monetizeapi.ServiceOffer{routeTableOffer()}, profile, noUpstreamOpenAPI); len(got) != 0 {
t.Fatalf("path-only offer produced bundles: %v", got)
}

bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{offer}, profile)
bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{offer}, profile, noUpstreamOpenAPI)
if len(bundles) != 4 {
t.Fatalf("len(bundles) = %d, want 4", len(bundles))
}
Expand Down Expand Up @@ -186,9 +187,6 @@ func TestBuildOfferBundles(t *testing.T) {
// spec.branding fields override the storefront profile on the dedicated
// origin's surfaces, empty fields inherit.
func TestBuildOfferBundles_BrandingOverride(t *testing.T) {
prev := tryUpstreamOpenAPI
tryUpstreamOpenAPI = func(*monetizeapi.ServiceOffer) map[string]any { return nil }
defer func() { tryUpstreamOpenAPI = prev }()
profile := storefront.ResolvePublished(&schemas.StorefrontProfile{
DisplayName: "Acme",
ContactEmail: "ops@acme.example",
Expand All @@ -202,7 +200,7 @@ func TestBuildOfferBundles_BrandingOverride(t *testing.T) {
Description: "**Deep** audits by AuditCo.",
}

bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{offer}, profile)
bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{offer}, profile, noUpstreamOpenAPI)
byPath := map[string]string{}
for _, f := range bundles {
byPath[f.Path] = f.Content
Expand Down Expand Up @@ -371,18 +369,15 @@ func TestStaticSiteServesChatWidget(t *testing.T) {
// Per-offer page: agent offers gain a chat.html bundle file carrying
// the landing page's theme tokens and title; non-agent offers do not.
profile := schemas.StorefrontProfile{DisplayName: "Acme"}
prev := tryUpstreamOpenAPI
tryUpstreamOpenAPI = func(*monetizeapi.ServiceOffer) map[string]any { return nil }
defer func() { tryUpstreamOpenAPI = prev }()
plain := buildOfferBundles([]*monetizeapi.ServiceOffer{hostnameOffer()}, profile)
plain := buildOfferBundles([]*monetizeapi.ServiceOffer{hostnameOffer()}, profile, noUpstreamOpenAPI)
for _, f := range plain {
if strings.HasSuffix(f.Path, "chat.html") {
t.Fatalf("non-agent offer rendered a chat page: %s", f.Path)
}
}
agent := hostnameOffer()
agent.Spec.Type = "agent"
bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{agent}, profile)
bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{agent}, profile, noUpstreamOpenAPI)
var chat string
for _, f := range bundles {
if f.Path == "offers/sec/audit/chat.html" {
Expand Down Expand Up @@ -427,8 +422,7 @@ func TestBuildOfferBundles_UpstreamOpenAPI(t *testing.T) {
offer := hostnameOffer()
offer.Spec.Registration.Name = "Hyperliquid Trading Intelligence"
offer.Spec.Registration.Description = "Full first-party catalog."
prev := tryUpstreamOpenAPI
tryUpstreamOpenAPI = func(*monetizeapi.ServiceOffer) map[string]any {
upstream := func(*monetizeapi.ServiceOffer) map[string]any {
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{"title": "upstream-title", "version": "1.1.0"},
Expand All @@ -446,8 +440,7 @@ func TestBuildOfferBundles_UpstreamOpenAPI(t *testing.T) {
},
}
}
defer func() { tryUpstreamOpenAPI = prev }()
bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{offer}, profile)
bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{offer}, profile, upstream)
byPath := map[string]string{}
for _, f := range bundles {
byPath[f.Path] = f.Content
Expand Down
13 changes: 11 additions & 2 deletions internal/serviceoffercontroller/offerbundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,16 @@ func offerBundleKey(offer *monetizeapi.ServiceOffer, file string) string {
// buildOfferBundles renders the discovery bundle for every hostname-bound,
// operationally-included offer. Deterministic order (bundle keys sorted) so
// content hashing is stable.
func buildOfferBundles(offers []*monetizeapi.ServiceOffer, profile schemas.StorefrontProfile) []offerBundleFile {
//
// upstreamOpenAPI supplies each offer's (possibly nil) upstream OpenAPI
// document. It must be a cache read, never a live fetch: this function runs
// under staticSiteMu on every offer's reconcile (see reconcileStaticSite),
// so a live HTTP call here would serialize every reconcile behind the
// slowest upstream and — on a flapping upstream — flip-flop the rebuilt
// content hash and roll the shared discovery pod. The production caller
// passes the Controller's upstreamOpenAPICache.get, refreshed independently
// from each offer's own reconcile.
func buildOfferBundles(offers []*monetizeapi.ServiceOffer, profile schemas.StorefrontProfile, upstreamOpenAPI func(*monetizeapi.ServiceOffer) map[string]any) []offerBundleFile {
var bundles []offerBundleFile
for _, offer := range offers {
if offer == nil || offer.Spec.Hostname == "" {
Expand All @@ -53,7 +62,7 @@ func buildOfferBundles(offers []*monetizeapi.ServiceOffer, profile schemas.Store
// branding block overrides the storefront profile field-wise
// (empty fields inherit).
originProfile := storefront.MergeProfile(profile, offer.Spec.Branding.ProfilePatch())
upstreamDoc := tryUpstreamOpenAPI(offer)
upstreamDoc := upstreamOpenAPI(offer)
openapiContent := buildOfferScopedOpenAPI(offer, originProfile)
x402Content := buildOfferWellKnownX402(offer)
if upstreamDoc != nil {
Expand Down
Loading