Skip to content

feat(server): apply ClusterCreationPolicy to provider option-list handlers - #74

Merged
atbagan merged 3 commits into
mainfrom
feat/clustercreationpolicy-resolver
May 19, 2026
Merged

atbagan merged 3 commits into
mainfrom
feat/clustercreationpolicy-resolver

Conversation

@atbagan

@atbagan atbagan commented May 18, 2026 •

Copy link
Copy Markdown
Contributor

Fourth PR in the five-repo ADR-018 implementation chain.

What this adds

Modal-time policy filtering

  • internal/api/policy/ package wrapping butler-api/pkg/policy.Resolve with the response-shape transformation: filter for pin/allowList, reorder for recommended, pass-through for default.
  • internal/api/handlers/providers_policy.go with GetID methods on the four response types plus applyXxxPolicy helpers per option type.
  • Four list handler updates in internal/api/handlers/providers.go. Each now appends an optional policy block to the response envelope.
  • ClusterCreationPolicyGVR added to internal/k8s/client.go.

Admin authoring endpoints (added in commit 326c539 per ADR-018 amendment butlerdotdev/butler-controller#106)

Five new endpoints under /admin/policies enabling UI authoring:

  • GET /admin/policies (platform-viewer + above)
  • GET /admin/policies/{name} (platform-viewer + above)
  • POST /admin/policies (platform-admin only)
  • PUT /admin/policies/{name} (platform-admin only; preserves resourceVersion for optimistic concurrency)
  • DELETE /admin/policies/{name} (platform-admin only)

Webhook denials are unwrapped via the existing writeWebhookError helper into a structured 403 the console renders inline against the offending field (ADR-010).

Depends on

Replace directives in flight

go.mod carries replace github.com/butlerdotdev/butler-api => ../butler-api. Removed at merge phase.

Backward compatibility

Existing API consumers ignore the optional policy block on the four list responses. New admin endpoints have no existing consumers.

Implementation chain status

Verification

  • go build ./... clean
  • go vet ./... clean
  • go test ./internal/api/... passes

…dlers

Wires ADR-018 modal-time policy enforcement into the four provider
option-list handlers (ListImages, ListNetworks, ListClusters,
ListStorageContainers).

- New package internal/api/policy with generic Apply that wraps the
  butler-api/pkg/policy resolver. Apply lists ClusterCreationPolicy
  via the dynamic client, runs the ADR-018 section 6 resolution
  algorithm, and either filters (pin/allowList), reorders
  (recommended), or passes the items through with metadata (default).
- New helpers in providers_policy.go (resolutionContext plus four
  applyXxxPolicy methods). The four ImageInfo, NetworkInfo,
  ClusterInfo, StorageContainerInfo response types get GetID methods
  so the generic policy.Apply filter can compare them against rule
  values without per-type code.
- Each of the four list handlers now appends an optional policy block
  to the response envelope. Existing API consumers ignore the new
  field; the console (PR 5) reads it.
- New ClusterCreationPolicyGVR constant in internal/k8s/client.go for
  symmetry with the other Butler CRDs.
- Resolution context comes from UserSession.SelectedTeam and
  UserSession.SelectedEnvironment, already populated by the existing
  X-Butler-Environment middleware. No new request headers.

Errors fetching policies are non-fatal at list time. The unfiltered
provider response is served and the policy block is omitted, so a
transient List failure does not block the modal entirely.

go.mod carries a replace directive to ../butler-api for the
implementation chain. Removed at merge phase.

Depends on butlerdotdev/butler-api#45.
Andrew Bagan added 2 commits May 18, 2026 13:46
Extends PR #74 scope per ADR-018 amendment promoting UI authoring
to v1 (butler-controller#106).

Five new endpoints under /admin/policies:
- GET /admin/policies — list (platform-viewer + above)
- GET /admin/policies/{name} — fetch (platform-viewer + above)
- POST /admin/policies — create (platform-admin only)
- PUT /admin/policies/{name} — update (platform-admin only)
- DELETE /admin/policies/{name} — delete (platform-admin only)

Thin dynamic-client wrappers around ClusterCreationPolicyGVR. The
admission webhook in butler-controller validates structure on Create
and Update; webhook denials are unwrapped via the existing
writeWebhookError helper into a structured 403 that the console
keys on for inline field-level rendering (see ADR-010).

Update path preserves metadata.resourceVersion for optimistic
concurrency: a concurrent edit returns 409 with a reload-and-retry
hint rather than silently overwriting.

Read routes mounted under the platform-viewer admin block (visible
in the admin UI for viewers); mutation routes mounted under the
admin-only group (RequireAdmin). Matches the existing identity
provider admin endpoint gating.

UI consumer lands in butler-console#76 (PolicyForm + admin pages).
Remove local replace directive now that butler-api v0.21.0 is
published with ClusterCreationPolicy types and resolver.
@atbagan
atbagan merged commit b3f2015 into main May 19, 2026
6 checks passed
@atbagan
atbagan deleted the feat/clustercreationpolicy-resolver branch May 19, 2026 14:23
atbagan added a commit to butlerdotdev/butler-console that referenced this pull request May 19, 2026
…76)

* feat(create-cluster): render ClusterCreationPolicy curation in modal

Consumes the optional policy block butler-server now appends to the
four provider option-list responses. The console does not re-resolve
policy; the server already filtered for pin and allowList and
reordered for recommended, so the modal renders the server-applied
state and adds the operator-facing affordances.

API typings:
- New PolicyMode union and PolicyMetadata interface in
  src/api/providers.ts
- Optional policy field on ImageListResponse, NetworkListResponse,
  ClusterListResponse, StorageContainerListResponse
- Both types re-exported from src/api/index.ts

CreateClusterPage:
- Four new policy state hooks (imagePolicy, networkPolicy,
  clusterPolicy, storagePolicy) capture the policy block from each
  list response
- Auto-select honors policy.default when the default ID is in the
  filtered list, falling back to the existing heuristic otherwise
- New PolicyBadge component renders below affected dropdowns,
  distinguishing pin ("Pinned by policy"), allowList ("Filtered by
  policy"), and recommended ("Recommended by policy" with the
  recommendedReason text). default mode stays silent since the
  pre-selection conveys intent without extra UI.
- HarvesterFields and NutanixFields accept the new policy props and
  render the badge under each dropdown they own (Harvester network
  and image; Nutanix cluster, subnet, image, and storage container).

Depends on butlerdotdev/butler-server#74 for the response envelope
change. Existing API consumers without the new field continue to
work because the policy block is optional.

* feat(console): add admin pages for ClusterCreationPolicy authoring

Extends PR #76 scope per ADR-018 amendment promoting UI authoring
to v1 (butler-controller#106). Companion to butler-server admin
endpoints landed in PR #74 commit 326c539.

API client (src/api/policies.ts, re-exported from src/api/index.ts):
- ClusterCreationPolicy, PolicyOptionType, PolicyOptionMode,
  PolicyOptionRule, PolicyScope, PolicyListResponse types
- WebhookError type matching butler-server's writeWebhookError shape
- policiesApi with list, get, create, update, delete

Shared form (src/components/policy/PolicyForm.tsx):
- Scope picker with three radios (clusterWide, team, teamAndEnv)
  and dependent team / environment dropdowns
- Provider multi-select from the ProviderType enum
- Per-rule editor with Option Type, Mode, Values (textarea), Default
  (Input), Recommended Reason (Input)
- Local-validation messaging plus webhook denial inline rendering
  keyed on WebhookError.field

Three pages under /admin/policies:
- PoliciesListPage — list view with name, scope, providers, option
  types, age columns; empty state with CTA; New Policy button
- PolicyCreatePage — wraps PolicyForm for create flow
- PolicyDetailPage — view mode with three read-only cards (scope,
  providers, option rules); Edit toggles to PolicyForm; Delete with
  confirmation modal

App.tsx wires three routes under RequireAdmin. Sidebar gains a
"Creation Policies" link under the Platform section.

Webhook denials surface inline against the offending field path
(spec.options[<option-type>] for rule conflicts; other field paths
as butler-server forwards them).

* feat(console): pick providers and option values from live cluster state

PolicyForm now fetches the cluster's configured ProviderConfigs at
mount and drives both the targetProviders selection and the value
pickers from live data instead of hardcoded type lists and free-text
ID entry.

targetProviders:
- Filtered to provider types that have at least one ProviderConfig
  on the cluster (queried via providersApi.list). Avoids offering
  aws/azure/gcp when no such ProviderConfig exists.
- Empty-state messaging when no ProviderConfigs are configured.

Discovery provider:
- New section in the form. Picks a specific ProviderConfig that the
  value pickers use as their data source. Defaults to the first
  configured provider matching the selected targetProviders.
- Choice does not change what the policy targets; it sources the
  dropdowns. Targeting is the targetProviders list.

Value pickers per option rule:
- pin, allowList, recommended: chip list of selected entries plus a
  SearchableSelect of remaining entries. Entries labelled by name
  with the ID truncated as a suffix.
- default: SearchableSelect with the discovery provider's entries.
- "Edit raw IDs" per-rule toggle falls back to the original textarea
  or text input. Used for GitOps imports that reference IDs the
  picker can't resolve, or for entries the provider has removed.

Per-option-type provider support matrix lives in
PROVIDER_OPTION_SUPPORT in PolicyForm. Nutanix supports all four;
Harvester supports image and network. Cluster and storageContainer
on Harvester fall back to raw IDs with an inline notice.

Pages drop the now-internal PROVIDER_TYPES constant; the form owns
the configured-types lookup.

* fix(console): keep policy value picker open during search

PolicyForm defined ValuePicker and DefaultPicker as inner functions
inside the PolicyForm component body. Inner-component definitions
create a new function reference on every parent render, so React
treats them as new component types and remounts on every
PolicyForm re-render. The SearchableSelect's internal open state
resets to false on remount, closing the dropdown the instant a
state change rerendered the form (such as the click that opened it).

Fix: inline the picker JSX directly inside the rule loop. No more
inner components. Entry fetching moves to a single useEffect on
PolicyForm keyed on discoveryKey and rules.length, so the form
still lazy-loads option lists when the discovery provider or rule
set changes.

Same behavior as the previous implementation for the user; the
dropdown now stays open and supports filter and keyboard nav as
the underlying SearchableSelect intended.

* fix(console): pin mode is exactly one value

PolicyForm previously rendered pin with the same multi-value chip
list as allowList and recommended. The name "pin" implies a single
fixed value; the form should match. Pin now renders as a
single-select identical in UX to default mode, storing the chosen
ID as Values[0].

Local form validation rejects pin rules with zero or more-than-one
values. The policy admission webhook in butler-controller enforces
the same constraint at the schema level (companion fix in PR #105).

allowList and recommended continue to accept multiple values as
before. The orthogonal mode grid is now clean:

  pin:          single value, enforcing
  default:      single value, advisory
  allowList:    multi value,  enforcing
  recommended:  multi value,  advisory

* chore(sidebar): rename Creation Policies to Policies, dedicated icon

Two small UX improvements on the new admin policies tab.

Name: "Creation Policies" pigeonholes the surface to the single
ClusterCreationPolicy CRD that exists today. The URL is already
/admin/policies (forward-friendly); aligning the label keeps the
sidebar future-proof when other policy CRDs land alongside. The
tab already lives under the Platform section, so prefixing with
"Platform" would be redundant.

Icon: the previous AccessControlIcon (shield + check) is also used
by the Access Control tab right below in the same section. New
PoliciesIcon is a clipboard with a check, the common policy/audit
metaphor, distinct from the shield motif.

Page H1 stays "Cluster Creation Policies" for now since the page
specifically lists that one CRD. Generalizes when other policy
kinds arrive.

* feat(console): /admin/policies is a hub of policy kinds

Previously /admin/policies jumped straight to the ClusterCreationPolicy
list, which pigeonholes the surface. /admin/policies now renders a hub
of cards, one per policy kind, with each card linking into a kind-
specific list. ClusterCreationPolicies is the one card today; future
policy CRDs (upgrade windows, addon governance, network pool
allocation) slot in as additional cards without restructuring routes.

Route restructure:
  /admin/policies                              -> PoliciesHubPage (new)
  /admin/policies/cluster-creation             -> PoliciesListPage
  /admin/policies/cluster-creation/new         -> PolicyCreatePage
  /admin/policies/cluster-creation/:name       -> PolicyDetailPage

Hub card shows: kind title, description, live policy count (fetched
on mount), "Manage" CTA, and the ADR reference tag. Cards are
clickable. Disabled future-kind cards can be added later as
"coming soon" placeholders when work begins.

Sidebar entry unchanged at /admin/policies (now lands on the hub).
Server-side API endpoints unchanged.

Internal navigation in PolicyCreatePage and PolicyDetailPage
updated to redirect to /admin/policies/cluster-creation after
create / delete / cancel. PoliciesListPage create button and detail
link updated likewise.

* chore(policies): add back buttons to nested pages, drop ADR tag

Two UI polish items.

Back buttons: the deeper policy pages now have a back affordance
that returns one level up the hierarchy rather than relying on
browser history.

  Cluster Creation list   -> back to Policies hub
  New Cluster Creation    -> back to Cluster Creation list
  Detail view             -> back to Cluster Creation list
  Detail edit mode        -> back to Detail view (cancels edit)

Each back button is the standard chevron-left icon with the parent
page name as the label, matching the AdminTeamDetailPage pattern.

ADR tag: hub card no longer shows "ADR-018" in a chip. The ADR
reference belongs in the design doc, not as user-facing UI
metadata on the policy authoring surface.

* feat(policies): resolve provider IDs to names on detail view

The detail page previously rendered every option rule as
"values: <uuid>, <uuid>" regardless of mode. Two improvements.

Name resolution. The detail page now fetches the configured
ProviderConfigs and the per-option-type entry lists from the first
eligible discovery provider, then displays each ID alongside the
provider-returned name. Fetch is best-effort and per-option-type
cached in state; on failure the page falls back to bare IDs so the
view still functions when the provider is unreachable.

Mode-specific display:
- pin: rendered as "Value: <name> (<id prefix>)" singular
- allowList and recommended: bulleted list of names with id
  prefix per entry, count in the section heading
- default: rendered as "Default: <name> (<id prefix>)"
- recommended reason: free text on its own line

ID prefix (first 8 chars) shows alongside each name for
disambiguation when multiple entries share a label and for
operator-facing kubectl correlation. Stored IDs are unchanged.

* feat(policies): render scope as parsed fields, not JSON

The Scope card on the detail page rendered the raw spec.scope
object as a JSON pre block. Readable to anyone living in YAML, but
unfriendly to an admin who just wants to see "this policy targets
team observability-engineering".

Replaced with a definition list keyed on the scope kind:

  Cluster-wide:
    Type:        Cluster-wide
    Applies to:  Every team on this cluster

  Team:
    Type:  Team
    Team:  <linked team name -> /admin/teams/:name>

  Team and environment:
    Type:         Team and environment
    Team:         <linked team name>
    Environment:  <env name>

Team name links to the admin team detail page. Environment shown
as plain monospace text since there's no per-env admin view to
link to today. ID prefix not relevant here since teams and envs
are named identifiers, not UUIDs.

* refactor(console): rename clusterWide scope to platformWide

Companion to butler-api commit 76b3e2b and butler-controller commit
ae79e68. The schema rename replaces "Cluster-wide" with
"Platform-wide" since the policy applies to every team and every
environment on the Butler platform, not to a specific Kubernetes
cluster.

- ScopeKind union in PolicyForm: 'clusterWide' -> 'platformWide'
- PolicyScope interface in src/api/policies.ts: field rename
- Form radio label "Cluster-wide" -> "Platform-wide"
- Detail page rendering branches on the new field name with the
  updated user-facing label and "Applies to: Every team and
  environment on this Butler platform"
- List page scope summary: "cluster-wide" -> "platform-wide"

No behavior change. UI strings now match the schema and the
hub copy.

* fix(console): remove em-dash from PoliciesHubPage empty-state

Three-pass review caught an em-dash on PoliciesHubPage as the
empty-state placeholder for the policy count. Butler discipline
disallows em-dashes in shipped code.

Replaced the single em-dash with an ASCII hyphen. PR #76 diff is
now em-dash free; pre-existing em-dashes elsewhere in the codebase
are not in this PR's scope.

---------

Co-authored-by: Andrew Bagan <andrew.bagan-2@corteva.com>
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.

1 participant