Files
headlamp-sealed-secrets-plugin/docs/architecture/adr/007-hooks-vs-context.md
T
2026-03-05 13:49:54 +00:00

6.0 KiB

ADR 007: Custom Hooks Architecture vs Data Context

Status: Accepted

Date: 2026-03-05

Deciders: Development Team


Context

All other Headlamp plugins in this project family (polaris, rook, intel-gpu, kube-vip, tns-csi) use a single React Context provider (*DataProvider) to centralize data fetching and share state across components. This is the established pattern.

The Sealed Secrets plugin has different requirements:

  1. Multiple independent data domains: Controller health, RBAC permissions, SealedSecret CRUD, and encryption are logically separate concerns with different lifecycles.
  2. CRD class extension: SealedSecret extends Headlamp's KubeObject class, providing its own useList() hook — making a centralized fetch redundant for the primary resource.
  3. Write-heavy workflows: Unlike read-only plugins, sealed-secrets creates, encrypts, and rotates resources. The encryption workflow involves multi-step state (certificate fetch → encrypt → create resource).
  4. Independent refresh cadences: Controller health polls every 30 seconds; SealedSecret list is reactive via useList(); RBAC checks run once on mount.

A single context provider would either become a monolithic "god context" or force artificial coupling between unrelated concerns.


Decision

Use independent custom hooks instead of a shared data context:

  • useControllerHealth(autoRefresh?, intervalMs?): Polls controller /healthz endpoint. Returns { healthy, checking, error, refresh }.
  • usePermissions(): Queries RBAC capabilities on mount. Returns permission flags for create, delete, encrypt operations.
  • useSealedSecretEncryption(): Orchestrates the encryption workflow (fetch cert → encrypt values → build manifest). Returns workflow state and action functions.
  • SealedSecret.useList(): Headlamp's built-in KubeObject.useList() — reactive to cluster changes, no custom fetch needed.

Each hook manages its own loading, error, and refresh state. Components compose multiple hooks as needed.

function SealedSecretList() {
  const [secrets, error] = SealedSecret.useList();
  const { healthy } = useControllerHealth(true);
  const { canCreate } = usePermissions();
  // Each concern is independent
}

Consequences

Positive

Separation of concerns: Each hook encapsulates a single domain (health, permissions, encryption, CRUD)

Independent lifecycles: Controller health polls at 30s; RBAC checks once; list is reactive — no unnecessary coupling

Composable: Components pick only the hooks they need, avoiding unnecessary data in scope

Testable in isolation: Each hook can be unit-tested independently without mocking an entire context provider

Leverages Headlamp's KubeObject: SealedSecret.useList() provides reactive list updates without custom fetch logic

Negative

⚠️ Diverges from project convention: Other plugins use the *DataProvider pattern — contributors must learn a different approach for this plugin

⚠️ No single source of truth: State is distributed across hooks rather than centralized — harder to debug "what data does the plugin have right now?"

⚠️ Potential duplicate fetches: If two components both call useControllerHealth(), the health endpoint is polled twice

Mitigation

  • The convention divergence is documented in CLAUDE.md and this ADR
  • Controller health polling is lightweight (single /healthz call)
  • SealedSecret.useList() is internally deduplicated by Headlamp's hook system

Alternatives Considered

1. Single SealedSecretsDataProvider context

Pros:

  • Consistent with other plugins in the project
  • Single source of truth for all sealed-secrets data
  • Deduplicates fetches automatically

Cons:

  • Would become a "god context" with 10+ fields spanning unrelated concerns
  • All consumers re-render when any field changes (health poll triggers list re-render)
  • Encryption workflow state doesn't belong in shared context (it's dialog-scoped)
  • SealedSecret.useList() already provides reactive CRUD — wrapping it in context adds indirection

Rejected: The data domains are too independent; a single context would create artificial coupling.


2. Multiple specialized contexts

Pros:

  • Separation of concerns (like hooks)
  • Consistent with React Context pattern

Cons:

  • Three or four nested providers in index.tsx — deep nesting
  • More boilerplate than hooks (provider + context + consumer hook per domain)
  • No benefit over standalone hooks when providers don't need to share state

Rejected: Contexts add boilerplate without benefit when data domains are independent.


3. State management library (Zustand, Jotai)

Pros:

  • Lightweight, no provider nesting
  • Built-in deduplication and memoization

Cons:

  • External dependency not available in plugin runtime
  • Plugins cannot add npm dependencies beyond Headlamp peer dependencies

Rejected: Dependency constraint makes this infeasible.


Implementation

src/hooks/
├── useControllerHealth.ts       # Health polling with configurable interval
├── usePermissions.ts            # RBAC capability check (runs once)
└── useSealedSecretEncryption.ts # Multi-step encryption workflow
  • Components in src/components/ import hooks directly
  • No provider wrapping needed in index.tsx (except error boundaries)
  • SealedSecret class in src/lib/SealedSecretCRD.ts extends KubeObject for useList()/useGet()

References



Changelog

  • 2026-03-05: Initial decision