From 140ceba88ab67fefcbb34cbc18d4a0c8fa73cb09 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 22 Apr 2026 02:32:16 +0900 Subject: [PATCH] fix: aggregate ERC-8004 registration services --- internal/serviceoffercontroller/controller.go | 153 ++++++++++++---- .../serviceoffercontroller/controller_test.go | 41 +++++ internal/serviceoffercontroller/render.go | 108 ++++++++---- .../serviceoffercontroller/render_test.go | 164 +++++++++++++++++- 4 files changed, 401 insertions(+), 65 deletions(-) diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index e5d276b2..401fa6f0 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -235,15 +235,21 @@ func (c *Controller) enqueueOfferFromRegistration(obj any) { if u == nil { return } - var request monetizeapi.RegistrationRequest - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &request); err != nil { - log.Printf("serviceoffer-controller: decode registrationrequest for parent enqueue: %v", err) - return - } - if request.Spec.ServiceOfferNamespace == "" || request.Spec.ServiceOfferName == "" { - return + for _, item := range c.offerInformer.GetStore().List() { + u := asUnstructured(item) + if u == nil { + continue + } + offer, err := decodeServiceOffer(u) + if err != nil { + log.Printf("serviceoffer-controller: decode offer for registration fan-out: %v", err) + continue + } + if offer.DeletionTimestamp != nil || offer.IsPaused() || !offer.Spec.Registration.Enabled { + continue + } + c.offerQueue.Add(offer.Namespace + "/" + offer.Name) } - c.offerQueue.Add(request.Spec.ServiceOfferNamespace + "/" + request.Spec.ServiceOfferName) } func (c *Controller) enqueueDiscoveryRefresh(obj any) { @@ -418,6 +424,15 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error { if err := c.updateOfferStatus(ctx, raw, status); err != nil { return err } + if offer.Spec.Registration.Enabled { + owner, err := c.registrationOwner() + if err != nil { + return err + } + if owner != nil { + c.registrationQueue.Add(owner.Namespace + "/" + registrationRequestName(owner.Name)) + } + } if !ready { // Dependent resources like the upstream Deployment, Middleware, HTTPRoute, // and RegistrationRequest can become ready after this reconcile completes. @@ -441,6 +456,21 @@ func (c *Controller) reconcileDeletingOffer(ctx context.Context, offer *monetize return err } + if offer.Spec.Registration.Enabled { + nextOwner, err := c.registrationOwner() + if err != nil { + return err + } + if nextOwner != nil { + if err := c.deleteRegistrationRequest(ctx, offer.Namespace, offer.Name); err != nil { + return err + } + c.offerQueue.Add(nextOwner.Namespace + "/" + nextOwner.Name) + c.registrationQueue.Add(nextOwner.Namespace + "/" + registrationRequestName(nextOwner.Name)) + return nil + } + } + if !offer.Spec.Registration.Enabled && strings.TrimSpace(offer.Status.AgentID) == "" { return c.deleteRegistrationRequest(ctx, offer.Namespace, offer.Name) } @@ -565,22 +595,33 @@ func (c *Controller) reconcileRegistrationStatus(ctx context.Context, status *mo if err != nil { return err } - if owner != nil && (owner.Namespace != offer.Namespace || owner.Name != offer.Name) { + if owner == nil { + setCondition(status, "Registered", "False", "Pending", "Waiting for shared registration owner") + return nil + } + if owner.Namespace != offer.Namespace || owner.Name != offer.Name { if err := c.deleteRegistrationRequest(ctx, offer.Namespace, offer.Name); err != nil { return err } - setCondition( - status, - "Registered", - "False", - "SingletonConflict", - fmt.Sprintf("Registration path /.well-known/agent-registration.json is reserved by %s/%s", owner.Namespace, owner.Name), - ) - log.Printf("serviceoffer-controller: registration for %s/%s blocked by singleton owner %s/%s", offer.Namespace, offer.Name, owner.Namespace, owner.Name) - return nil - } - if !isConditionTrue(*status, "RoutePublished") { - setCondition(status, "Registered", "False", "WaitingForRoute", "Waiting for route publication before registration") + raw, err := c.registrationRequests.Namespace(owner.Namespace).Get(ctx, registrationRequestName(owner.Name), metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + setCondition( + status, + "Registered", + "False", + "Pending", + fmt.Sprintf("Waiting for shared registration owned by %s/%s", owner.Namespace, owner.Name), + ) + return nil + } + if err != nil { + return err + } + request, err := decodeRegistrationRequest(raw) + if err != nil { + return err + } + applySharedRegistrationStatus(status, offer, owner, request) return nil } @@ -603,14 +644,7 @@ func (c *Controller) reconcileRegistrationStatus(ctx context.Context, status *mo status.AgentID = request.Status.AgentID status.RegistrationTxHash = request.Status.RegistrationTxHash - if requestPhaseReady(request.Status.Phase) { - setCondition(status, "Registered", "True", request.Status.Phase, defaultString(request.Status.Message, "Registration reconciled")) - return nil - } - - reason := defaultString(request.Status.Phase, "Pending") - message := defaultString(request.Status.Message, "Waiting for RegistrationRequest to finish") - setCondition(status, "Registered", "False", reason, message) + applySharedRegistrationStatus(status, offer, owner, request) return nil } @@ -654,6 +688,18 @@ func (c *Controller) reconcileRegistrationRequest(ctx context.Context, key strin offerRaw, err := c.offers.Namespace(request.Spec.ServiceOfferNamespace).Get(ctx, request.Spec.ServiceOfferName, metav1.GetOptions{}) if apierrors.IsNotFound(err) { + owner, ownerErr := c.registrationOwner() + if ownerErr != nil { + return ownerErr + } + if owner != nil { + if err := c.deleteRegistrationRequest(ctx, namespace, request.Spec.ServiceOfferName); err != nil { + return err + } + c.offerQueue.Add(owner.Namespace + "/" + owner.Name) + c.registrationQueue.Add(owner.Namespace + "/" + registrationRequestName(owner.Name)) + return nil + } if err := c.deleteRegistrationResources(ctx, request); err != nil { return err } @@ -689,7 +735,11 @@ func (c *Controller) reconcileRegistrationActive(ctx context.Context, raw *unstr agentID := firstNonEmpty(status.AgentID, offer.Status.AgentID) txHash := firstNonEmpty(status.RegistrationTxHash, offer.Status.RegistrationTxHash) - document := buildActiveRegistrationDocument(offer, baseURL, agentID) + offers, err := c.registrationOffers("", "") + if err != nil { + return err + } + document := buildActiveRegistrationDocument(offer, offers, baseURL, agentID) documentJSON, contentHash, err := marshalRegistrationDocument(document) if err != nil { return err @@ -1117,7 +1167,7 @@ func (c *Controller) deleteRegistrationRequest(ctx context.Context, namespace, o return nil } -func (c *Controller) registrationOwner() (*monetizeapi.ServiceOffer, error) { +func (c *Controller) registrationOffers(excludeNamespace, excludeName string) ([]*monetizeapi.ServiceOffer, error) { var candidates []*monetizeapi.ServiceOffer for _, item := range c.offerInformer.GetStore().List() { u := asUnstructured(item) @@ -1128,11 +1178,22 @@ func (c *Controller) registrationOwner() (*monetizeapi.ServiceOffer, error) { if err != nil { return nil, err } + if offer.Namespace == excludeNamespace && offer.Name == excludeName { + continue + } if offer.DeletionTimestamp != nil || offer.IsPaused() || !offer.Spec.Registration.Enabled { continue } candidates = append(candidates, offer) } + return candidates, nil +} + +func (c *Controller) registrationOwner() (*monetizeapi.ServiceOffer, error) { + candidates, err := c.registrationOffers("", "") + if err != nil { + return nil, err + } return selectRegistrationOwner(candidates), nil } @@ -1160,6 +1221,36 @@ func selectRegistrationOwner(offers []*monetizeapi.ServiceOffer) *monetizeapi.Se return offers[0] } +func applySharedRegistrationStatus(status *monetizeapi.ServiceOfferStatus, offer, owner *monetizeapi.ServiceOffer, request *monetizeapi.RegistrationRequest) { + status.AgentID = request.Status.AgentID + status.RegistrationTxHash = request.Status.RegistrationTxHash + + if !isConditionTrue(*status, "RoutePublished") { + setCondition(status, "Registered", "False", "WaitingForRoute", "Waiting for route publication before shared registration") + return + } + + if requestPhaseReady(request.Status.Phase) { + message := defaultString(request.Status.Message, "Registration reconciled") + if owner != nil && (owner.Namespace != offer.Namespace || owner.Name != offer.Name) { + if request.Status.AgentID != "" { + message = fmt.Sprintf("Shared registration via %s/%s recorded agent %s", owner.Namespace, owner.Name, request.Status.AgentID) + } else { + message = fmt.Sprintf("Shared registration via %s/%s is active", owner.Namespace, owner.Name) + } + } + setCondition(status, "Registered", "True", request.Status.Phase, message) + return + } + + reason := defaultString(request.Status.Phase, "Pending") + message := defaultString(request.Status.Message, "Waiting for RegistrationRequest to finish") + if owner != nil && (owner.Namespace != offer.Namespace || owner.Name != offer.Name) { + message = fmt.Sprintf("Waiting for shared registration owned by %s/%s: %s", owner.Namespace, owner.Name, message) + } + setCondition(status, "Registered", "False", reason, message) +} + func (c *Controller) applyObject(ctx context.Context, resource dynamic.ResourceInterface, desired *unstructured.Unstructured) error { payload, err := json.Marshal(desired.Object) if err != nil { diff --git a/internal/serviceoffercontroller/controller_test.go b/internal/serviceoffercontroller/controller_test.go index afbddb5a..8d308b0b 100644 --- a/internal/serviceoffercontroller/controller_test.go +++ b/internal/serviceoffercontroller/controller_test.go @@ -101,3 +101,44 @@ func TestPurchaseReadyRequiresRuntimePoolToMatchSpec(t *testing.T) { t.Fatal("purchase should be ready once runtime pool matches spec") } } + +func TestApplySharedRegistrationStatus_NonOwnerUsesSharedAgent(t *testing.T) { + status := &monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "RoutePublished", Status: "True"}}, + } + owner := &monetizeapi.ServiceOffer{ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "demo"}} + offer := &monetizeapi.ServiceOffer{ObjectMeta: metav1.ObjectMeta{Name: "beta", Namespace: "demo"}} + request := &monetizeapi.RegistrationRequest{ + Status: monetizeapi.RegistrationRequestStatus{ + Phase: registrationPhaseRegistered, + AgentID: "42", + RegistrationTxHash: "0xtx", + }, + } + + applySharedRegistrationStatus(status, offer, owner, request) + + if status.AgentID != "42" || status.RegistrationTxHash != "0xtx" { + t.Fatalf("shared registration identifiers not copied: %+v", status) + } + if !isConditionTrue(*status, "Registered") { + t.Fatalf("registered condition not set true: %+v", status.Conditions) + } +} + +func TestApplySharedRegistrationStatus_WaitsForRoute(t *testing.T) { + status := &monetizeapi.ServiceOfferStatus{} + owner := &monetizeapi.ServiceOffer{ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "demo"}} + request := &monetizeapi.RegistrationRequest{ + Status: monetizeapi.RegistrationRequestStatus{ + Phase: registrationPhaseRegistered, + AgentID: "7", + }, + } + + applySharedRegistrationStatus(status, owner, owner, request) + + if isConditionTrue(*status, "Registered") { + t.Fatalf("registered should remain false until route is published: %+v", status.Conditions) + } +} diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index 6fe37646..323e609f 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -566,50 +566,32 @@ func isConditionTrue(status monetizeapi.ServiceOfferStatus, conditionType string return false } -func buildActiveRegistrationDocument(offer *monetizeapi.ServiceOffer, baseURL, agentID string) erc8004.AgentRegistration { +func buildActiveRegistrationDocument(owner *monetizeapi.ServiceOffer, offers []*monetizeapi.ServiceOffer, baseURL, agentID string) erc8004.AgentRegistration { baseURL = strings.TrimRight(baseURL, "/") - description := offer.Spec.Registration.Description + description := owner.Spec.Registration.Description if description == "" { - description = fmt.Sprintf("x402 payment-gated %s service: %s", fallbackOfferType(offer), offer.Name) + description = fmt.Sprintf("x402 payment-gated %s service: %s", fallbackOfferType(owner), owner.Name) } - if offer.IsInference() && offer.Spec.Model.Name != "" { - description = fmt.Sprintf("%s inference via x402 micropayments", offer.Spec.Model.Name) + if owner.IsInference() && owner.Spec.Model.Name != "" { + description = fmt.Sprintf("%s inference via x402 micropayments", owner.Spec.Model.Name) } - image := offer.Spec.Registration.Image + image := owner.Spec.Registration.Image if image == "" { image = baseURL + "/agent-icon.png" } - services := []erc8004.ServiceDef{{ - Name: "web", - Endpoint: baseURL + offer.EffectivePath(), - }} - if len(offer.Spec.Registration.Skills) > 0 || len(offer.Spec.Registration.Domains) > 0 { - services = append(services, erc8004.ServiceDef{ - Name: "OASF", - Version: "0.8", - Skills: offer.Spec.Registration.Skills, - Domains: offer.Spec.Registration.Domains, - }) - } - for _, service := range offer.Spec.Registration.Services { - services = append(services, erc8004.ServiceDef{ - Name: service.Name, - Endpoint: service.Endpoint, - Version: service.Version, - }) - } + services := buildRegistrationServices(owner, offers, baseURL) registration := erc8004.AgentRegistration{ Type: erc8004.RegistrationType, - Name: defaultString(offer.Spec.Registration.Name, offer.Name), + Name: defaultString(owner.Spec.Registration.Name, owner.Name), Description: description, Image: image, Services: services, X402Support: true, Active: true, - SupportedTrust: offer.Spec.Registration.SupportedTrust, + SupportedTrust: owner.Spec.Registration.SupportedTrust, } if agentID != "" { registration.Registrations = []erc8004.OnChainReg{{ @@ -617,23 +599,89 @@ func buildActiveRegistrationDocument(offer *monetizeapi.ServiceOffer, baseURL, a AgentRegistry: fmt.Sprintf("eip155:%d:%s", erc8004.BaseSepoliaChainID, erc8004.IdentityRegistryBaseSepolia), }} } - if metadata := nonEmptyStringMap(offer.Spec.Registration.Metadata); len(metadata) > 0 { + if metadata := nonEmptyStringMap(owner.Spec.Registration.Metadata); len(metadata) > 0 { registration.Metadata = metadata } - if provenance := nonEmptyStringMap(offer.Spec.Provenance); len(provenance) > 0 { + if provenance := nonEmptyStringMap(owner.Spec.Provenance); len(provenance) > 0 { registration.Provenance = provenance } return registration } func buildTombstoneRegistrationDocument(offer *monetizeapi.ServiceOffer, baseURL, agentID string) erc8004.AgentRegistration { - registration := buildActiveRegistrationDocument(offer, baseURL, agentID) + registration := buildActiveRegistrationDocument(offer, []*monetizeapi.ServiceOffer{offer}, baseURL, agentID) registration.Active = false registration.X402Support = false registration.Description = fmt.Sprintf("%s (deactivated)", registration.Description) return registration } +func buildRegistrationServices(owner *monetizeapi.ServiceOffer, offers []*monetizeapi.ServiceOffer, baseURL string) []erc8004.ServiceDef { + baseURL = strings.TrimRight(baseURL, "/") + type offerKey struct { + namespace string + name string + } + seen := map[offerKey]struct{}{} + ordered := []*monetizeapi.ServiceOffer{} + add := func(offer *monetizeapi.ServiceOffer, force bool) { + if offer == nil { + return + } + key := offerKey{namespace: offer.Namespace, name: offer.Name} + if _, ok := seen[key]; ok { + return + } + if !force && !offerPublishedForRegistration(offer) { + return + } + seen[key] = struct{}{} + ordered = append(ordered, offer) + } + + add(owner, true) + for _, offer := range offers { + if owner != nil && offer != nil && offer.Namespace == owner.Namespace && offer.Name == owner.Name { + continue + } + add(offer, false) + } + + services := make([]erc8004.ServiceDef, 0, len(ordered)*2) + for _, offer := range ordered { + services = append(services, erc8004.ServiceDef{ + Name: "web", + Endpoint: baseURL + offer.EffectivePath(), + }) + if len(offer.Spec.Registration.Skills) > 0 || len(offer.Spec.Registration.Domains) > 0 { + services = append(services, erc8004.ServiceDef{ + Name: "OASF", + Version: "0.8", + Skills: offer.Spec.Registration.Skills, + Domains: offer.Spec.Registration.Domains, + }) + } + for _, service := range offer.Spec.Registration.Services { + services = append(services, erc8004.ServiceDef{ + Name: service.Name, + Endpoint: service.Endpoint, + Version: service.Version, + }) + } + } + return services +} + +func offerPublishedForRegistration(offer *monetizeapi.ServiceOffer) bool { + if offer == nil || offer.DeletionTimestamp != nil || offer.IsPaused() || !offer.Spec.Registration.Enabled { + return false + } + return isConditionTrue(offer.Status, "ModelReady") && + isConditionTrue(offer.Status, "UpstreamHealthy") && + isConditionTrue(offer.Status, "PaymentGateReady") && + isConditionTrue(offer.Status, "RoutePublished") +} + func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL string) string { baseURL = strings.TrimRight(baseURL, "/") diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index 407a086f..55fc56a0 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -1,6 +1,7 @@ package serviceoffercontroller import ( + "encoding/json" "strings" "testing" @@ -132,7 +133,7 @@ func TestBuildRegistrationHTTPRoute(t *testing.T) { } func TestBuildActiveRegistrationDocument(t *testing.T) { - offer := &monetizeapi.ServiceOffer{ + owner := &monetizeapi.ServiceOffer{ ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "llm"}, Spec: monetizeapi.ServiceOfferSpec{ Type: "inference", @@ -156,8 +157,28 @@ func TestBuildActiveRegistrationDocument(t *testing.T) { }, }, } + secondary := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "blocks", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Path: "/services/blocks", + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + Skills: []string{"blockchain/data"}, + Domains: []string{"technology/blockchain"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{ + {Type: "ModelReady", Status: "True"}, + {Type: "UpstreamHealthy", Status: "True"}, + {Type: "PaymentGateReady", Status: "True"}, + {Type: "RoutePublished", Status: "True"}, + }, + }, + } - document := buildActiveRegistrationDocument(offer, "https://example.com", "7") + document := buildActiveRegistrationDocument(owner, []*monetizeapi.ServiceOffer{owner, secondary}, "https://example.com", "7") if document.Type != erc8004.RegistrationType { t.Fatalf("type = %q", document.Type) @@ -171,8 +192,8 @@ func TestBuildActiveRegistrationDocument(t *testing.T) { if len(document.Registrations) != 1 || document.Registrations[0].AgentID != 7 { t.Fatalf("registrations = %+v, want agentId 7", document.Registrations) } - if len(document.Services) < 2 { - t.Fatalf("services = %+v, want web + OASF", document.Services) + if len(document.Services) < 4 { + t.Fatalf("services = %+v, want aggregated web + OASF entries", document.Services) } if document.Metadata["gpu"] != "A100-80GB" { t.Fatalf("metadata = %+v, want gpu entry", document.Metadata) @@ -180,6 +201,141 @@ func TestBuildActiveRegistrationDocument(t *testing.T) { if document.Provenance["framework"] != "autoresearch" { t.Fatalf("provenance = %+v, want framework entry", document.Provenance) } + + var seenBlocks bool + for _, svc := range document.Services { + if svc.Endpoint == "https://example.com/services/blocks" { + seenBlocks = true + break + } + } + if !seenBlocks { + t.Fatalf("aggregated document missing secondary service endpoint: %+v", document.Services) + } +} + +func TestBuildRegistrationServices_IncludesOwnerWhenOwnerNotYetPublished(t *testing.T) { + owner := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "owner", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Path: "/services/owner", + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + }, + }, + } + other := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "other", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Path: "/services/other", + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{ + {Type: "ModelReady", Status: "True"}, + {Type: "UpstreamHealthy", Status: "True"}, + {Type: "PaymentGateReady", Status: "True"}, + {Type: "RoutePublished", Status: "True"}, + }, + }, + } + + services := buildRegistrationServices(owner, []*monetizeapi.ServiceOffer{owner, other}, "https://example.com") + if len(services) != 2 { + t.Fatalf("services = %+v, want 2 web entries", services) + } + if services[0].Endpoint != "https://example.com/services/owner" { + t.Fatalf("owner service endpoint = %q", services[0].Endpoint) + } + if services[1].Endpoint != "https://example.com/services/other" { + t.Fatalf("other service endpoint = %q", services[1].Endpoint) + } +} + +func TestBuildRegistrationConfigMap_PublishesAggregatedAgentRegistration(t *testing.T) { + readyConditions := []monetizeapi.Condition{ + {Type: "ModelReady", Status: "True"}, + {Type: "UpstreamHealthy", Status: "True"}, + {Type: "PaymentGateReady", Status: "True"}, + {Type: "RoutePublished", Status: "True"}, + } + owner := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "hello", Namespace: "demo", UID: types.UID("owner-uid")}, + Spec: monetizeapi.ServiceOfferSpec{ + Path: "/services/hello", + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + Name: "Demo Agent", + }, + }, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyConditions}, + } + offers := []*monetizeapi.ServiceOffer{ + owner, + { + ObjectMeta: metav1.ObjectMeta{Name: "blocks", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Path: "/services/blocks", + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + }, + }, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyConditions}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "oracle", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Path: "/services/oracle", + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + }, + }, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyConditions}, + }, + } + + document := buildActiveRegistrationDocument(owner, offers, "https://example.com", "42") + documentJSON, _, err := marshalRegistrationDocument(document) + if err != nil { + t.Fatalf("marshalRegistrationDocument: %v", err) + } + request := &monetizeapi.RegistrationRequest{ + ObjectMeta: metav1.ObjectMeta{Name: registrationRequestName(owner.Name), Namespace: owner.Namespace, UID: types.UID("req-uid")}, + Spec: monetizeapi.RegistrationRequestSpec{ + ServiceOfferName: owner.Name, + ServiceOfferNamespace: owner.Namespace, + }, + } + + cm := buildRegistrationConfigMap(request, documentJSON) + data := cm.Object["data"].(map[string]any) + rawDoc, ok := data["agent-registration.json"].(string) + if !ok || rawDoc == "" { + t.Fatalf("agent-registration.json missing from configmap: %+v", data) + } + + var published erc8004.AgentRegistration + if err := json.Unmarshal([]byte(rawDoc), &published); err != nil { + t.Fatalf("unmarshal published registration document: %v", err) + } + + wantEndpoints := map[string]bool{ + "https://example.com/services/hello": false, + "https://example.com/services/blocks": false, + "https://example.com/services/oracle": false, + } + for _, svc := range published.Services { + if _, ok := wantEndpoints[svc.Endpoint]; ok { + wantEndpoints[svc.Endpoint] = true + } + } + for endpoint, seen := range wantEndpoints { + if !seen { + t.Fatalf("published registration document missing endpoint %s: %+v", endpoint, published.Services) + } + } } func TestRegistrationDataURL(t *testing.T) {