Skip to content

feat(api): dynamic API group registration via ApplicationGroupDefinition (MVP) - #3448

Draft
Timofei Larkin (lllamnyp) wants to merge 3 commits into
mainfrom
feat/application-group-definitions
Draft

Timofei Larkin (lllamnyp) wants to merge 3 commits into
mainfrom
feat/application-group-definitions

Conversation

@lllamnyp

Copy link
Copy Markdown
Member

What this PR does

Status: MVP / proof-of-concept. This is a working, buildable slice meant to validate the design of dynamically-registered API groups before committing to the API surface. Reviewers should calibrate accordingly: the mechanism is real end to end, but several hard parts are deliberately deferred (listed below).

Today every ApplicationDefinition is served in the single hardwired apps.cozystack.io group: the group name is baked into the scheme registration, the REST storage, the OpenAPI post-processors, the HelmRelease application.group labels, and a static helm-managed APIService. Third-party catalogs and platform extensions therefore cannot bring their own API group — everything lands in the platform's namespace.

This PR extends the ApplicationDefinition mechanism (it does not replace it) with three capabilities:

  1. A new cluster-scoped CRD, ApplicationGroupDefinition, registers an additional API group to be served by the cozystack aggregated apiserver.
  2. ApplicationDefinition gains an optional spec.application.group selecting the group its kind is served in. Empty keeps today's behavior exactly.
  3. The cozystack apiserver serves all registered groups: it lists ApplicationGroupDefinitions at startup, resolves each ApplicationDefinition's group against them, and installs one API group per resolved group (scheme registration, REST storage, discovery, and OpenAPI v2/v3 all follow the per-kind group).

API shape

apiVersion: cozystack.io/v1alpha1
kind: ApplicationGroupDefinition
metadata:
  name: apps.example.com   # must equal spec.group (CEL-enforced)
spec:
  group: apps.example.com
---
apiVersion: cozystack.io/v1alpha1
kind: ApplicationDefinition
metadata:
  name: widget
spec:
  application:
    group: apps.example.com   # optional; empty = apps.cozystack.io
    kind: Widget
    plural: widgets
    singular: widget
    openAPISchema: "..."
  release:
    prefix: widget-
    chartRef: {kind: ExternalArtifact, name: widget, namespace: cozy-public}

The spec is deliberately minimal — one field. Requiring metadata.name == spec.group makes registrations unique (object names are unique) and the group immutable (names are immutable) without extra webhook machinery. Group names must be dotted DNS-1123 subdomains, so single-label built-in Kubernetes groups (apps, batch) are unrepresentable, and the cozystack.io / k8s.io namespaces are reserved via CEL, with the same rules re-checked in Go by every consumer of cluster state (ValidateApplicationGroup). All groups are served at the fixed version v1alpha1, matching the built-in group; a versions field can be added compatibly later.

Group selection and back-compat guarantee

spec.application.group resolves through a single helper (EffectiveGroup / GroupOrDefault): empty means apps.cozystack.io, which needs no registration and is always served. An ApplicationDefinition that does not set the field behaves bit-for-bit as today — same group, same HelmRelease labels, same discovery — and the new code paths collapse to the old ones (pinned by TestBuildResourceConfig_DefaultGroupUnchanged). The resolved group flows everywhere the old constant was baked in: REST storage gvr/gvk, the apps.cozystack.io/application.group HelmRelease label and every label-selector check against it, the HelmRelease-updating reconciler in cozystack-controller (which previously hardcoded the group), and the OpenAPI GVK stamps.

Restart-based pickup (not hot reload), and why

The apiserver picks up group changes by restart, not hot reload, riding the mechanism ApplicationDefinition changes already use today: cozystack-controller watches both CRDs, hashes their specs into the cozystack.io/config-hash pod-template annotation of the cozystack-api Deployment (debounced), and the rollout re-reads everything at startup. Hot-adding an API group to a running GenericAPIServer is not supported upstream (InstallAPIGroup after PrepareRun is unsafe), so true hot reload would mean a serving-mux indirection layer — far beyond an MVP. Nothing in this architecture precludes it later: group resolution is already a pure function of the two CRD lists (buildResourceConfig), and the serving side consumes its output. Consequence: a new group's APIService may exist briefly before the restarted apiserver serves the group; discovery for that group reports unavailable for those few seconds, then converges.

Aggregation registration (APIService)

