Skip to content
Open
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
20 changes: 10 additions & 10 deletions internal/serviceoffercontroller/agent_confighash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,32 @@ func TestHermesConfigDecision(t *testing.T) {
wantDrift: false,
},
{
name: "annotation matches and data hashes to desired -> skip, no drift",
live: hermesConfigCM(t, "ns", desiredYAML, desiredHash, "1"),
name: "annotation matches and data hashes to desired -> skip, no drift",
live: hermesConfigCM(t, "ns", desiredYAML, desiredHash, "1"),
wantSkip: true,
wantDrift: false,
},
{
name: "annotation matches but data hand-edited -> skip with drift",
live: hermesConfigCM(t, "ns", otherYAML, desiredHash, "1"),
name: "annotation matches but data hand-edited -> skip with drift",
live: hermesConfigCM(t, "ns", otherYAML, desiredHash, "1"),
wantSkip: true,
wantDrift: true,
},
{
name: "annotation does not match desired -> apply",
live: hermesConfigCM(t, "ns", desiredYAML, otherHash, "1"),
name: "annotation does not match desired -> apply",
live: hermesConfigCM(t, "ns", desiredYAML, otherHash, "1"),
wantSkip: false,
wantDrift: false,
},
{
name: "annotation absent (pre-feature migration) -> apply",
live: hermesConfigCM(t, "ns", desiredYAML, "", "1"),
name: "annotation absent (pre-feature migration) -> apply",
live: hermesConfigCM(t, "ns", desiredYAML, "", "1"),
wantSkip: false,
wantDrift: false,
},
{
name: "annotation matches but data key missing -> skip with drift",
live: hermesConfigCMNoData(t, "ns", desiredHash, "1"),
name: "annotation matches but data key missing -> skip with drift",
live: hermesConfigCMNoData(t, "ns", desiredHash, "1"),
wantSkip: true,
wantDrift: true,
},
Expand Down
12 changes: 6 additions & 6 deletions internal/serviceoffercontroller/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func (c *Controller) listServiceOffersForCatalog(ctx context.Context, override *
return offers, nil
}

func skillCatalogContentMatches(cm *unstructured.Unstructured, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) bool {
func staticSiteContentMatches(cm *unstructured.Unstructured, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) bool {
if cm == nil {
return false
}
Expand Down Expand Up @@ -75,22 +75,22 @@ func skillCatalogContentMatches(cm *unstructured.Unstructured, content, services
return true
}

func (c *Controller) skillCatalogContentUnchanged(ctx context.Context, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) (bool, error) {
cm, err := c.configMaps.Namespace(skillCatalogNamespace).Get(ctx, skillCatalogConfigMapName, metav1.GetOptions{})
func (c *Controller) staticSiteContentUnchanged(ctx context.Context, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) (bool, error) {
cm, err := c.configMaps.Namespace(staticSiteNamespace).Get(ctx, staticSiteConfigMapName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return false, nil
}
if err != nil {
return false, err
}
return skillCatalogContentMatches(cm, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles), nil
return staticSiteContentMatches(cm, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles), nil
}

func computeSkillCatalogContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string {
func computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string {
return fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML+bundleDigestInput(bundles)))[:8]
}

func skillCatalogDeployedContentHash(deployment *unstructured.Unstructured) string {
func staticSiteDeployedContentHash(deployment *unstructured.Unstructured) string {
if deployment == nil {
return ""
}
Expand Down
28 changes: 14 additions & 14 deletions internal/serviceoffercontroller/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,46 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func TestComputeSkillCatalogContentHashDeterministic(t *testing.T) {
a := computeSkillCatalogContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
b := computeSkillCatalogContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
func TestComputeStaticSiteContentHashDeterministic(t *testing.T) {
a := computeStaticSiteContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
b := computeStaticSiteContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
if a != b {
t.Fatalf("hash not deterministic: %q vs %q", a, b)
}
if len(a) != 8 {
t.Fatalf("hash length = %d, want 8", len(a))
}

changed := computeSkillCatalogContentHash("# cat", `{"services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
changed := computeStaticSiteContentHash("# cat", `{"services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
if changed == a {
t.Fatal("expected different hash when catalog content changes")
}
}

func TestSkillCatalogContentMatches(t *testing.T) {
cm := buildSkillCatalogConfigMap("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
if !skillCatalogContentMatches(cm, "# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil) {
func TestStaticSiteContentMatches(t *testing.T) {
cm := buildStaticSiteConfigMap("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
if !staticSiteContentMatches(cm, "# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil) {
t.Fatal("expected matching catalog content")
}
if skillCatalogContentMatches(cm, "# changed", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil) {
if staticSiteContentMatches(cm, "# changed", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil) {
t.Fatal("expected different skill.md to not match")
}
if skillCatalogContentMatches(nil, "# cat", `{}`, `{}`, "", nil) {
if staticSiteContentMatches(nil, "# cat", `{}`, `{}`, "", nil) {
t.Fatal("nil configmap must not match")
}
}

func TestSkillCatalogDeployedContentHash(t *testing.T) {
deployment := buildSkillCatalogDeployment("abc12345", nil)
if got := skillCatalogDeployedContentHash(deployment); got != "abc12345" {
func TestStaticSiteDeployedContentHash(t *testing.T) {
deployment := buildStaticSiteDeployment("abc12345", nil)
if got := staticSiteDeployedContentHash(deployment); got != "abc12345" {
t.Fatalf("hash = %q, want abc12345", got)
}
if got := skillCatalogDeployedContentHash(nil); got != "" {
if got := staticSiteDeployedContentHash(nil); got != "" {
t.Fatalf("nil deployment hash = %q, want empty", got)
}

empty := &unstructured.Unstructured{Object: map[string]any{"spec": map[string]any{}}}
if got := skillCatalogDeployedContentHash(empty); got != "" {
if got := staticSiteDeployedContentHash(empty); got != "" {
t.Fatalf("missing annotation hash = %q, want empty", got)
}
}
56 changes: 28 additions & 28 deletions internal/serviceoffercontroller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ type Controller struct {
identityQueue workqueue.TypedRateLimitingInterface[string]
purchaseQueue workqueue.TypedRateLimitingInterface[string]
agentQueue workqueue.TypedRateLimitingInterface[string]
catalogMu sync.Mutex
staticSiteMu sync.Mutex

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

Expand Down Expand Up @@ -351,25 +351,25 @@ func (c *Controller) enqueueStorefrontProfileRefresh(obj any) {
if u.GetNamespace() != storefront.ProfileNamespace || u.GetName() != storefront.ProfileConfigMap {
return
}
log.Printf("serviceoffer-controller: storefront profile change detected, refreshing skill catalog")
c.enqueueSkillCatalogRefresh()
log.Printf("serviceoffer-controller: storefront profile change detected, refreshing static site")
c.enqueueStaticSiteRefresh()
}

func (c *Controller) enqueueSkillCatalogRefresh() {
func (c *Controller) enqueueStaticSiteRefresh() {
items := c.offerInformer.GetStore().List()
if len(items) > 0 {
// Any single offer reconcile rebuilds the full catalog.
c.enqueueOffer(items[0])
return
}
go c.refreshSkillCatalogAsync()
go c.refreshStaticSiteAsync()
}

func (c *Controller) refreshSkillCatalogAsync() {
func (c *Controller) refreshStaticSiteAsync() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := c.reconcileSkillCatalog(ctx, nil); err != nil {
log.Printf("serviceoffer-controller: refresh skill catalog: %v", err)
if err := c.reconcileStaticSite(ctx, nil); err != nil {
log.Printf("serviceoffer-controller: refresh static site: %v", err)
}
}

Expand Down Expand Up @@ -467,7 +467,7 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error {
now := metav1.Now()
tombstone.DeletionTimestamp = &now
}
if err := c.reconcileSkillCatalog(ctx, &tombstone); err != nil {
if err := c.reconcileStaticSite(ctx, &tombstone); err != nil {
return err
}
return c.removeFinalizer(ctx, raw, serviceOfferFinalizer)
Expand Down Expand Up @@ -661,10 +661,10 @@ 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)
}
// Rebuild the skill catalog on every reconcile so tunnel URL changes and
// offer status updates propagate immediately. reconcileSkillCatalog skips
// 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.
return c.reconcileSkillCatalog(ctx, nil)
return c.reconcileStaticSite(ctx, nil)
}

func (c *Controller) reconcileDeletingOffer(ctx context.Context, offer *monetizeapi.ServiceOffer) error {
Expand Down Expand Up @@ -1246,16 +1246,16 @@ func (c *Controller) loadStorefrontProfile(ctx context.Context) (*schemas.Storef
return storefront.ParseProfile(raw)
}

// reconcileSkillCatalog rebuilds the /skill.md ConfigMap/Deployment/Service/
// reconcileStaticSite rebuilds the /skill.md ConfigMap/Deployment/Service/
// HTTPRoute from the current set of operationally-ready ServiceOffers. Offers
// are listed from the API server so every reconcile sees a consistent snapshot;
// override replaces or appends a just-created offer the list has not observed yet.
// ConfigMap and Deployment are only applied when the rendered catalog hash differs
// from the live obol-skill-md Deployment annotation, so idle reconciles do not
// roll the skill-catalog pod.
func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *monetizeapi.ServiceOffer) error {
c.catalogMu.Lock()
defer c.catalogMu.Unlock()
// roll the static-site pod.
func (c *Controller) reconcileStaticSite(ctx context.Context, override *monetizeapi.ServiceOffer) error {
c.staticSiteMu.Lock()
defer c.staticSiteMu.Unlock()

baseURL, err := c.registrationBaseURL(ctx)
if err != nil {
Expand All @@ -1271,7 +1271,7 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti
if err != nil {
return err
}
content := buildSkillCatalogMarkdown(offers, baseURL, storefrontProfile)
content := buildSkillMarkdown(offers, baseURL, storefrontProfile)
servicesJSON := buildServiceCatalogJSON(offers, baseURL, storefrontProfile)
resolvedProfile := storefront.ResolvePublished(storefrontProfile, baseURL)
// buildOpenAPIDocument prefers the tunnel URL for the public `servers[0]`
Expand All @@ -1283,9 +1283,9 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti
openAPIJSON := buildOpenAPIDocument(offers, baseURL, resolvedProfile)
apiDocsHTML := scalarHTML(resolvedProfile)
bundles := buildOfferBundles(offers, resolvedProfile)
contentHash := computeSkillCatalogContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)
contentHash := computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)

unchanged, err := c.skillCatalogContentUnchanged(ctx, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)
unchanged, err := c.staticSiteContentUnchanged(ctx, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)
if err != nil {
return err
}
Expand All @@ -1295,30 +1295,30 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti
return nil
}

if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)); err != nil {
if err := c.applyObject(ctx, c.configMaps.Namespace(staticSiteNamespace), buildStaticSiteConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)); err != nil {
return err
}
if err := c.applyObject(ctx, c.deployments.Namespace(skillCatalogNamespace), buildSkillCatalogDeployment(contentHash, bundles)); err != nil {
if err := c.applyObject(ctx, c.deployments.Namespace(staticSiteNamespace), buildStaticSiteDeployment(contentHash, bundles)); err != nil {
return err
}
if err := c.applyObject(ctx, c.services.Namespace(skillCatalogNamespace), buildSkillCatalogService()); err != nil {
if err := c.applyObject(ctx, c.services.Namespace(staticSiteNamespace), buildStaticSiteService()); err != nil {
return err
}
// Headers Middleware must exist before the routes that reference it, or
// Traefik drops the routes for a dangling ExtensionRef.
if err := c.applyObject(ctx, c.middlewares.Namespace(skillCatalogNamespace), buildCatalogHeadersMiddleware()); err != nil {
if err := c.applyObject(ctx, c.middlewares.Namespace(staticSiteNamespace), buildCatalogHeadersMiddleware()); err != nil {
return err
}
if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildSkillCatalogHTTPRoute()); err != nil {
if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildStaticSiteHTTPRoute()); err != nil {
return err
}
if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildServicesJSONHTTPRoute()); err != nil {
if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildServicesJSONHTTPRoute()); err != nil {
return err
}
if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildOpenAPIHTTPRoute()); err != nil {
if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildOpenAPIHTTPRoute()); err != nil {
return err
}
if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildAPIDocsHTTPRoute()); err != nil {
if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildAPIDocsHTTPRoute()); err != nil {
return err
}
readyOffers := countReadyServiceOffers(offers)
Expand Down
4 changes: 2 additions & 2 deletions internal/serviceoffercontroller/hostoffer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestBuildHostHTTPRoute(t *testing.T) {
t.Errorf("rule %d (%s) rewrites to %v, want %s", i, public, got, wantExact[public])
}
backend := rule["backendRefs"].([]any)[0].(map[string]any)
if backend["name"] != skillCatalogConfigMapName || backend["namespace"] != skillCatalogNamespace {
if backend["name"] != staticSiteConfigMapName || backend["namespace"] != staticSiteNamespace {
t.Errorf("rule %d backend = %v", i, backend)
}
}
Expand Down Expand Up @@ -262,7 +262,7 @@ func TestCatalogAdvertisesDedicatedOrigin(t *testing.T) {
t.Fatalf("catalog endpoint = %+v, want dedicated origin", catalog.Services)
}

skill := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://shared.example", nil)
skill := buildSkillMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://shared.example", nil)
if !strings.Contains(skill, "`POST https://audit.v1337.example/submit`") {
t.Errorf("skill.md routes not rooted at dedicated origin:\n%.400s", skill)
}
Expand Down
Loading
Loading