diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 84d5b0f..c00fb52 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,3 +20,34 @@ jobs: - name: Test run: go test ./... -v + + crd-drift: + name: Embedded CRDs in sync with butler-api + runs-on: ubuntu-latest + steps: + - name: Checkout butler-cli + uses: actions/checkout@v4 + with: + path: butler-cli + + # butler-api is the single source of truth for the CRD schemas. butler-api is + # public, so the default token can read it. + - name: Checkout butler-api + uses: actions/checkout@v4 + with: + repository: butlerdotdev/butler-api + path: butler-api + + - name: Re-sync embedded CRDs from butler-api + working-directory: butler-cli + run: BUTLER_API_PATH="$GITHUB_WORKSPACE/butler-api" ./hack/sync-crds.sh + + - name: Fail if the committed copies drifted + working-directory: butler-cli + run: | + if ! git diff --exit-code internal/adm/bootstrap/manifests/crds/; then + echo "::error::Embedded bootstrap CRDs are out of sync with butler-api." \ + "Run 'make sync-crds' against an up-to-date butler-api checkout and commit the result." >&2 + exit 1 + fi + echo "Embedded CRDs match butler-api." diff --git a/Makefile b/Makefile index 695fbc3..d66cf8c 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,13 @@ tidy: generate: go generate ./... +# Sync the embedded bootstrap CRDs from butler-api (the single source of truth). +# Override the source with BUTLER_API_PATH=/path/to/butler-api. The crd-drift CI job +# fails if the committed copies differ from a fresh sync. +.PHONY: sync-crds +sync-crds: + ./hack/sync-crds.sh + # Documentation .PHONY: docs docs: build diff --git a/hack/sync-crds.sh b/hack/sync-crds.sh new file mode 100755 index 0000000..a6d9483 --- /dev/null +++ b/hack/sync-crds.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# hack/sync-crds.sh - Sync CRDs from butler-api into the embedded bootstrap manifests. +# +# butleradm bootstrap embeds these CRD YAMLs (see manifests/embed.go) and applies them +# when standing up a management cluster. butler-api is the single source of truth for the +# CRD schemas; this script is the only supported way to refresh the embedded copies. +# Do NOT hand-edit files under internal/adm/bootstrap/manifests/crds/ - they are +# overwritten here and any manual change is reverted (and caught by the crd-drift CI job). +# +# Usage: +# ./hack/sync-crds.sh [BUTLER_API_PATH] +# BUTLER_API_PATH=/path/to/butler-api ./hack/sync-crds.sh +# Defaults to the sibling checkout ../butler-api. +# +# Copyright 2026 The Butler Authors. +# SPDX-License-Identifier: Apache-2.0 +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BUTLER_API_PATH="${1:-${BUTLER_API_PATH:-$REPO_ROOT/../butler-api}}" +CRD_SOURCE="$BUTLER_API_PATH/config/crd/bases" +CRD_DEST="$REPO_ROOT/internal/adm/bootstrap/manifests/crds" + +if [[ ! -d "$CRD_SOURCE" ]]; then + echo "ERROR: CRD source not found: $CRD_SOURCE" >&2 + echo "Pass the butler-api checkout path as \$1 or set BUTLER_API_PATH." >&2 + exit 1 +fi + +mkdir -p "$CRD_DEST" +echo "Syncing CRDs from: $CRD_SOURCE" + +# The embedded copies are raw controller-gen output, applied verbatim by the bootstrap +# orchestrator, so this is a straight copy (no Helm templating, unlike butler-charts). +synced=0 +for src in "$CRD_SOURCE"/*.yaml; do + [[ -e "$src" ]] || continue + b="$(basename "$src")" + cp "$src" "$CRD_DEST/$b" + echo "SYNC: $b" + synced=$((synced + 1)) +done + +# Prune embedded CRDs that no longer exist upstream so the embedded set stays exactly +# equal to butler-api's (a CRD deleted in butler-api must not linger here). +for dst in "$CRD_DEST"/*.yaml; do + [[ -e "$dst" ]] || continue + b="$(basename "$dst")" + if [[ ! -f "$CRD_SOURCE/$b" ]]; then + echo "PRUNE: $b (no longer in butler-api)" + rm -f "$dst" + fi +done + +echo "Done: $synced CRDs synced to ${CRD_DEST#"$REPO_ROOT"/}" diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_addondefinitions.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_addondefinitions.yaml index 921f9f9..109c250 100644 --- a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_addondefinitions.yaml +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_addondefinitions.yaml @@ -174,6 +174,16 @@ spec: description: Icon is an emoji or icon identifier for UI display. maxLength: 8 type: string + iconData: + description: |- + IconData is a base64-encoded SVG used for inline icon rendering + in the addon catalog UI. Allows self-contained operation without + external image hosting, which is required for air-gapped deployments. + Custom addon authors set this field to embed their own project logo. + When present, takes precedence over the Icon emoji field. + Tracked in butlerdotdev/butler-api#37. + maxLength: 131072 + type: string links: description: Links provides URLs for documentation, source, etc. properties: @@ -204,6 +214,20 @@ spec: Platform addons are installed during cluster bootstrap and cannot be uninstalled via the UI. They appear in a separate section. type: boolean + tier: + description: |- + Tier controls which directory the addon is placed in during GitOps + export: "infrastructure" or "apps". Infrastructure-tier addons + provide CRDs or platform prerequisites that must exist before + workloads that consume them. When set, this takes precedence over + the Platform field for directory placement. When empty, inferred + from Platform: platform addons default to "infrastructure", + non-platform addons default to "apps". + See ADR-015 for design rationale. Tracked in butler-controller#79. + enum: + - infrastructure + - apps + type: string required: - category - chart diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_butlerconfigs.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_butlerconfigs.yaml index ea07765..dbfbbde 100644 --- a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_butlerconfigs.yaml +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_butlerconfigs.yaml @@ -64,6 +64,26 @@ spec: spec: description: ButlerConfigSpec defines the desired state of ButlerConfig. properties: + audit: + description: Audit configures the platform audit log. + properties: + bufferSize: + default: 10000 + description: BufferSize is the in-memory ring buffer capacity + for recent audit queries. + format: int32 + maximum: 100000 + minimum: 100 + type: integer + enabled: + default: true + description: Enabled controls whether audit logging is active. + type: boolean + webhookURL: + description: WebhookURL is an optional URL to POST audit events + to for SIEM integration. + type: string + type: object controlPlaneExposure: description: |- ControlPlaneExposure configures how tenant control planes are exposed. @@ -324,6 +344,15 @@ spec: minimum: 1 type: integer type: object + defaultTimeServers: + description: |- + DefaultTimeServers are the platform-wide default NTP servers for Talos worker nodes. + Can be overridden per-provider via ProviderConfig.spec.network.timeServers + or per-cluster via TenantCluster.spec.timeServers. + If empty, falls back to pool.ntp.org. + items: + type: string + type: array gitProvider: description: |- GitProvider configures the default Git provider for GitOps operations. @@ -425,6 +454,14 @@ spec: - Disabled type: string type: object + notifications: + description: Notifications configures real-time notification forwarding. + properties: + webhookURL: + description: WebhookURL is an optional URL to POST notifications + to (Slack, PagerDuty, Teams, etc). + type: string + type: object observability: description: Observability configures platform-level observability (pipeline, collection defaults). diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_clustercreationpolicies.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_clustercreationpolicies.yaml new file mode 100644 index 0000000..0c8a57b --- /dev/null +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_clustercreationpolicies.yaml @@ -0,0 +1,279 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: clustercreationpolicies.butler.butlerlabs.dev +spec: + group: butler.butlerlabs.dev + names: + kind: ClusterCreationPolicy + listKind: ClusterCreationPolicyList + plural: clustercreationpolicies + shortNames: + - ccp + singular: clustercreationpolicy + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Team this policy targets (team scope) + jsonPath: .spec.scope.team.teamRef.name + name: Team + type: string + - description: Environment this policy targets (teamAndEnvironment scope) + jsonPath: .spec.scope.teamAndEnvironment.environmentName + name: Env + type: string + - description: Providers this policy filters (empty means all) + jsonPath: .spec.targetProviders + name: Providers + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ClusterCreationPolicy is the Schema for admin-curated defaults, pins, + allow-lists, and recommendations applied to the create-cluster + option lists (subnets, images, storage containers, clusters). + Enforced at both butler-server option-list time and at TenantCluster + admission. See ADR-018 for the full design. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + ClusterCreationPolicySpec declares admin-curated constraints over the + create-cluster option lists. Scalar soft defaults remain on + Team.spec.clusterDefaults (see ADR-009); this CRD owns option-list + curation only. See ADR-018 Decision section 1. + properties: + options: + additionalProperties: + description: |- + OptionRule defines the curation behavior for one option type. See + ADR-018 Decision section 5 for mode semantics. Cross-field validation + (pin and allowList require non-empty Values; default requires + non-empty Default; recommended requires non-empty Values) is enforced + by the policy admission webhook in butler-controller, not by CRD + schema; the webhook produces better operator-facing error messages. + properties: + default: + description: |- + Default is the pre-selected entry ID. Required when Mode is + default; optional for allowList (pre-selects within the allowed + set); ignored for recommended; auto-resolves to the single Values + entry for pin when Values has length 1. + type: string + mode: + description: Mode selects the curation behavior. + enum: + - pin + - allowList + - default + - recommended + type: string + recommendedReason: + description: |- + RecommendedReason is the short string shown alongside the + recommended badge in the console. Used only when Mode is + recommended. + maxLength: 120 + type: string + values: + description: |- + Values lists the affected provider entry IDs. Semantics depend on + Mode (see ADR-018 Decision section 5). Validated by the policy + admission webhook against the mode-specific requirements. + items: + type: string + type: array + required: + - mode + type: object + description: |- + Options maps option-type name to its curation rule. Each + ClusterCreationPolicy contains zero or more OptionRule entries + within Options, one rule per option type. A policy is the unit of + authorship and RBAC; a rule is the unit of resolution. + type: object + scope: + description: |- + Scope selects which TenantCluster create requests this policy + applies to. + properties: + platformWide: + description: |- + PlatformWide applies the policy to every team and every + environment on this Butler platform. + type: object + team: + description: Team applies the policy to one team across all of + its environments. + properties: + teamRef: + description: TeamRef names the Team this policy applies to. + properties: + name: + description: Name is the name of the resource. + minLength: 1 + type: string + required: + - name + type: object + required: + - teamRef + type: object + teamAndEnvironment: + description: |- + TeamAndEnvironment applies the policy to one team's named + environment. + properties: + environmentName: + description: |- + EnvironmentName must match a Team.spec.environments[].name on the + referenced Team. + maxLength: 63 + minLength: 1 + type: string + teamRef: + description: TeamRef names the Team this policy applies to. + properties: + name: + description: Name is the name of the resource. + minLength: 1 + type: string + required: + - name + type: object + required: + - environmentName + - teamRef + type: object + type: object + x-kubernetes-validations: + - message: exactly one of platformWide, team, or teamAndEnvironment + must be set + rule: (has(self.platformWide)?1:0) + (has(self.team)?1:0) + (has(self.teamAndEnvironment)?1:0) + == 1 + targetProviders: + description: |- + TargetProviders names the ProviderType values this policy filters. + Empty list means all providers. + items: + description: ProviderType defines the supported infrastructure providers. + enum: + - harvester + - nutanix + - proxmox + - azure + - aws + - gcp + - local + type: string + type: array + required: + - scope + type: object + status: + description: |- + ClusterCreationPolicyStatus captures observed conditions on the + policy. v1 admission webhooks do not populate any fields here; the + fields are reserved for the deferred status reconciler that surfaces + stale references and policy health (per ADR-018 Deferred). + properties: + conditions: + description: |- + Conditions records observed conditions on the policy. Reserved + for the deferred status reconciler; not populated by v1 admission + webhooks. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + staleReferences: + description: |- + StaleReferences names policy entry IDs that no longer exist in + the current provider option list. Reserved for the deferred + status reconciler. + items: + type: string + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_identityproviders.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_identityproviders.yaml index 594bd10..f3aa38f 100644 --- a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_identityproviders.yaml +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_identityproviders.yaml @@ -201,6 +201,35 @@ spec: - issuerURL - redirectURL type: object + platformRoleGroups: + description: |- + PlatformRoleGroups maps IdP groups to platform-wide roles. + Group names are matched using the same normalization as + Team.spec.access.groups (LDAP DN, email-style, and plain + names are all supported). When a user belongs to multiple + matching groups, the highest role wins. + items: + description: PlatformRoleGroupEntry maps an IdP group to a platform-wide + role. + properties: + name: + description: |- + Name is the group identifier as it appears in the OIDC token's + groups claim. + minLength: 1 + type: string + role: + description: Role is the platform role to grant members of this + group. + enum: + - admin + - viewer + type: string + required: + - name + - role + type: object + type: array type: description: |- Type specifies the identity provider type. diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_networkpools.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_networkpools.yaml index ce5c2a6..34f580b 100644 --- a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_networkpools.yaml +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_networkpools.yaml @@ -106,14 +106,46 @@ spec: type: integer type: object end: - description: End is the last allocatable IP address. + description: |- + End is the last allocatable IP address. + Deprecated: Use Ranges for multi-range support. When Ranges is + specified, Start and End are ignored. Removal targeted after all + deployments migrate to Ranges. type: string + ranges: + description: |- + Ranges defines one or more allocatable IP ranges within the pool's + CIDR. When specified, Start and End are ignored. Each range must fall + within the pool's CIDR, ranges must not overlap, and must be sorted + by start IP ascending. Validated by the admission webhook. + items: + description: |- + AllocationRange defines a contiguous block of allocatable IPs. + Maximum 16 ranges per pool. This is sufficient for all expected + topologies; if more are needed, reconsider whether the pool should + be split into multiple NetworkPools. + properties: + end: + description: End is the last allocatable IP address in this + range. + type: string + start: + description: Start is the first allocatable IP address in + this range. + type: string + required: + - end + - start + type: object + maxItems: 16 + type: array start: - description: Start is the first allocatable IP address. + description: |- + Start is the first allocatable IP address. + Deprecated: Use Ranges for multi-range support. When Ranges is + specified, Start and End are ignored. Removal targeted after all + deployments migrate to Ranges. type: string - required: - - end - - start type: object required: - cidr @@ -201,6 +233,89 @@ spec: maximum: 100 minimum: 0 type: integer + infrastructureAllocations: + description: |- + InfrastructureAllocations lists IPs within the pool CIDR that are + consumed by management-plane infrastructure (LoadBalancer Services, Nodes). + Populated by the infrastructure-allocation reconciler. May be absent + on clusters running older CRD versions. + items: + description: |- + InfrastructureAllocation represents a single infrastructure IP observed + within the pool CIDR by a management-cluster resource (e.g., a + LoadBalancer Service or a Node's InternalIP). + properties: + ip: + description: IP is the individual IP address allocated to infrastructure. + pattern: ^(\d{1,3}\.){3}\d{1,3}$ + type: string + machineRef: + description: |- + MachineRef references a cluster infrastructure Machine resource whose VM uses this IP. + Populated when Source is "machine". The Machine's spec.clusterName + identifies which tenant cluster the VM belongs to. + properties: + name: + description: Name is the name of the resource. + minLength: 1 + type: string + namespace: + description: Namespace is the namespace of the resource. + minLength: 1 + type: string + required: + - name + - namespace + type: object + nodeRef: + description: |- + NodeRef references the Kubernetes Node using this IP as its InternalIP. + Populated when Source is "node" or when a Node's InternalIP matches + a Service's LoadBalancer IP. + properties: + name: + description: Name is the name of the resource. + minLength: 1 + type: string + required: + - name + type: object + serviceRef: + description: |- + ServiceRef references the Kubernetes Service consuming this IP. + Populated when a LoadBalancer Service uses this IP. + properties: + name: + description: Name is the name of the resource. + minLength: 1 + type: string + namespace: + description: Namespace is the namespace of the resource. + minLength: 1 + type: string + required: + - name + - namespace + type: object + source: + description: |- + Source identifies the infrastructure system that owns this IP. + Examples: "metallb", "node", "static". + type: string + required: + - ip + - source + type: object + type: array + x-kubernetes-list-map-keys: + - ip + x-kubernetes-list-type: map + infrastructureConsumedIPs: + description: |- + InfrastructureConsumedIPs is the count of unique IPs tracked in + InfrastructureAllocations. + format: int32 + type: integer largestFreeBlock: description: LargestFreeBlock is the size of the largest contiguous free block. @@ -210,11 +325,57 @@ spec: description: ObservedGeneration is the last observed generation. format: int64 type: integer + poolSizeIPs: + description: |- + PoolSizeIPs is the total number of IPs in the pool CIDR + (including gateway, reserved, tenant, and unmanaged space). + format: int32 + type: integer + reservedIPs: + description: ReservedIPs is the total number of IPs across all declared + reserved CIDRs. + format: int32 + type: integer totalIPs: description: TotalIPs is the total number of usable IPs (excluding reserved). format: int32 type: integer + unmanagedRanges: + description: |- + UnmanagedRanges lists the contiguous IP ranges within the pool CIDR + that are not covered by reserved ranges or tenant allocation ranges. + Computed by the controller from the spec. Consumers use this to + understand the pool's unmanaged address space without performing + CIDR arithmetic. + items: + description: |- + AllocationRange defines a contiguous block of allocatable IPs. + Maximum 16 ranges per pool. This is sufficient for all expected + topologies; if more are needed, reconsider whether the pool should + be split into multiple NetworkPools. + properties: + end: + description: End is the last allocatable IP address in this + range. + type: string + start: + description: Start is the first allocatable IP address in this + range. + type: string + required: + - end + - start + type: object + type: array + unmanagedScopeIPs: + description: |- + UnmanagedScopeIPs is the number of IPs in the pool CIDR that are not + covered by reserved ranges or tenant allocation ranges (excluding the + network and broadcast addresses). These IPs are within the pool CIDR + but not managed by Butler's IPAM allocation logic. + format: int32 + type: integer type: object type: object served: true diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_providerconfigs.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_providerconfigs.yaml index f9b7998..7cb32fa 100644 --- a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_providerconfigs.yaml +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_providerconfigs.yaml @@ -131,7 +131,8 @@ spec: - proxmox: "username", "password" or "token" - gcp: "serviceAccountKey" (JSON service account key) Required for all providers except "local", which needs no credentials. - The per-provider requirement is enforced by the ProviderConfig admission webhook. + The local-exempt, non-local-required split is enforced by the CEL validation + rule on this spec (see the XValidation marker on ProviderConfigSpec). properties: key: description: |- @@ -499,6 +500,11 @@ spec: required: - provider type: object + x-kubernetes-validations: + - message: credentialsRef with a non-empty name is required for all providers + except local + rule: self.provider == 'local' || (has(self.credentialsRef) && self.credentialsRef.name + != '') status: description: ProviderConfigStatus defines the observed state of ProviderConfig. properties: diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_teams.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_teams.yaml index 0fe0470..7f1eb56 100644 --- a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_teams.yaml +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_teams.yaml @@ -174,6 +174,178 @@ spec: displayName: description: DisplayName is a human-readable name for the Team. type: string + environments: + description: |- + Environments defines logical groupings of TenantClusters within this Team + (for example dev, stage, prod, per-user sandboxes, shared utilities). + When any environment is defined, new TenantClusters in this Team must + carry the butler.butlerlabs.dev/environment label set to a matching name. + Existing unlabeled TenantClusters continue to work and count against the + Team's total only; the butleradm env migrate command can backfill labels. + items: + description: |- + EnvironmentSpec defines an environment within a Team. + Environments provide logical segmentation for lifecycle stages, per-user + sandboxes, and shared utility clusters without a fixed taxonomy. + properties: + access: + description: |- + Access elevates Team members within this environment. Additive only: + this block cannot reduce a Team-level role. Team admins retain admin + regardless of what Access contains. + properties: + groups: + description: |- + Groups is a list of groups with access to this Team. + Groups are matched against OIDC groups or AD groups. + items: + description: TeamGroup represents a group with access + to a Team. + properties: + identityProvider: + description: |- + IdentityProvider is the name of the IdentityProvider CRD this group comes from. + If specified, only users authenticating through this IdP will be matched. + If not specified, the group name will be matched against groups from any IdP. + type: string + name: + description: |- + Name is the group identifier (OIDC group, AD group DN, etc.). + This can be the full DN for AD groups or simple names for OIDC. + minLength: 1 + type: string + role: + default: viewer + description: |- + Role is the group's role within the Team. + All members of the group inherit this role. + enum: + - admin + - operator + - viewer + type: string + required: + - name + type: object + type: array + users: + description: |- + Users is a list of users with access to this Team. + Users are identified by their email address. + items: + description: TeamUser represents a user with access to + a Team. + properties: + name: + description: |- + Name is the user identifier (email address). + For internal users, this is the email from User.spec.email. + For SSO users, this is the email from the OIDC token. + minLength: 1 + type: string + role: + default: viewer + description: Role is the user's role within the Team. + enum: + - admin + - operator + - viewer + type: string + required: + - name + type: object + type: array + type: object + clusterDefaults: + description: |- + ClusterDefaults apply when a TenantCluster is created in this + environment and does not set the field itself. Environment defaults + win over Team-level ClusterDefaults on conflicts. + properties: + defaultAddons: + description: DefaultAddons are addons automatically installed + on new clusters. + items: + type: string + type: array + kubernetesVersion: + description: KubernetesVersion is the default K8s version + for new clusters. + type: string + workerCPU: + description: WorkerCPU is the default CPU cores per worker. + format: int32 + minimum: 1 + type: integer + workerCount: + description: WorkerCount is the default number of worker + nodes. + format: int32 + minimum: 0 + type: integer + workerDiskGi: + description: WorkerDiskGi is the default disk size per worker + in Gi. + format: int32 + minimum: 10 + type: integer + workerMemoryGi: + description: WorkerMemoryGi is the default memory per worker + in Gi. + format: int32 + minimum: 1 + type: integer + type: object + description: + description: |- + Description is a free-text blurb describing the environment's + purpose. Surfaced in the console env list and cluster context; + not interpreted anywhere in the controller or webhook. + type: string + limits: + description: |- + Limits optionally caps this environment's usage within the Team + ceiling. Unset means no environment-level cap; Team-level cap + still applies. + Team admins can edit this block. Platform admins can also edit. + properties: + maxClusters: + description: |- + MaxClusters caps how many TenantClusters can exist in this + environment. Zero or unset means no env-level cap; the Team + ceiling still applies. + format: int32 + minimum: 0 + type: integer + maxClustersPerMember: + description: |- + MaxClustersPerMember caps how many TenantClusters each individual + Team member can own in this environment. Zero or unset means no + cap. Enables personal sandbox patterns without a first-class + sandbox type: compose the effect from env access scoped to a + single member plus MaxClustersPerMember=1. + format: int32 + minimum: 0 + type: integer + type: object + name: + description: |- + Name is the environment name, operator-chosen. Used as the value + of the butler.butlerlabs.dev/environment label on TenantClusters + in this environment, so the pattern must match Kubernetes + label-value syntax: alphanumeric plus "-" "_" "." with + alphanumeric anchors on both ends, up to 63 characters. + maxLength: 63 + minLength: 1 + pattern: ^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$ + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map providerConfigRef: description: |- ProviderConfigRef references a Team-specific ProviderConfig. diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_tenantclusters.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_tenantclusters.yaml index 177a3ea..b195773 100644 --- a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_tenantclusters.yaml +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_tenantclusters.yaml @@ -151,6 +151,12 @@ spec: ingress: description: Ingress configures the ingress controller. properties: + enabled: + default: true + description: |- + Enabled controls whether the ingress controller is installed on the tenant cluster. + Defaults to true. Set to false to skip ingress controller installation (saves 1 LB IP). + type: boolean provider: description: Provider is the ingress provider. enum: @@ -162,10 +168,9 @@ spec: type: object x-kubernetes-preserve-unknown-fields: true version: - description: Version is the addon version. + description: Version is the addon version. Defaults to the + controller's built-in version when omitted. type: string - required: - - version type: object loadBalancer: description: LoadBalancer configures the load balancer. @@ -534,6 +539,15 @@ spec: required: - name type: object + timeServers: + description: |- + TimeServers overrides the NTP servers used by Talos worker nodes. + If empty, falls back to ProviderConfig.spec.network.timeServers, + then ButlerConfig.spec.defaultTimeServers, then pool.ntp.org. + Required on networks where the Talos default (time.cloudflare.com) is unreachable. + items: + type: string + type: array workers: description: Workers configures the worker nodes. properties: @@ -876,11 +890,15 @@ spec: resources. type: string workerNodesDesired: - description: WorkerNodesDesired is the desired count of worker nodes + description: |- + WorkerNodesDesired is the desired count of worker nodes. + Zero is a valid value (no omitempty) to distinguish "0 desired" from "not set". format: int32 type: integer workerNodesReady: - description: WorkerNodesReady is the count of ready worker nodes + description: |- + WorkerNodesReady is the count of ready worker nodes. + Zero is a valid value (no omitempty) to distinguish "0 ready" from "not set". format: int32 type: integer type: object diff --git a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_users.yaml b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_users.yaml index bb6a077..4327a44 100644 --- a/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_users.yaml +++ b/internal/adm/bootstrap/manifests/crds/butler.butlerlabs.dev_users.yaml @@ -58,9 +58,13 @@ spec: 5. Password is hashed (bcrypt) and stored in a Secret 6. User status changes from Pending to Active - Platform Admin: - Users with spec.isPlatformAdmin=true have full platform access, - bypassing team-based RBAC. This should be used sparingly. + Platform Roles: + Users with spec.platformRole="admin" (or the deprecated + spec.isPlatformAdmin=true) have full platform access, bypassing + team-based RBAC. Users with spec.platformRole="viewer" have + read-only access across all teams and clusters. Platform roles + can also be assigned via IdP group mapping on the IdentityProvider + CRD (spec.platformRoleGroups). properties: apiVersion: description: |- @@ -113,12 +117,24 @@ spec: default: false description: |- IsPlatformAdmin grants full platform access, bypassing team-based RBAC. - Platform admins can: - - Manage all teams, users, clusters, and providers - - Access all resources regardless of team membership - - Configure platform-wide settings - Use sparingly - most users should use team-based access control. + Deprecated: use PlatformRole instead. When true, treated as + PlatformRole "admin" internally. Both fields are honored; the + effective role is the higher of the two. Removal targeted for + v1.0.0 after butler-console and butler-cli migrate to PlatformRole. + Tracked in butler-console#56, butler-cli#37, butler-server#56. type: boolean + platformRole: + description: |- + PlatformRole is the platform-wide role assigned to this user. + "admin" grants full platform access (equivalent to isPlatformAdmin). + "viewer" grants read-only access across all teams and clusters. + Empty string means no platform role; the user has only their + team-scoped permissions. + enum: + - admin + - viewer + - "" + type: string sshKeys: description: |- SSHKeys are the user's saved SSH public keys for workspace access. @@ -256,6 +272,20 @@ spec: in. format: date-time type: string + lastSeenGroups: + description: |- + LastSeenGroups contains the IdP group identifiers from the user's most + recent OIDC token. Written by butler-server at login. Used by the + butler-controller User reconciler to resolve group-based Team membership + without requiring the user to be actively logged in. + + Group identifiers are IdP-specific: UUIDs for Microsoft Entra, email-style + for Google Workspace, plain names for others. Values are stored exactly as + received from the IdP (no normalization). The Team resolution logic + normalizes at match time. + items: + type: string + type: array lockedUntil: description: |- LockedUntil is set when the account is temporarily locked due to failed attempts. @@ -328,6 +358,20 @@ spec: - role type: object type: array + teamsResolvedAt: + description: |- + TeamsResolvedAt is when status.teams was last successfully resolved + by the butler-controller User reconciler. Updated atomically with + status.teams on conditional writes (only when resolved teams differ + from current status.teams). Used by the butler_user_status_stale_age_seconds + metric to surface silent degradation in team resolution. + + When nil (pre-upgrade User CRDs or never-resolved Users), stale_age + computation falls back to the User's creationTimestamp. This produces + the worst-case stale_age value, ensuring unresolved users are visible + in monitoring alerts. + format: date-time + type: string type: object type: object served: true