A new ApplicationGroupDefinitionReconciler in cozystack-controller materializes an APIService v1alpha1.<group> per registration, cloning the backend service reference and the cert-manager CA-injection annotation from the helm-managed v1alpha1.apps.cozystack.io reference APIService, so custom groups always target the same cozystack-api backend regardless of installation layout. The APIService carries an ownerReference to its ApplicationGroupDefinition, so deleting the registration garbage-collects the APIService. Only reconciler-owned fields are enforced on update; the cert-manager-injected caBundle is preserved. APIServices are managed as unstructured objects to avoid growing a kube-aggregator dependency for one object shape.

Dangling references and collisions

  • ApplicationGroupDefinition deleted while ApplicationDefinitions still reference it: on the next config rollout those definitions are skipped with an error log and simply stop being served; their HelmReleases (and tenant workloads) are untouched, and serving resumes if the group is re-registered. No panic, no startup failure — one bad reference cannot take down healthy groups.
  • Kind collisions across groups: kinds must be unique across all groups because the published OpenAPI schema refs are keyed by kind alone (...apps.v1alpha1.<Kind>); the second group claiming a kind is skipped with an error log.
  • Invalid or reserved groups that bypass CRD validation: re-validated in Go on both the serving and the APIService-creating paths, skipped with error logs.

Deferred (named, not stubbed silently)

  • RBAC for new groups: cluster admins must grant tenant roles access to the new group themselves (apiGroups: ["apps.example.com"]); the tenant RBAC the platform ships only covers apps.cozystack.io. A follow-up could stamp aggregated ClusterRoles per registered group.
  • Hot reload (see above): restart-based by design for now.
  • Per-group versions: v1alpha1 fixed; a versions field is a compatible future addition.
  • Status/conditions on ApplicationGroupDefinition (e.g. surfacing "APIService created", "N definitions served"): the CRD has no status subresource yet, matching ApplicationDefinition.
  • Dashboard/UI awareness of non-default groups.
  • Reporting skipped definitions (dangling group, kind collision) somewhere more visible than apiserver logs.

Verification

  • go build ./... and go vet ./... clean; make generate re-run to convergence (zero drift — deepcopy, CRD YAML, and openapi are all tool-generated).
  • New unit tests: group resolution incl. the default/back-compat path, dangling-reference skip, cross-group kind-collision skip, reserved/malformed group rejection (pkg/cmd/server/groups_test.go); multi-group scheme registration (pkg/apis/apps/v1alpha1/register_test.go); per-group OpenAPI v3 document filtering and v2 GVK stamping (pkg/cmd/server/openapi_groups_test.go); APIService create/update-preserving-caBundle/invalid-group paths and the config-hash extension (internal/controller/applicationgroupdefinition_controller_test.go).
  • Full test sweep over every touched package passes; e2e not extended (MVP).

Downstream repositories

Boxes are deliberately left empty rather than ticked: this is a draft MVP and filing downstream PRs now would be premature. Walking the trigger map against the diff, if this merges as-is two repositories are reached: cozystack/website (content/en/docs/next/cozystack-api/application-definitions.md documents ApplicationDefinition semantics, which gain the group field and the new companion CRD) and cozystack/external-apps-example (the ApplicationDefinition CRD in packages/system/application-definition-crd/definition/ changed — additively only, an optional field and a new CRD file, so nothing there breaks, but the example is the natural place to demonstrate a third-party group). Follow-ups should be filed when this graduates from proof-of-concept.

Release note

feat(api): ApplicationGroupDefinition CRD (MVP) — register custom API groups served by the cozystack apiserver; ApplicationDefinition gains optional spec.application.group, defaulting to apps.cozystack.io with unchanged behavior

Add a cluster-scoped ApplicationGroupDefinition CRD that registers an
additional API group for the cozystack aggregated apiserver to serve.
The object name must equal spec.group (enforced by CEL), making
registrations unique and immutable by construction; reserved
cozystack.io / k8s.io namespaces and undotted (built-in-shaped) group
names are rejected. ValidateApplicationGroup mirrors the CEL rules in
Go for consumers that must not trust cluster state they did not write.

ApplicationDefinition gains an optional spec.application.group
selecting the group its kind is served in; empty keeps today's
apps.cozystack.io behavior exactly (EffectiveGroup resolves the
default in one place).

The new CRD ships in the application-definition-crd package alongside
applicationdefinitions.

Assisted-By: Claude <[email protected]>
Signed-off-by: Timofei Larkin <[email protected]>
Teach the aggregated apiserver to serve application kinds in any API
group registered through an ApplicationGroupDefinition, instead of
hardwiring apps.cozystack.io everywhere:

- config.ApplicationConfig carries the resolved group; the startup
  loading path (extracted into buildResourceConfig, now unit-testable)
  lists ApplicationGroupDefinitions next to ApplicationDefinitions and
  resolves each definition's group. A definition referencing an
  unregistered group (dangling reference) is skipped with an error log
  and picked up again once the group is registered; kinds must stay
  unique across groups because OpenAPI schema refs are keyed by kind.
- RegisterDynamicTypes registers kinds in their selected group and
  registers each non-default group-version (meta types, priority) the
  way install.Install does for the default group.
- The apiserver installs one APIGroupInfo per group; the default group
  is always installed so its static APIService stays healthy.
- The REST storage derives gvr/gvk (and hence the HelmRelease
  application.group label and every label-selector check) from the
  configured group.
- OpenAPI post-processors stamp each kind with its group; the v3
  processor runs per group-version document and only clones the kinds
  of that document's group (derived from its paths).

ApplicationDefinitions with no group keep today's behavior bit for
bit; group pickup is restart-based via the existing config-hash
rollout (no hot reload yet).

Assisted-By: Claude <[email protected]>
Signed-off-by: Timofei Larkin <[email protected]>
Wire the controller side of ApplicationGroupDefinitions:

- A new ApplicationGroupDefinitionReconciler materializes an
  aggregation-layer APIService (v1alpha1.<group>) per registration,
  cloning the service reference and cert-manager CA-injection
  annotation from the helm-managed v1alpha1.apps.cozystack.io
  reference so custom groups always target the same cozystack-api
  backend. The APIService is owned by its registration, so deleting
  the ApplicationGroupDefinition garbage-collects it. APIServices are
  managed as unstructured objects to avoid a kube-aggregator
  dependency; only reconciler-owned fields are enforced on update, so
  the cert-manager-injected caBundle survives.
- The debounced cozystack-api restart now hashes
  ApplicationGroupDefinitions next to ApplicationDefinitions and
  watches both, so registering or deleting a group rolls the apiserver
  the same way definition changes always have.
- The HelmRelease reconciler selects by the definition's effective
  group instead of a hardcoded apps.cozystack.io, matching the labels
  the REST storage now stamps.

Assisted-By: Claude <[email protected]>
Signed-off-by: Timofei Larkin <[email protected]>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 22e8e991-74cf-4eb3-af9d-c083211f5578

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/application-group-definitions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size/XXL This PR changes 1000+ lines, ignoring generated files area/api Issues or PRs related to the cozystack-api aggregated API server kind/feature Categorizes issue or PR as related to a new feature labels Jul 24, 2026
Timofei Larkin (lllamnyp) added a commit to cozystack/community that referenced this pull request Jul 27, 2026
Propose splitting the managed-app catalog out of cozystack/cozystack
into separately hosted and tested catalog repositories, keeping only
platform-contract apps in-tree. Motivated by core-repo bloat from the
application tail and by full-suite e2e cost paid by every platform PR;
the source-available licensing of some upstreams (Redis, MongoDB) is
recorded as context pending CNCF review (cncf/foundation#1465), not as
a settled driver. Builds on PackageSource delivery and the
ApplicationGroupDefinition MVP (cozystack/cozystack#3448).

Assisted-By: Claude <[email protected]>
Signed-off-by: Timofei Larkin <[email protected]>
@github-actions

Copy link
Copy Markdown

This PR has had no activity for 60 days and was marked lifecycle/stale.
It will be closed in 14 days unless commented or labelled lifecycle/frozen.

@github-actions github-actions Bot added the lifecycle/stale Denotes an issue or PR has remained open with no activity and has become stale label Sep 23, 2026
@myasnikovdaniil myasnikovdaniil added the lifecycle/frozen Indicates that an issue or PR should not be auto-closed due to staleness label Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api Issues or PRs related to the cozystack-api aggregated API server kind/feature Categorizes issue or PR as related to a new feature lifecycle/frozen Indicates that an issue or PR should not be auto-closed due to staleness lifecycle/stale Denotes an issue or PR has remained open with no activity and has become stale size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants