Skip to content

Show only the APIs that make sense for each context#567

Merged
scotwells merged 6 commits intomainfrom
feat/discovery-context-aware-api
Apr 17, 2026
Merged

Show only the APIs that make sense for each context#567
scotwells merged 6 commits intomainfrom
feat/discovery-context-aware-api

Conversation

@scotwells
Copy link
Copy Markdown
Contributor

@scotwells scotwells commented Apr 16, 2026

Summary

When a user runs datumctl api-resources against Milo, they see every API the platform exposes — even the ones that don't apply to where they are. A user browsing their organization sees platform-level APIs mixed in with organization-level ones. A user inside a project sees organization-level APIs they can't act on. It's noise.

This PR lets a resource declare which parent contexts it belongs in (Root, Organization, Project, User), and Milo filters discovery responses accordingly — so datumctl api-resources only shows the APIs that make sense for whatever URL the caller is using.

What the user sees

Before — same list everywhere:

$ datumctl api-resources --api-group=resourcemanager.miloapis.com
NAME                     NAMESPACED   KIND
organizations            false        Organization
organizationmemberships  true         OrganizationMembership
projects                 false        Project

After — scoped to context:

# At the cluster root: just the platform-level APIs
$ datumctl api-resources --api-group=resourcemanager.miloapis.com
NAME             NAMESPACED   KIND
organizations    false        Organization

# Inside an organization: the things you'd do in an org
$ kubectl api-resources --api-group=resourcemanager.miloapis.com  # (org-scoped kubeconfig)
NAME                     NAMESPACED   KIND
organizationmemberships  true         OrganizationMembership
projects                 false        Project

How someone opts a resource in

On a Go-defined type (Milo's own APIs):

// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Organization,User"
type OrganizationMembership struct { ... }

On a CRD installed by an external service building on Milo:

metadata:
  annotations:
    discovery.miloapis.com/parent-contexts: "Organization"

That's the whole contract. No annotation = visible everywhere (so nothing changes until a team opts in). Multiple contexts are comma-separated. Details and the thinking behind the design live in docs/architecture/discovery-contexts.md.

Who this helps

  • Platform users get a clean kubectl api-resources — only the APIs relevant to the URL they're hitting.
  • Service teams building on Milo tag their CRDs and immediately feel native alongside Milo's own APIs. No new registration step.
  • Documentation and tab-completion stop drowning users in irrelevant resources they can't use.

Scope

This is a discovery hint — it changes what the list endpoints return, not what the server accepts. A caller who already knows a GVR can still talk to it directly. Turning this into a hard boundary would mean pairing the filter with an admission check; that's deliberately out of scope for this change and called out in the doc.

Three existing Milo resources are opted in as the first examples: Organization (Root), Project (Organization), OrganizationMembership (Organization + User). Everything else stays visible everywhere until its owner chooses to tag it.

Follow-up: aggregated apiservers

This PR covers CRDs — Milo's own types and any CRD an external service installs. It does not yet cover the aggregated apiservers Milo already proxies (activity.miloapis.com, quota.miloapis.com, search.miloapis.com, incidents.operations.miloapis.com, identity.miloapis.com). Those are a larger slice of the user-facing surface than CRDs, so coverage is important.

The filter already captures aggregated responses — only the source-of-truth for the mapping is missing. A follow-up PR will add a second registry source that reads an x-milo-parent-contexts OpenAPI extension from the aggregated OpenAPI doc each aggregated service already serves. Each aggregated service will get a small companion PR to emit the extension on its schemas. Sequencing this as a follow-up keeps each change small and reviewable.

Test plan

  • Unit tests for parser, registry precedence, and per-context filtering (go test ./pkg/server/discovery/...)
  • Chainsaw e2e covering Root / Organization / User + an external CRD installed mid-test (task test:end-to-end -- discovery-context-filter)
  • Manual kubectl api-resources against a live dev cluster in all three contexts
  • Reviewer verifies annotations on Organization, Project, OrganizationMembership render correctly after task generate

🤖 Generated with Claude Code

Filter kubectl discovery per parent context (Root / Organization /
Project / User) so callers only see the resources relevant to where
they are. Tag resources with a CRD annotation; the filter handles the
rest. External CRDs participate the same way.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@joggrbot
Copy link
Copy Markdown
Contributor

joggrbot Bot commented Apr 16, 2026

📝 Documentation Analysis

All docs are up to date! 🎉


✅ Latest commit analyzed: 06ba99d | Powered by Joggr

@scotwells scotwells marked this pull request as ready for review April 16, 2026 15:17
ecv
ecv previously approved these changes Apr 16, 2026
@gianarb
Copy link
Copy Markdown

gianarb commented Apr 17, 2026

thanks!!

Rename the "Root" parent context to "Platform" for clarity — it
represents the platform-level scope, not a generic cluster root.

Annotate all 32 CRD types with discovery.miloapis.com/parent-contexts
so the filter covers every Milo-managed resource. Two Notes CRDs are
intentionally left unannotated (visible in all contexts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
scotwells and others added 3 commits April 17, 2026 10:48
Platform (root) context is where controllers, admin tools, and internal
clients operate. Filtering discovery there breaks the controller-manager
because it can't find CRD types like OrganizationMembership.

Skip filtering entirely for Platform context requests — only apply it
when a user explicitly navigates into Organization, Project, or User
scope. Remove "Platform" from all CRD annotations since the context is
never filtered and the tag has no effect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Platform annotations are still meaningful — they tell the filter to hide
resources from Organization/Project/User contexts. Only the Platform
context *request path* bypasses filtering (so controllers see
everything); the annotation value is still used when filtering other
contexts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ture flag

The filter is alpha and disabled by default. Enable with:

  --feature-gates=DiscoveryContextFilter=true

When disabled, the registry is not created, the filter middleware is
skipped, and the CRD informer post-start hook is a no-op. Zero
overhead for deployments that don't opt in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@scotwells scotwells requested a review from ecv April 17, 2026 17:11
The chainsaw discovery-context-filter test requires the gate to be on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@scotwells scotwells merged commit 31bfec7 into main Apr 17, 2026
7 checks passed
@scotwells scotwells deleted the feat/discovery-context-aware-api branch April 17, 2026 19:25
scotwells added a commit that referenced this pull request Apr 17, 2026
## What changed

When you run `datumctl api-resources`, the list of resources is now
correctly filtered based on your current context — so you only see
what's relevant to where you are (organization, project, or user).

## Why it wasn't working

The previous release (#567) introduced context-aware discovery
filtering. It worked correctly when tested via direct HTTP calls, but
`datumctl api-resources` was still showing everything regardless of
context.

The root cause: the filter was checking the response `Content-Type`
header to detect the discovery format. In standard Kubernetes, the
server echoes back the requested format in `Content-Type` — but Milo
returns plain `application/json`, so the filter never recognized the
format and skipped filtering entirely.

## The fix

kubectl always declares the format it wants via the request `Accept`
header. The filter now checks that instead, which is both more reliable
and simpler — no dependency on what the server echoes back.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants