diff --git a/Makefile b/Makefile index 01dcfdb5..b1ca9cea 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ OS_SUPPORTED_VERSION ?= "4.22.0-okd-scos.ec.10" ## Tool Binaries KUBECTL ?= kubectl HELM ?= helm +DEV_SETUP_TIMEOUT ?= 10m # Options for 'bundle-build' ifneq ($(origin CHANNELS), undefined) @@ -166,8 +167,8 @@ endef export TLS_CNF CHART ?= "./charts/capsule" CHART_VERSION ?= "./charts/capsule" -dev-setup: dev-setup-cert-manager - $(KUBECTL) -n capsule-system scale deployment capsule-controller-manager --replicas=0 || true +.PHONY: dev-setup dev-setup-flux-handoff +dev-setup: dev-setup-flux-handoff mkdir -p /tmp/k8s-webhook-server/serving-certs echo "$${TLS_CNF}" > _tls.cnf openssl req -newkey rsa:4096 -days 3650 -nodes -x509 \ @@ -224,6 +225,18 @@ dev-setup: dev-setup-cert-manager mkdir -p ./hack/generated/ || true $(KUBECTL) label clusterrole admin projectcapsule.dev/aggregate-to-controller=true bash ./hack/kubeconfig-for-sa.sh $(CLUSTER_NAME) "capsule-system" "capsule" "./hack/generated/kubeconfig.yaml" + $(KUBECTL) -n capsule-system scale deployment capsule-controller-manager --replicas=0 || true + +dev-setup-flux-handoff: dev-setup-cert-manager + @test -n '$(LAPTOP_HOST_IP)' || { echo "LAPTOP_HOST_IP must be set before handing the Capsule release over to local development" >&2; exit 1; } + @if $(KUBECTL) get helmrelease capsule --namespace flux-system >/dev/null 2>&1; then \ + echo "Waiting for the Flux-managed Capsule release to become ready..."; \ + $(KUBECTL) wait helmrelease/capsule --namespace flux-system --for=condition=ready --timeout=$(DEV_SETUP_TIMEOUT); \ + echo "Deleting flux-system/capsule before installing the local development release..."; \ + $(KUBECTL) delete helmrelease capsule --namespace flux-system --wait=true --timeout=$(DEV_SETUP_TIMEOUT); \ + else \ + echo "No Flux-managed Capsule HelmRelease found; continuing with the local development release"; \ + fi setup-monitoring: dev-setup-fluxcd @@ -256,34 +269,10 @@ dev-setup-fluxcd-openshift: dev-setup-openshift-specifics: @$(KUBECTL) apply -f hack/distro/openshift/extend-admin-role.yaml @$(KUBECTL) apply -f hack/distro/openshift/capsule-namespace-deleter.yaml -# Here to setup the current capsule version -# Intended to test updates to new version -dev-setup-capsule: dev-setup-fluxcd - @$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/capsule | envsubst | kubectl apply -f - - @$(MAKE) wait-for-helmreleases - @$(MAKE) dev-setup-capsule-example - -dev-setup-capsule-example: dev-setup-fluxcd - @$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/capsule/example-setup | envsubst | kubectl apply -f - - @$(KUBECTL) create ns wind-uat --as joe --as-group projectcapsule.dev || true - @$(KUBECTL) label ns wind-uat env=test - @$(KUBECTL) create ns wind-test --as joe --as-group projectcapsule.dev || true - @$(KUBECTL) label ns wind-test env=test - @$(KUBECTL) create ns wind-prod --as joe --as-group projectcapsule.dev || true - @$(KUBECTL) label ns wind-prod env=prod - @$(KUBECTL) create ns green-uat --as bob --as-group projectcapsule.dev || true - @$(KUBECTL) label ns green-uat env=test - @$(KUBECTL) create ns green-test --as bob --as-group projectcapsule.dev || true - @$(KUBECTL) label ns green-test env=test - @$(KUBECTL) create ns green-prod --as bob --as-group projectcapsule.dev || true - @$(KUBECTL) label ns green-prod env=prod - @$(KUBECTL) create ns solar-uat --as alice --as-group projectcapsule.dev || true - @$(KUBECTL) label ns solar-uat env=test - @$(KUBECTL) create ns solar-test --as alice --as-group projectcapsule.dev || true - @$(KUBECTL) label ns solar-test env=test - @$(KUBECTL) create ns solar-prod --as alice --as-group projectcapsule.dev || true - @$(KUBECTL) label ns solar-prod env=prod - @$(KUBECTL) apply -f hack/distro/capsule/example-setup/claims.yaml +# Build and deploy the current Capsule checkout in the local playground. +.PHONY: dev-setup-capsule +dev-setup-capsule: + @$(MAKE) -C playground dev-capsule wait-for-helmreleases: diff --git a/api/v1beta2/namespace_options.go b/api/v1beta2/namespace_options.go index 051e0f0a..eba78342 100644 --- a/api/v1beta2/namespace_options.go +++ b/api/v1beta2/namespace_options.go @@ -11,18 +11,26 @@ type NamespaceOptions struct { // +kubebuilder:validation:Minimum=1 // Specifies the maximum number of namespaces allowed for that Tenant. Once the namespace quota assigned to the Tenant has been reached, the Tenant owner cannot create further namespaces. Optional. Quota *int32 `json:"quota,omitempty"` - // Deprecated: Use additionalMetadataList instead (https://projectcapsule.dev/docs/tenants/metadata/#additionalmetadatalist) + // Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) // // Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant. Optional. AdditionalMetadata *api.AdditionalMetadataSpec `json:"additionalMetadata,omitempty"` + // Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) + // // Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant via a list. Optional. AdditionalMetadataList []api.AdditionalMetadataSelectorSpec `json:"additionalMetadataList,omitempty"` + // Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) + // // Required Metadata for namespace within this tenant // +optional RequiredMetadata *RequiredMetadata `json:"requiredMetadata,omitzero"` + // Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) + // // Define the labels that a Tenant Owner cannot set for their Namespace resources. // +optional ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels,omitzero"` + // Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) + // // Define the annotations that a Tenant Owner cannot set for their Namespace resources. // +optional ForbiddenAnnotations api.ForbiddenListSpec `json:"forbiddenAnnotations,omitzero"` diff --git a/api/v1beta2/tenant_types.go b/api/v1beta2/tenant_types.go index 2041bfdd..0b91c368 100644 --- a/api/v1beta2/tenant_types.go +++ b/api/v1beta2/tenant_types.go @@ -40,10 +40,6 @@ type TenantSpec struct { Owners rbac.OwnerListSpec `json:"owners,omitempty"` // Specifies options for the Namespaces, such as additional metadata or maximum number of namespaces allowed for that Tenant. Once the namespace quota assigned to the Tenant has been reached, the Tenant owner cannot create further namespaces. Optional. NamespaceOptions *NamespaceOptions `json:"namespaceOptions,omitempty"` - // Specifies options for the Service, such as additional metadata or block of certain type of Services. Optional. - ServiceOptions *api.ServiceOptions `json:"serviceOptions,omitempty"` - // Specifies options for the Pods deployed in the Tenant namespaces, such as additional metadata. - PodOptions *api.PodOptions `json:"podOptions,omitempty"` // Specifies the allowed StorageClasses assigned to the Tenant. // Capsule assures that all PersistentVolumeClaim resources created in the Tenant can use only one of the allowed StorageClasses. // A default value can be specified, and all the PersistentVolumeClaim resources created will inherit the declared class. @@ -54,9 +50,6 @@ type TenantSpec struct { IngressOptions IngressOptions `json:"ingressOptions,omitzero"` // Specifies the label to control the placement of pods on a given pool of worker nodes. All namespaces created within the Tenant will have the node selector annotation. This annotation tells the Kubernetes scheduler to place pods on the nodes having the selector label. Optional. NodeSelector map[string]string `json:"nodeSelector,omitempty"` - // Specifies a list of ResourceQuota resources assigned to the Tenant. The assigned values are inherited by any namespace created in the Tenant. The Capsule operator aggregates ResourceQuota at Tenant level, so that the hard quota is never crossed for the given Tenant. This permits the Tenant owner to consume resources in the Tenant regardless of the namespace. Optional. - // +optional - ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitzero"` // Specifies additional RoleBindings assigned to the Tenant. Capsule will ensure that all namespaces in the Tenant always contain the RoleBinding for the given ClusterRole. Optional. AdditionalRoleBindings []rbac.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"` // Specifies the allowed RuntimeClasses assigned to the Tenant. @@ -90,6 +83,19 @@ type TenantSpec struct { // Optional ForceTenantPrefix *bool `json:"forceTenantPrefix,omitempty"` + // Deprecated: Use Rules Quota (https://projectcapsule.dev/docs/tenants/rules/#quotas) + // + // Specifies a list of ResourceQuota resources assigned to the Tenant. The assigned values are inherited by any namespace created in the Tenant. The Capsule operator aggregates ResourceQuota at Tenant level, so that the hard quota is never crossed for the given Tenant. This permits the Tenant owner to consume resources in the Tenant regardless of the namespace. Optional. + // +optional + ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitzero"` + // Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/) + // + // Specifies options for the Service, such as additional metadata or block of certain type of Services. Optional. + ServiceOptions *api.ServiceOptions `json:"serviceOptions,omitempty"` + // Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/) + // + // Specifies options for the Pods deployed in the Tenant namespaces, such as additional metadata. + PodOptions *api.PodOptions `json:"podOptions,omitempty"` // Deprecated: Use Enforcement.Registries instead // // Specifies the trusted Image Registries assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed trusted registries. Optional. @@ -98,7 +104,6 @@ type TenantSpec struct { // // Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional. ImagePullPolicies []rules.ImagePullPolicySpec `json:"imagePullPolicies,omitempty"` - // Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/) // // Specifies the NetworkPolicies assigned to the Tenant. The assigned NetworkPolicies are inherited by any namespace created in the Tenant. Optional. diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index a39eb647..5690787b 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -2452,16 +2452,6 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { *out = new(NamespaceOptions) (*in).DeepCopyInto(*out) } - if in.ServiceOptions != nil { - in, out := &in.ServiceOptions, &out.ServiceOptions - *out = new(api.ServiceOptions) - (*in).DeepCopyInto(*out) - } - if in.PodOptions != nil { - in, out := &in.PodOptions, &out.PodOptions - *out = new(api.PodOptions) - (*in).DeepCopyInto(*out) - } if in.StorageClasses != nil { in, out := &in.StorageClasses, &out.StorageClasses *out = new(api.DefaultAllowedListSpec) @@ -2475,7 +2465,6 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { (*out)[key] = val } } - in.ResourceQuota.DeepCopyInto(&out.ResourceQuota) if in.AdditionalRoleBindings != nil { in, out := &in.AdditionalRoleBindings, &out.AdditionalRoleBindings *out = make([]rbac.AdditionalRoleBindingsSpec, len(*in)) @@ -2504,6 +2493,17 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { *out = new(bool) **out = **in } + in.ResourceQuota.DeepCopyInto(&out.ResourceQuota) + if in.ServiceOptions != nil { + in, out := &in.ServiceOptions, &out.ServiceOptions + *out = new(api.ServiceOptions) + (*in).DeepCopyInto(*out) + } + if in.PodOptions != nil { + in, out := &in.PodOptions, &out.PodOptions + *out = new(api.PodOptions) + (*in).DeepCopyInto(*out) + } if in.ContainerRegistries != nil { in, out := &in.ContainerRegistries, &out.ContainerRegistries *out = new(api.AllowedListSpec) diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 8bcd651c..56341972 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -89,7 +89,9 @@ spec: description: Annotations added to the Admission Webhook type: object client: - description: whats the problem + description: |- + Client defines how the Kubernetes API server reaches the admission webhook. + Exactly one of URL or Service must be configured. properties: caBundle: description: |- @@ -564,6 +566,9 @@ spec: required: - client type: object + x-kubernetes-validations: + - message: client must configure exactly one of url or service + rule: has(self.client.url) != has(self.client.service) serviceName: default: capsule-webhook-service description: Service Name of the Admission Service @@ -577,7 +582,9 @@ spec: description: Annotations added to the Admission Webhook type: object client: - description: whats the problem + description: |- + Client defines how the Kubernetes API server reaches the admission webhook. + Exactly one of URL or Service must be configured. properties: caBundle: description: |- @@ -1034,6 +1041,9 @@ spec: required: - client type: object + x-kubernetes-validations: + - message: client must configure exactly one of url or service + rule: has(self.client.url) != has(self.client.service) type: object allowServiceAccountPromotion: default: false diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index e43528cd..d327375d 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -1567,7 +1567,7 @@ spec: properties: additionalMetadata: description: |- - Deprecated: Use additionalMetadataList instead (https://projectcapsule.dev/docs/tenants/metadata/#additionalmetadatalist) + Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant. Optional. properties: @@ -1581,9 +1581,10 @@ spec: type: object type: object additionalMetadataList: - description: Specifies additional labels and annotations the Capsule - operator places on any Namespace resource in the Tenant via - a list. Optional. + description: |- + Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) + + Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant via a list. Optional. items: properties: annotations: @@ -1646,8 +1647,10 @@ spec: type: object type: array forbiddenAnnotations: - description: Define the annotations that a Tenant Owner cannot - set for their Namespace resources. + description: |- + Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) + + Define the annotations that a Tenant Owner cannot set for their Namespace resources. properties: denied: items: @@ -1657,8 +1660,10 @@ spec: type: string type: object forbiddenLabels: - description: Define the labels that a Tenant Owner cannot set - for their Namespace resources. + description: |- + Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) + + Define the labels that a Tenant Owner cannot set for their Namespace resources. properties: denied: items: @@ -1681,7 +1686,10 @@ spec: minimum: 1 type: integer requiredMetadata: - description: Required Metadata for namespace within this tenant + description: |- + Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace) + + Required Metadata for namespace within this tenant properties: annotations: additionalProperties: @@ -2316,8 +2324,10 @@ spec: type: array type: object podOptions: - description: Specifies options for the Pods deployed in the Tenant - namespaces, such as additional metadata. + description: |- + Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/) + + Specifies options for the Pods deployed in the Tenant namespaces, such as additional metadata. properties: additionalMetadata: description: Specifies additional labels and annotations the Capsule @@ -2404,12 +2414,10 @@ spec: type: object x-kubernetes-map-type: atomic resourceQuotas: - description: Specifies a list of ResourceQuota resources assigned - to the Tenant. The assigned values are inherited by any namespace - created in the Tenant. The Capsule operator aggregates ResourceQuota - at Tenant level, so that the hard quota is never crossed for the - given Tenant. This permits the Tenant owner to consume resources - in the Tenant regardless of the namespace. Optional. + description: |- + Deprecated: Use Rules Quota (https://projectcapsule.dev/docs/tenants/rules/#quotas) + + Specifies a list of ResourceQuota resources assigned to the Tenant. The assigned values are inherited by any namespace created in the Tenant. The Capsule operator aggregates ResourceQuota at Tenant level, so that the hard quota is never crossed for the given Tenant. This permits the Tenant owner to consume resources in the Tenant regardless of the namespace. Optional. properties: items: items: @@ -3252,8 +3260,10 @@ spec: type: object x-kubernetes-map-type: atomic serviceOptions: - description: Specifies options for the Service, such as additional - metadata or block of certain type of Services. Optional. + description: |- + Deprecated: Use Rules Metadata instead (https://projectcapsule.dev/docs/rules/enforcement/metadata/) + + Specifies options for the Service, such as additional metadata or block of certain type of Services. Optional. properties: additionalMetadata: description: Specifies additional labels and annotations the Capsule diff --git a/hack/distro/capsule/example-setup/custom-quotas.yaml b/hack/distro/capsule/example-setup/custom-quotas.yaml deleted file mode 100644 index 7ccc77d0..00000000 --- a/hack/distro/capsule/example-setup/custom-quotas.yaml +++ /dev/null @@ -1,59 +0,0 @@ ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: GlobalCustomQuota -metadata: - name: service-aggregate -spec: - limit: 5 - namespaceSelectors: - - matchLabels: - capsule.clastix.io/tenant: wind - sources: - - apiVersion: v1 - kind: Service - op: count - selectors: - - fieldSelectors: - - .spec.type=="ClusterIP" ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: GlobalCustomQuota -metadata: - name: storage-aggregate -spec: - limit: 5Gi - namespaceSelectors: - - matchLabels: - capsule.clastix.io/tenant: wind - sources: - - apiVersion: v1 - kind: Pod - op: add - path: ".spec.volumes[*].ephemeral.volumeClaimTemplate.spec.resources.requests.storage" - - - apiVersion: v1 - kind: PersistentVolumeClaim - op: add - path: ".spec.resources.requests.storage" - selectors: - - fieldSelectors: - - '.spec.accessModes[?(@=="ReadWriteOnce")]' ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: GlobalCustomQuota -metadata: - name: cpu-limits -spec: - limit: 5 - namespaceSelectors: - - matchLabels: - capsule.clastix.io/tenant: wind - sources: - - apiVersion: "v1" - kind: Pod - op: add - path: .spec.containers[*].resources.limits.cpu - - apiVersion: "v1" - kind: Pod - op: add - path: .spec.initContainers[*].resources.limits.cpu diff --git a/hack/distro/capsule/example-setup/kustomization.yaml b/hack/distro/capsule/example-setup/kustomization.yaml deleted file mode 100644 index 602b044e..00000000 --- a/hack/distro/capsule/example-setup/kustomization.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - owners.yaml - - tenants.yaml - - resource.yaml - - pools.yaml - - global-resource-quotas.yaml - - rbac.yaml - - custom-quotas.yaml diff --git a/hack/distro/capsule/example-setup/pools.yaml b/hack/distro/capsule/example-setup/pools.yaml deleted file mode 100644 index e19d31e6..00000000 --- a/hack/distro/capsule/example-setup/pools.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: ResourcePool -metadata: - name: solar-pool -spec: - quota: - hard: - limits.cpu: "2" - limits.memory: 2Gi - requests.cpu: "2" - requests.memory: 2Gi - requests.storage: "5Gi" - selectors: - - matchLabels: - capsule.clastix.io/tenant: solar ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: ResourcePool -metadata: - name: customer-pool -spec: - quota: - hard: - limits.cpu: "2" - limits.memory: 2Gi - requests.cpu: "2" - requests.memory: 2Gi - requests.storage: "5Gi" - selectors: - - matchLabels: - customer: a diff --git a/hack/distro/capsule/example-setup/rbac.yaml b/hack/distro/capsule/example-setup/rbac.yaml deleted file mode 100644 index 4e5a2660..00000000 --- a/hack/distro/capsule/example-setup/rbac.yaml +++ /dev/null @@ -1,31 +0,0 @@ ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: configmap-replicator - labels: - projectcapsule.dev/aggregate-to-controller: "true" -rules: -- apiGroups: [""] - resources: ["configmaps"] - verbs: ["get", "create", "patch", "watch", "list", "delete"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: secret-replicator - labels: - projectcapsule.dev/aggregate-to-controller: "true" -rules: -- apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "create", "patch", "watch", "list", "delete"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: custom:proxy-viewer -rules: -- apiGroups: [""] - resources: ["*"] - verbs: ["list"] diff --git a/hack/distro/capsule/example-setup/resource.yaml b/hack/distro/capsule/example-setup/resource.yaml deleted file mode 100644 index 8f210dda..00000000 --- a/hack/distro/capsule/example-setup/resource.yaml +++ /dev/null @@ -1,43 +0,0 @@ ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: GlobalTenantResource -metadata: - name: gitops-owners -spec: - resyncPeriod: 60s - resources: - - additionalMetadata: - labels: - "replicated-by": "capsule" - rawItems: - - apiVersion: capsule.clastix.io/v1beta2 - kind: TenantOwner - metadata: - name: "{{tenant.name}}-{{namespace}}" - spec: - clusterRoles: - - capsule-namespace-deleter - - admin - kind: ServiceAccount - name: "system:serviceaccount:{{namespace}}:gitops-reconciler" ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: GlobalTenantResource -metadata: - name: cluster-replication -spec: - resyncPeriod: 60s - resources: - - generators: - - template: | - {{ $$key := generateAgeKey }} - --- - apiVersion: v1 - kind: ConfigMap - metadata: - name: "cluster-replication" - namespace: "default" - data: - data: | - identity: {{ $$key.Identity | quote }} - recipient: {{ $$key.Recipient | quote }} diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml deleted file mode 100644 index d7952be3..00000000 --- a/hack/distro/capsule/example-setup/tenants.yaml +++ /dev/null @@ -1,195 +0,0 @@ ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: Tenant -metadata: - name: solar - labels: - customer: a -spec: - owners: - - name: alice - kind: User - rules: - - namespaceSelector: - matchExpressions: - - key: env - operator: In - values: - - "test" - quota: - - name: "max-pods" - hard: - pods: "10" - permissions: - promotions: - - clusterRoles: - - "secret-replicator" - - quota: - - name: shared-compute - hard: - limits.cpu: "8" - limits.memory: 16Gi - requests.cpu: "8" - requests.memory: 16Gi - # classes: - # cluster: - # - matchLabels: - # team: platform - # namespaceSelector: - # matchExpressions: - # - key: env - # operator: In - # values: - # - "test" - # classes: - # gateway: - # - matchLabels: - # team: platform - # ingress: - # - matchLabels: - # team: platform - # storage: - # - matchLabels: - # team: platform - # priority: - # - matchLabels: - # team: platform - # runtime: - # - matchLabels: - # team: platform - # cluster: - # - matchLabels: - # team: platform - # namespaceSelector: - # matchExpressions: - # - key: env - # operator: In - # values: - # - "test" - - permissions: - bindings: - - clusterRoleName: 'custom:proxy-viewer' - subjects: - - apiGroup: rbac.authorization.k8s.io - kind: User - name: joe - labels: - reflection.proxy.projectcapsule.dev/enabled: "true" - enforce: - action: deny - metadata: - - apiGroups: - - "v1" - kinds: - - "Namespace" - labels: - pod-security.kubernetes.io/enforce: - managed: "baseline" - - audience: - - kind: "Custom" - name: "CapsuleUser" - enforce: - action: deny - metadata: - - apiGroups: - - "v1" - kinds: - - "Namespace" - labels: - "openshift.io/*": - required: false - values: - - exp: "^.*$" - - namespaceSelector: - matchExpressions: - - key: env - operator: In - values: - - "test" - permissions: - promotions: - - clusterRoles: - - "secret-replicator" - permissions: - matchOwners: - - matchLabels: - team: platform - - matchLabels: - tenant: solar - namespaceOptions: - additionalMetadata: - labels: - team: platform - annotations: - owner: "team-platform" - additionalMetadataList: - - labels: - customer: a ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: Tenant -metadata: - name: green - labels: - customer: a -spec: - permissions: - matchOwners: - - matchLabels: - team: devops - - matchLabels: - tenant: green - owners: - - name: bob - kind: User - rules: - - permissions: - bindings: - - clusterRoleName: 'custom:proxy-viewer' - subjects: - - apiGroup: rbac.authorization.k8s.io - kind: User - name: alice - labels: - reflection.proxy.projectcapsule.dev/enabled: "true" - - - enforce: - action: "allow" - services: - types: ["ClusterIP", "NodePort", "LoadBalancer", "ExternalName"] - loadBalancers: - cidrs: - - 10.0.0.2/32 - externalNames: - hostnames: - - exp: ".*\\.example\\.com" - exact: - - "internal.git.com" - nodePorts: - ports: - - from: 30000 - to: 32767 ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: Tenant -metadata: - name: wind -spec: - permissions: - matchOwners: - - matchLabels: - team: devops - - matchLabels: - tenant: wind - owners: - - name: joe - kind: User - rules: - - permissions: - bindings: - - clusterRoleName: 'view' - subjects: - - apiGroup: rbac.authorization.k8s.io - kind: Group - name: wind-users diff --git a/internal/controllers/pod/metadata.go b/internal/controllers/pod/metadata.go index d8e5f53f..2ab36bb6 100644 --- a/internal/controllers/pod/metadata.go +++ b/internal/controllers/pod/metadata.go @@ -47,6 +47,7 @@ func (m *MetadataReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Mana Complete(m) } +//nolint:staticcheck func (m *MetadataReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { var pod corev1.Pod @@ -109,6 +110,7 @@ func (m *MetadataReconciler) podsForTenant(ctx context.Context, obj client.Objec return requests } +//nolint:staticcheck func (m *MetadataReconciler) getTenant(ctx context.Context, namespacedName types.NamespacedName, client client.Client) (*capsulev1beta2.Tenant, error) { ns := &corev1.Namespace{} tenant := &capsulev1beta2.Tenant{} diff --git a/internal/controllers/servicelabels/abstract.go b/internal/controllers/servicelabels/abstract.go index 192c2ba7..89b98afc 100644 --- a/internal/controllers/servicelabels/abstract.go +++ b/internal/controllers/servicelabels/abstract.go @@ -32,6 +32,7 @@ type abstractServiceLabelsReconciler struct { log logr.Logger } +//nolint:staticcheck func (r *abstractServiceLabelsReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { tenant, err := r.getTenant(ctx, request.NamespacedName, r.client) if err != nil { @@ -66,6 +67,7 @@ func (r *abstractServiceLabelsReconciler) Reconcile(ctx context.Context, request return reconcile.Result{}, err } +//nolint:staticcheck func (r *abstractServiceLabelsReconciler) getTenant(ctx context.Context, namespacedName types.NamespacedName, client client.Client) (*capsulev1beta2.Tenant, error) { ns := &corev1.Namespace{} tenant := &capsulev1beta2.Tenant{} diff --git a/internal/controllers/tenant/manager.go b/internal/controllers/tenant/manager.go index 457210be..4cf60380 100644 --- a/internal/controllers/tenant/manager.go +++ b/internal/controllers/tenant/manager.go @@ -358,6 +358,7 @@ func (r *Manager) Reconcile(ctx context.Context, request ctrl.Request) (result c return reconcile.Result{}, reconcileError } +//nolint:staticcheck func (r *Manager) reconcile(ctx context.Context, log logr.Logger, instance *capsulev1beta2.Tenant) (err error) { var errs []error diff --git a/internal/controllers/tenant/resourcequotas.go b/internal/controllers/tenant/resourcequotas.go index 3b885b4d..2be7d3db 100644 --- a/internal/controllers/tenant/resourcequotas.go +++ b/internal/controllers/tenant/resourcequotas.go @@ -62,6 +62,7 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, log logr.Logger, tenan }) } +//nolint:staticcheck func (r *Manager) latestResourceQuotaTenant( ctx context.Context, tenant *capsulev1beta2.Tenant, @@ -129,7 +130,8 @@ func (r *Manager) withTenantResourceQuotaSync(tenant string, syncFn func() error return syncFn() } -func (r *Manager) syncResourceQuotasLocked(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) (err error) { //nolint:gocognit +//nolint:gocognit,staticcheck +func (r *Manager) syncResourceQuotasLocked(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) (err error) { if err := r.prepareResourceQuotaSync(ctx, tenant); err != nil { return err } @@ -294,6 +296,7 @@ func (r *Manager) syncResourceQuotasLocked(ctx context.Context, log logr.Logger, }) } +//nolint:staticcheck func (r *Manager) prepareResourceQuotaSync( ctx context.Context, tenant *capsulev1beta2.Tenant, @@ -336,6 +339,7 @@ func (r *Manager) prepareResourceQuotaSync( return nil } +//nolint:staticcheck func (r *Manager) syncResourceQuota(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant, namespace string) (err error) { // getting ResourceQuota labels for the mutateFn var typeLabel string diff --git a/internal/controllers/tls/manager.go b/internal/controllers/tls/manager.go index 11343045..f7141c11 100644 --- a/internal/controllers/tls/manager.go +++ b/internal/controllers/tls/manager.go @@ -284,22 +284,27 @@ func (r *Reconciler) ensureCertificateMaterial( } case hasCABundle && !hasCAKey: - // Legacy mode: we can validate and patch caBundle, but we cannot issue - // a new serving certificate without the CA private key. - log.V(10).Info( - "TLS Secret contains CA bundle but no CA private key; running in legacy CA mode", + // A CA bundle without its private key is typical of an externally + // managed Secret. Once the Capsule TLS controller is enabled, it must + // take ownership of the complete certificate lifecycle; otherwise a + // SAN change or certificate renewal cannot be recovered. + log.V(3).Info( + "TLS Secret contains CA bundle but no CA private key, rotating into controller-managed TLS material", "secret", client.ObjectKeyFromObject(certSecret).String(), ) - if err := r.validateSecretCertificate(certSecret, sans); err != nil { - return nil, nil, false, fmt.Errorf( - "TLS Secret %s contains legacy CA material without ca.key and the serving certificate is invalid: %w", - client.ObjectKeyFromObject(certSecret).String(), - err, - ) + generatedCA, generatedCABundle, generatedCAKey, err := generateCertificateAuthorityMaterial() + if err != nil { + return nil, nil, false, err } - return nil, caBundle, false, nil + ca = generatedCA + caBundle = generatedCABundle + + certSecret.Data[corev1.ServiceAccountRootCAKey] = generatedCABundle + certSecret.Data["ca.key"] = generatedCAKey + + rotateServingCert = true default: log.V(10).Info( diff --git a/internal/controllers/tls/manager_test.go b/internal/controllers/tls/manager_test.go new file mode 100644 index 00000000..2c116fa1 --- /dev/null +++ b/internal/controllers/tls/manager_test.go @@ -0,0 +1,165 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tls + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/runtime/cert" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" +) + +func TestReconcileCertificatesMigratesExternalTLSSecret(t *testing.T) { + t.Parallel() + + const ( + namespace = "capsule-system" + secretName = "capsule-tls" + serviceName = "capsule-webhook-service" + ) + + ctx := context.Background() + externalCABundle, externalCertificate, externalKey := generateTestTLSMaterial(t, cert.CertificateSANs{ + DNSNames: []string{serviceName + "." + namespace + ".svc"}, + }) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + corev1.ServiceAccountRootCAKey: externalCABundle, + corev1.TLSCertKey: externalCertificate, + corev1.TLSPrivateKeyKey: externalKey, + }, + } + + configurationObject := &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + EnableTLSReconciler: true, + CapsuleResources: capsulev1beta2.CapsuleResources{ + TLSSecretName: secretName, + }, + Admission: capsulev1beta2.DynamicAdmission{ + ServiceName: serviceName, + Mutating: &capsulev1beta2.DynamicMutatingAdmissionConfig{}, + Validating: &capsulev1beta2.DynamicValidatingAdmissionConfig{}, + }, + }, + } + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + if err := apiextensionsv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(secret, configurationObject). + Build() + + reconciler := &Reconciler{ + Client: kubeClient, + Log: logr.Discard(), + Namespace: namespace, + Configuration: configuration.NewCapsuleConfiguration( + ctx, + kubeClient, + kubeClient, + nil, + configurationObject.Name, + ), + } + + desiredSANs, err := reconciler.desiredWebhookSANs(ctx) + if err != nil { + t.Fatalf("desiredWebhookSANs() error = %v", err) + } + + if err := reconciler.validateSecretCertificate(secret, desiredSANs); err == nil { + t.Fatal("external serving certificate unexpectedly satisfies all desired SANs") + } + + if err := reconciler.ReconcileCertificates(ctx, logr.Discard(), secret.DeepCopy()); err != nil { + t.Fatalf("ReconcileCertificates() error = %v", err) + } + + updated := &corev1.Secret{} + if err := kubeClient.Get(ctx, client.ObjectKeyFromObject(secret), updated); err != nil { + t.Fatalf("get reconciled TLS Secret: %v", err) + } + + if len(updated.Data["ca.key"]) == 0 { + t.Fatal("reconciled TLS Secret does not contain ca.key") + } + + if bytes.Equal(updated.Data[corev1.ServiceAccountRootCAKey], externalCABundle) { + t.Fatal("reconciled TLS Secret retained the external CA bundle") + } + + if bytes.Equal(updated.Data[corev1.TLSCertKey], externalCertificate) { + t.Fatal("reconciled TLS Secret retained the external serving certificate") + } + + if _, err := cert.NewCertificateAuthorityFromBytes( + updated.Data[corev1.ServiceAccountRootCAKey], + updated.Data["ca.key"], + ); err != nil { + t.Fatalf("reconciled CA certificate/key pair is invalid: %v", err) + } + + if err := reconciler.validateSecretCertificate(updated, desiredSANs); err != nil { + t.Fatalf("reconciled serving certificate is invalid: %v", err) + } +} + +func generateTestTLSMaterial( + t *testing.T, + sans cert.CertificateSANs, +) ([]byte, []byte, []byte) { + t.Helper() + + ca, err := cert.GenerateCertificateAuthority() + if err != nil { + t.Fatalf("generate test CA: %v", err) + } + + caBundle, err := ca.CACertificatePem() + if err != nil { + t.Fatalf("encode test CA: %v", err) + } + + certificate, key, err := ca.GenerateCertificate(cert.NewCertOpts( + time.Now().Add(certificateValidity), + sans, + )) + if err != nil { + t.Fatalf("generate test serving certificate: %v", err) + } + + return caBundle.Bytes(), certificate.Bytes(), key.Bytes() +} diff --git a/internal/webhook/cfg/validation.go b/internal/webhook/cfg/validation.go index 46e6d51a..4b093d31 100644 --- a/internal/webhook/cfg/validation.go +++ b/internal/webhook/cfg/validation.go @@ -70,6 +70,10 @@ func (h *validationHandler) handle( config *capsulev1beta2.CapsuleConfiguration, req admission.Request, ) *admission.Response { + if err := validateAdmissionClients(config.Spec.Admission); err != nil { + return ad.Deny(err.Error()) + } + if err := h.validateRegex( "spec.protectedNamespaceRegex", config.Spec.ProtectedNamespaceRegexpString, @@ -94,6 +98,22 @@ func (h *validationHandler) handle( return nil } +func validateAdmissionClients(config capsulev1beta2.DynamicAdmission) error { + if config.Validating != nil { + if err := ad.ValidateWebhookClientConfig(config.Validating.Client); err != nil { + return fmt.Errorf("spec.admission.validating.client is invalid: %w", err) + } + } + + if config.Mutating != nil { + if err := ad.ValidateWebhookClientConfig(config.Mutating.Client); err != nil { + return fmt.Errorf("spec.admission.mutating.client is invalid: %w", err) + } + } + + return nil +} + func (h *validationHandler) validateRegex(fieldPath string, value string) error { if strings.TrimSpace(value) == "" { return nil diff --git a/internal/webhook/cfg/validation_test.go b/internal/webhook/cfg/validation_test.go new file mode 100644 index 00000000..b8c93af5 --- /dev/null +++ b/internal/webhook/cfg/validation_test.go @@ -0,0 +1,94 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cfg + +import ( + "testing" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + runtimeadmission "github.com/projectcapsule/capsule/pkg/runtime/admission" +) + +func TestValidateAdmissionClients(t *testing.T) { + t.Parallel() + + webhookURL := "https://capsule.example.com" + service := &admissionregistrationv1.ServiceReference{ + Name: "capsule-webhook-service", + Namespace: "capsule-system", + } + + tests := []struct { + name string + config capsulev1beta2.DynamicAdmission + wantErr bool + }{ + {name: "not configured"}, + { + name: "validating url", + config: capsulev1beta2.DynamicAdmission{ + Validating: dynamicValidatingConfig(&admissionregistrationv1.WebhookClientConfig{URL: &webhookURL}), + }, + }, + { + name: "mutating service", + config: capsulev1beta2.DynamicAdmission{ + Mutating: dynamicMutatingConfig(&admissionregistrationv1.WebhookClientConfig{Service: service}), + }, + }, + { + name: "validating missing client", + config: capsulev1beta2.DynamicAdmission{ + Validating: dynamicValidatingConfig(nil), + }, + wantErr: true, + }, + { + name: "mutating has neither", + config: capsulev1beta2.DynamicAdmission{ + Mutating: dynamicMutatingConfig(&admissionregistrationv1.WebhookClientConfig{}), + }, + wantErr: true, + }, + { + name: "mutating has both", + config: capsulev1beta2.DynamicAdmission{ + Mutating: dynamicMutatingConfig(&admissionregistrationv1.WebhookClientConfig{ + URL: &webhookURL, + Service: service, + }), + }, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateAdmissionClients(test.config) + if (err != nil) != test.wantErr { + t.Fatalf("validateAdmissionClients() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func dynamicValidatingConfig( + client *admissionregistrationv1.WebhookClientConfig, +) *capsulev1beta2.DynamicValidatingAdmissionConfig { + return &capsulev1beta2.DynamicValidatingAdmissionConfig{ + DynamicAdmissionConfig: runtimeadmission.DynamicAdmissionConfig{Client: client}, + } +} + +func dynamicMutatingConfig( + client *admissionregistrationv1.WebhookClientConfig, +) *capsulev1beta2.DynamicMutatingAdmissionConfig { + return &capsulev1beta2.DynamicMutatingAdmissionConfig{ + DynamicAdmissionConfig: runtimeadmission.DynamicAdmissionConfig{Client: client}, + } +} diff --git a/internal/webhook/namespace/validation/required_metadata.go b/internal/webhook/namespace/validation/required_metadata.go index 15779099..3328385e 100644 --- a/internal/webhook/namespace/validation/required_metadata.go +++ b/internal/webhook/namespace/validation/required_metadata.go @@ -24,6 +24,7 @@ func RequiredMetadataHandler() handlers.TypedHandlerWithTenantUser[*corev1.Names return &requiredMetadataHandler{} } +//nolint:staticcheck func (h *requiredMetadataHandler) OnCreate( _ client.Client, _ client.Reader, @@ -61,6 +62,7 @@ func (h *requiredMetadataHandler) OnCreate( } } +//nolint:staticcheck func (h *requiredMetadataHandler) OnUpdate( _ client.Client, _ client.Reader, diff --git a/internal/webhook/namespace/validation/user_metadata.go b/internal/webhook/namespace/validation/user_metadata.go index 63d6690c..391b8dd5 100644 --- a/internal/webhook/namespace/validation/user_metadata.go +++ b/internal/webhook/namespace/validation/user_metadata.go @@ -149,6 +149,7 @@ func (h *userMetadataHandler) OnDelete( } } +//nolint:staticcheck func validateUserMetadata( ctx context.Context, req admission.Request, diff --git a/internal/webhook/service/validating.go b/internal/webhook/service/validating.go index ccf472ef..e815ef36 100644 --- a/internal/webhook/service/validating.go +++ b/internal/webhook/service/validating.go @@ -72,6 +72,7 @@ func (h *validating) OnDelete( } } +//nolint:staticcheck func (h *validating) handle( ctx context.Context, req admission.Request, diff --git a/internal/webhook/tenant/validation/forbidden_annotations_regex.go b/internal/webhook/tenant/validation/forbidden_annotations_regex.go index 5f4e512a..8deb8912 100644 --- a/internal/webhook/tenant/validation/forbidden_annotations_regex.go +++ b/internal/webhook/tenant/validation/forbidden_annotations_regex.go @@ -67,6 +67,7 @@ func (h *forbiddenAnnotationsRegexHandler) OnUpdate( } } +//nolint:staticcheck func (h *forbiddenAnnotationsRegexHandler) validate(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response { if tnt == nil || tnt.Spec.NamespaceOptions == nil { return nil diff --git a/internal/webhook/tenant/validation/namespace_metadata.go b/internal/webhook/tenant/validation/namespace_metadata.go index c47cae82..97bed890 100644 --- a/internal/webhook/tenant/validation/namespace_metadata.go +++ b/internal/webhook/tenant/validation/namespace_metadata.go @@ -63,6 +63,7 @@ func (h *namespaceMetadataHandler) OnUpdate( } } +//nolint:staticcheck func validateTenantNamespaceMetadata(tnt *capsulev1beta2.Tenant) *admission.Response { if tnt == nil { return nil diff --git a/internal/webhook/tenant/validation/required_metdata_regex.go b/internal/webhook/tenant/validation/required_metdata_regex.go index 8652da87..665b4984 100644 --- a/internal/webhook/tenant/validation/required_metdata_regex.go +++ b/internal/webhook/tenant/validation/required_metdata_regex.go @@ -72,6 +72,7 @@ func (h *requiredMetadataHandler) OnUpdate( } } +//nolint:staticcheck func (h *requiredMetadataHandler) validate(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response { no := tnt.Spec.NamespaceOptions if no == nil || no.RequiredMetadata == nil { @@ -118,6 +119,7 @@ func requiredMetadataChanged(oldT, newT *capsulev1beta2.Tenant) bool { return false } +//nolint:staticcheck func getRequiredMetadata(t *capsulev1beta2.Tenant) *capsulev1beta2.RequiredMetadata { // Adjust the return type to your actual struct type: // e.g. *capsulev1beta2.NamespaceRequiredMetadata or similar. diff --git a/internal/webhook/tenant/validation/warnings.go b/internal/webhook/tenant/validation/warnings.go index 545f2b30..7aeedb8f 100644 --- a/internal/webhook/tenant/validation/warnings.go +++ b/internal/webhook/tenant/validation/warnings.go @@ -82,6 +82,8 @@ func (h *warningHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Reques } } + response.Warnings = append(response.Warnings, deprecatedTenantFieldWarnings(tnt)...) + //nolint:staticcheck if len(tnt.Spec.LimitRanges.Items) > 0 { response.Warnings = append(response.Warnings, @@ -96,12 +98,7 @@ func (h *warningHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Reques ) } - //nolint:staticcheck - if tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.AdditionalMetadata != nil { - response.Warnings = append(response.Warnings, - "The field `additionalMetadata` is deprecated and will be removed in a future release. Please migrate to `additionalMetadataList`. See: https://projectcapsule.dev/docs/tenants/metadata/#additionalmetadatalist.", - ) - } + response.Warnings = append(response.Warnings, deprecatedNamespaceOptionWarnings(tnt)...) //nolint:staticcheck if tnt.Spec.StorageClasses != nil && tnt.Spec.StorageClasses.Regex != "" { @@ -143,3 +140,73 @@ func (h *warningHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Reques return response } + +func deprecatedTenantFieldWarnings(tnt *capsulev1beta2.Tenant) (warnings []string) { + //nolint:staticcheck + if tnt.Spec.ResourceQuota.Scope != "" || len(tnt.Spec.ResourceQuota.Items) > 0 { + warnings = append(warnings, + "The field `resourceQuotas` is deprecated and will be removed in a future release. Please migrate to rules quotas. See: https://projectcapsule.dev/docs/tenants/rules/#quotas.", + ) + } + + //nolint:staticcheck + if tnt.Spec.ServiceOptions != nil { + warnings = append(warnings, + "The field `serviceOptions` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/.", + ) + } + + //nolint:staticcheck + if tnt.Spec.PodOptions != nil { + warnings = append(warnings, + "The field `podOptions` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/.", + ) + } + + return warnings +} + +func deprecatedNamespaceOptionWarnings(tnt *capsulev1beta2.Tenant) (warnings []string) { + if tnt.Spec.NamespaceOptions == nil { + return warnings + } + + //nolint:staticcheck + if tnt.Spec.NamespaceOptions.AdditionalMetadata != nil { + warnings = append(warnings, + "The field `additionalMetadata` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.", + ) + } + + //nolint:staticcheck + if len(tnt.Spec.NamespaceOptions.AdditionalMetadataList) > 0 { + warnings = append(warnings, + "The field `additionalMetadataList` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.", + ) + } + + //nolint:staticcheck + if tnt.Spec.NamespaceOptions.RequiredMetadata != nil { + warnings = append(warnings, + "The field `requiredMetadata` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.", + ) + } + + //nolint:staticcheck + if len(tnt.Spec.NamespaceOptions.ForbiddenLabels.Exact) > 0 || + tnt.Spec.NamespaceOptions.ForbiddenLabels.Regex != "" { + warnings = append(warnings, + "The field `forbiddenLabels` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.", + ) + } + + //nolint:staticcheck + if len(tnt.Spec.NamespaceOptions.ForbiddenAnnotations.Exact) > 0 || + tnt.Spec.NamespaceOptions.ForbiddenAnnotations.Regex != "" { + warnings = append(warnings, + "The field `forbiddenAnnotations` is deprecated and will be removed in a future release. Please migrate to rules metadata. See: https://projectcapsule.dev/docs/rules/enforcement/metadata/#namespace.", + ) + } + + return warnings +} diff --git a/internal/webhook/tenant/validation/warnings_test.go b/internal/webhook/tenant/validation/warnings_test.go new file mode 100644 index 00000000..1e478b33 --- /dev/null +++ b/internal/webhook/tenant/validation/warnings_test.go @@ -0,0 +1,142 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" +) + +//nolint:staticcheck +func TestDeprecatedTenantFieldsWarnings(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configure func(*capsulev1beta2.Tenant) + field string + }{ + { + name: "resource quota items", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.ResourceQuota.Items = []corev1.ResourceQuotaSpec{{}} + }, + field: "`resourceQuotas`", + }, + { + name: "resource quota scope", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.ResourceQuota.Scope = api.ResourceQuotaScopeTenant + }, + field: "`resourceQuotas`", + }, + { + name: "service options", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.ServiceOptions = &api.ServiceOptions{} + }, + field: "`serviceOptions`", + }, + { + name: "pod options", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.PodOptions = &api.PodOptions{} + }, + field: "`podOptions`", + }, + { + name: "additional metadata list", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{ + AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{{}}, + } + }, + field: "`additionalMetadataList`", + }, + { + name: "required metadata", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{ + RequiredMetadata: &capsulev1beta2.RequiredMetadata{}, + } + }, + field: "`requiredMetadata`", + }, + { + name: "forbidden labels exact", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{ + ForbiddenLabels: api.ForbiddenListSpec{Exact: []string{"blocked"}}, + } + }, + field: "`forbiddenLabels`", + }, + { + name: "forbidden labels regex", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{ + ForbiddenLabels: api.ForbiddenListSpec{Regex: "blocked-.*"}, + } + }, + field: "`forbiddenLabels`", + }, + { + name: "forbidden annotations exact", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{ + ForbiddenAnnotations: api.ForbiddenListSpec{Exact: []string{"blocked"}}, + } + }, + field: "`forbiddenAnnotations`", + }, + { + name: "forbidden annotations regex", + configure: func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{ + ForbiddenAnnotations: api.ForbiddenListSpec{Regex: "blocked-.*"}, + } + }, + field: "`forbiddenAnnotations`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tnt := &capsulev1beta2.Tenant{} + tt.configure(tnt) + + response := (&warningHandler{}).handle(tnt, admission.Request{}) + if len(response.Warnings) != 1 { + t.Fatalf("warnings = %v, want exactly one warning", response.Warnings) + } + + if !strings.Contains(response.Warnings[0], tt.field) { + t.Fatalf("warning = %q, want field %s", response.Warnings[0], tt.field) + } + }) + } +} + +func TestDeprecatedTenantFieldsWarningsAreAbsentForZeroValues(t *testing.T) { + t.Parallel() + + tnt := &capsulev1beta2.Tenant{ + Spec: capsulev1beta2.TenantSpec{ + NamespaceOptions: &capsulev1beta2.NamespaceOptions{}, + }, + } + + response := (&warningHandler{}).handle(tnt, admission.Request{}) + if len(response.Warnings) != 0 { + t.Fatalf("warnings = %v, want no warnings", response.Warnings) + } +} diff --git a/pkg/runtime/admission/dynamic.go b/pkg/runtime/admission/dynamic.go index 9ada4836..cba2a7e6 100644 --- a/pkg/runtime/admission/dynamic.go +++ b/pkg/runtime/admission/dynamic.go @@ -4,6 +4,7 @@ package admission import ( + "errors" "strings" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" @@ -12,6 +13,7 @@ import ( ) // +kubebuilder:object:generate=true +// +kubebuilder:validation:XValidation:rule="has(self.client.url) != has(self.client.service)",message="client must configure exactly one of url or service" type DynamicAdmissionConfig struct { // Name the Admission Webhook Name meta.RFC1123Name `json:"name,omitempty"` @@ -21,10 +23,28 @@ type DynamicAdmissionConfig struct { // Annotations added to the Admission Webhook // +optional Annotations map[string]string `json:"annotations,omitempty"` - // whats the problem + // Client defines how the Kubernetes API server reaches the admission webhook. + // Exactly one of URL or Service must be configured. Client *admissionregistrationv1.WebhookClientConfig `json:"client"` } +// ValidateWebhookClientConfig checks the invariant required by the Kubernetes +// admissionregistration API before a dynamic webhook object is constructed. +func ValidateWebhookClientConfig(client *admissionregistrationv1.WebhookClientConfig) error { + if client == nil { + return errors.New("webhook client config is required") + } + + hasURL := client.URL != nil + + hasService := client.Service != nil + if hasURL == hasService { + return errors.New("webhook client config must configure exactly one of url or service") + } + + return nil +} + func DynamicWebhookURL(baseURL *string, webhookPath string) *string { cleanPath := normalizePath(webhookPath) if cleanPath == "" { diff --git a/pkg/runtime/admission/dynamic_test.go b/pkg/runtime/admission/dynamic_test.go index 3426107a..4b27e648 100644 --- a/pkg/runtime/admission/dynamic_test.go +++ b/pkg/runtime/admission/dynamic_test.go @@ -11,6 +11,52 @@ import ( admissionregistrationv1 "k8s.io/api/admissionregistration/v1" ) +func TestValidateWebhookClientConfig(t *testing.T) { + t.Parallel() + + webhookURL := "https://capsule.example.com" + service := &admissionregistrationv1.ServiceReference{ + Name: "capsule-webhook-service", + Namespace: "capsule-system", + } + + tests := []struct { + name string + client *admissionregistrationv1.WebhookClientConfig + wantErr bool + }{ + {name: "nil", wantErr: true}, + {name: "neither", client: &admissionregistrationv1.WebhookClientConfig{}, wantErr: true}, + { + name: "url", + client: &admissionregistrationv1.WebhookClientConfig{URL: &webhookURL}, + }, + { + name: "service", + client: &admissionregistrationv1.WebhookClientConfig{Service: service}, + }, + { + name: "both", + client: &admissionregistrationv1.WebhookClientConfig{ + URL: &webhookURL, + Service: service, + }, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := admission.ValidateWebhookClientConfig(test.client) + if (err != nil) != test.wantErr { + t.Fatalf("ValidateWebhookClientConfig() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + func TestDynamicClientWithPath_EmptyPath_NoChange(t *testing.T) { t.Parallel() diff --git a/pkg/runtime/admission/webhook.go b/pkg/runtime/admission/webhook.go index 661b8592..5a5b9859 100644 --- a/pkg/runtime/admission/webhook.go +++ b/pkg/runtime/admission/webhook.go @@ -24,6 +24,10 @@ type WebhookOptions struct { } func NewValidatingWebhook(in *ValidatingWebhook, c *admissionregistrationv1.WebhookClientConfig, users rbac.UserListSpec, admins rbac.UserListSpec) (admissionregistrationv1.ValidatingWebhook, error) { + if err := ValidateWebhookClientConfig(c); err != nil { + return admissionregistrationv1.ValidatingWebhook{}, err + } + out := admissionregistrationv1.ValidatingWebhook{ Name: in.Name, Rules: in.Rules, @@ -212,6 +216,10 @@ type ValidatingWebhook struct { } func NewMutatingWebhook(in *MutatingWebhook, c *admissionregistrationv1.WebhookClientConfig, users rbac.UserListSpec, admins rbac.UserListSpec) (admissionregistrationv1.MutatingWebhook, error) { + if err := ValidateWebhookClientConfig(c); err != nil { + return admissionregistrationv1.MutatingWebhook{}, err + } + out := admissionregistrationv1.MutatingWebhook{ Name: in.Name, Rules: in.Rules, diff --git a/pkg/runtime/predicates/controller_work.go b/pkg/runtime/predicates/controller_work.go index 9042510e..1c422a53 100644 --- a/pkg/runtime/predicates/controller_work.go +++ b/pkg/runtime/predicates/controller_work.go @@ -275,6 +275,8 @@ type TenantPodOptionsChangedPredicate struct{ predicate.Funcs } func (TenantPodOptionsChangedPredicate) Create(event.CreateEvent) bool { return false } func (TenantPodOptionsChangedPredicate) Delete(event.DeleteEvent) bool { return false } func (TenantPodOptionsChangedPredicate) Generic(event.GenericEvent) bool { return false } + +//nolint:staticcheck func (TenantPodOptionsChangedPredicate) Update(e event.UpdateEvent) bool { oldTenant, oldOK := e.ObjectOld.(*capsulev1beta2.Tenant) newTenant, newOK := e.ObjectNew.(*capsulev1beta2.Tenant) @@ -287,6 +289,8 @@ type TenantServiceOptionsChangedPredicate struct{ predicate.Funcs } func (TenantServiceOptionsChangedPredicate) Create(event.CreateEvent) bool { return false } func (TenantServiceOptionsChangedPredicate) Delete(event.DeleteEvent) bool { return false } func (TenantServiceOptionsChangedPredicate) Generic(event.GenericEvent) bool { return false } + +//nolint:staticcheck func (TenantServiceOptionsChangedPredicate) Update(e event.UpdateEvent) bool { oldTenant, oldOK := e.ObjectOld.(*capsulev1beta2.Tenant) newTenant, newOK := e.ObjectNew.(*capsulev1beta2.Tenant) diff --git a/pkg/tenant/metdata.go b/pkg/tenant/metdata.go index ded990cd..3cd3da89 100644 --- a/pkg/tenant/metdata.go +++ b/pkg/tenant/metdata.go @@ -130,6 +130,7 @@ func BuildInstanceMetadataForNamespace(ns *corev1.Namespace, tnt *capsulev1beta2 return labels, annotations } +//nolint:staticcheck func BuildNamespaceMetadataForTenant(ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) (labels map[string]string, annotations map[string]string, err error) { annotations = BuildNamespaceAnnotationsForTenant(tnt) labels = BuildNamespaceLabelsForTenant(tnt) diff --git a/playground/.env.example b/playground/.env.example new file mode 100644 index 00000000..5304083e --- /dev/null +++ b/playground/.env.example @@ -0,0 +1,23 @@ +# Hostnames used by the browser and ingress resources. +CLUSTER_NAME=capsule +PROXY_PORT=9001 +DEX_HOST=dex.capsule.local +HEADLAMP_HOST=headlamp.capsule.local +PROXY_HOST=proxy.capsule.local +GANGPLANK_HOST=gangplank.capsule.local + +# Public URLs advertised to OIDC clients. Override these independently when +# using HTTPS or a different local DNS setup. +DEX_URL=https://dex.capsule.local +HEADLAMP_URL=https://headlamp.capsule.local +PROXY_URL=https://proxy.capsule.local:9001 +GANGPLANK_URL=http://gangplank.capsule.local + +# The API server validates Dex tokens with these claim mappings. The `name` +# claim matches the alice/bob/gatsby tenant owners in the sample resources. +OIDC_CLIENT_ID=kubernetes +OIDC_CLIENT_SECRET=kubernetes-secret +OIDC_USERNAME_CLAIM=name +OIDC_USERNAME_PREFIX= +OIDC_GROUPS_CLAIM=groups +OIDC_GROUPS_PREFIX=oidc: diff --git a/playground/.gitignore b/playground/.gitignore new file mode 100644 index 00000000..d62910b6 --- /dev/null +++ b/playground/.gitignore @@ -0,0 +1,2 @@ +.env +installation/.generated/ diff --git a/playground/Makefile b/playground/Makefile new file mode 100644 index 00000000..c3f5e7fc --- /dev/null +++ b/playground/Makefile @@ -0,0 +1,304 @@ +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help +.NOTPARALLEL: + +CLUSTER_NAME ?= capsule +PROXY_PORT ?= 9001 +CONTEXT = kind-$(CLUSTER_NAME) +KUBECTL = kubectl --context $(CONTEXT) +TIMEOUT ?= 10m +ROOT_DIR := $(abspath $(CURDIR)/..) +CAPSULE_CHART ?= $(ROOT_DIR)/charts/capsule +DEV_IMAGE_REGISTRY ?= ko.local +DEV_IMAGE_REPOSITORY ?= capsule +DEV_IMAGE := $(DEV_IMAGE_REGISTRY)/$(DEV_IMAGE_REPOSITORY) +DEV_GIT_COMMIT := $(shell git -C '$(ROOT_DIR)' rev-parse --short HEAD) +ifeq ($(origin DEV_VERSION), undefined) +DEV_VERSION := dev-$(shell date -u +%Y%m%d%H%M%S) +endif +CAPSULE_POST_RENDERER := $(CURDIR)/scripts/capsule-post-renderer.sh +HELM_PLUGINS_DIR := $(CURDIR)/helm-plugins +GENERATED_DIR := $(CURDIR)/installation/.generated +DEX_CA_FILE := $(GENERATED_DIR)/ca.crt +AUTHENTICATION_CONFIG_FILE := $(GENERATED_DIR)/authentication-config.yaml +PLAYGROUND_CA_SHA256 = $(shell test -s '$(GENERATED_DIR)/ca.sha256' && sed -n '1p' '$(GENERATED_DIR)/ca.sha256') +PLAYGROUND_CA_BUNDLE = $(shell test -s '$(GENERATED_DIR)/ca.base64' && sed -n '1p' '$(GENERATED_DIR)/ca.base64') +CAPSULE_TLS_CERT_BUNDLE = $(shell test -s '$(GENERATED_DIR)/capsule.crt.base64' && sed -n '1p' '$(GENERATED_DIR)/capsule.crt.base64') +CAPSULE_TLS_KEY_BUNDLE = $(shell test -s '$(GENERATED_DIR)/capsule.key.base64' && sed -n '1p' '$(GENERATED_DIR)/capsule.key.base64') + +-include .env + +DEX_HOST ?= dex.capsule.local +HEADLAMP_HOST ?= headlamp.capsule.local +PROXY_HOST ?= proxy.capsule.local +GANGPLANK_HOST ?= gangplank.capsule.local +DEX_URL ?= https://$(DEX_HOST) +HEADLAMP_URL ?= https://$(HEADLAMP_HOST) +PROXY_URL ?= https://$(PROXY_HOST):$(PROXY_PORT) +GANGPLANK_URL ?= http://$(GANGPLANK_HOST) +OIDC_CLIENT_ID ?= kubernetes +OIDC_CLIENT_SECRET ?= kubernetes-secret +OIDC_USERNAME_CLAIM ?= name +OIDC_USERNAME_PREFIX ?= +OIDC_GROUPS_CLAIM ?= groups +OIDC_GROUPS_PREFIX ?= oidc: +LOCAL_HOSTS = $(DEX_HOST) $(HEADLAMP_HOST) $(PROXY_HOST) $(GANGPLANK_HOST) + +export DEX_HOST HEADLAMP_HOST PROXY_HOST GANGPLANK_HOST +export DEX_URL HEADLAMP_URL PROXY_URL GANGPLANK_URL +export CLUSTER_NAME PROXY_PORT +export DEX_CA_FILE AUTHENTICATION_CONFIG_FILE +export PLAYGROUND_CA_SHA256 PLAYGROUND_CA_BUNDLE +export CAPSULE_TLS_CERT_BUNDLE CAPSULE_TLS_KEY_BUNDLE +export OIDC_CLIENT_ID OIDC_CLIENT_SECRET OIDC_USERNAME_CLAIM OIDC_USERNAME_PREFIX +export OIDC_GROUPS_CLAIM OIDC_GROUPS_PREFIX + +SUBSTITUTION_VARIABLES := '$${CLUSTER_NAME} $${PROXY_PORT} $${DEX_HOST} $${HEADLAMP_HOST} $${PROXY_HOST} $${GANGPLANK_HOST} $${DEX_URL} $${HEADLAMP_URL} $${PROXY_URL} $${GANGPLANK_URL} $${DEX_CA_FILE} $${AUTHENTICATION_CONFIG_FILE} $${PLAYGROUND_CA_SHA256} $${PLAYGROUND_CA_BUNDLE} $${CAPSULE_TLS_CERT_BUNDLE} $${CAPSULE_TLS_KEY_BUNDLE} $${OIDC_CLIENT_ID} $${OIDC_CLIENT_SECRET} $${OIDC_USERNAME_CLAIM} $${OIDC_USERNAME_PREFIX} $${OIDC_GROUPS_CLAIM} $${OIDC_GROUPS_PREFIX}' + +.PHONY: help check check-dev hosts certificates render-kind render-authentication cluster node-hosts flux apply render apply-platform apply-user wait-oidc configure-oidc wait status dev-image dev-capsule dev capsule-stable up down + +help: ## Show the available playground targets. + @awk 'BEGIN {FS = ":.*## "; print "Capsule local playground\n"} /^[a-zA-Z_-]+:.*## / {printf " %-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +check: ## Check required local tools and the Docker daemon. + @for tool in curl docker envsubst flux jq kind kubectl openssl; do \ + command -v "$$tool" >/dev/null || { echo "Missing required tool: $$tool" >&2; exit 1; }; \ + done + @[[ '$(DEX_HOST)' =~ ^[a-zA-Z0-9.-]+$$ ]] || { echo "DEX_HOST is not a valid DNS hostname" >&2; exit 1; } + @[[ '$(DEX_URL)' == 'https://$(DEX_HOST)' ]] || { echo "DEX_URL must be https://$(DEX_HOST) for local API-server OIDC" >&2; exit 1; } + @[[ '$(HEADLAMP_URL)' == 'https://$(HEADLAMP_HOST)' ]] || { echo "HEADLAMP_URL must be https://$(HEADLAMP_HOST) for local ingress TLS" >&2; exit 1; } + @docker info >/dev/null 2>&1 || { echo "Docker is not running" >&2; exit 1; } + +check-dev: check ## Check the additional tools and files needed for a local Capsule build. + @for tool in go helm; do \ + command -v "$$tool" >/dev/null || { echo "Missing required development tool: $$tool" >&2; exit 1; }; \ + done + @test -d '$(CAPSULE_CHART)' || { echo "Capsule chart not found: $(CAPSULE_CHART)" >&2; exit 1; } + @test -x '$(CAPSULE_POST_RENDERER)' || { echo "Capsule post-renderer is not executable: $(CAPSULE_POST_RENDERER)" >&2; exit 1; } + @test -f '$(HELM_PLUGINS_DIR)/capsule-playground/plugin.yaml' || { echo "Capsule Helm post-renderer plugin is missing" >&2; exit 1; } + +hosts: ## Register the playground hostnames in the local /etc/hosts file. + @for local_host in $(LOCAL_HOSTS); do \ + [[ "$$local_host" =~ ^[a-zA-Z0-9.-]+$$ ]] || { echo "Invalid local hostname: $$local_host" >&2; exit 1; }; \ + if awk -v host="$$local_host" '!/^[[:space:]]*#/ { for (field = 2; field <= NF; field++) if ($$field == host) found = 1 } END { exit !found }' /etc/hosts; then \ + echo "Already registered: $$local_host"; \ + else \ + printf '127.0.0.1\t%s\n' "$$local_host" | sudo tee -a /etc/hosts >/dev/null; \ + echo "Registered: $$local_host"; \ + fi; \ + done + +certificates: ## Generate one persistent local CA and its service certificates. + @set -eu; umask 077; mkdir -p '$(GENERATED_DIR)'; ca_changed=false; \ + if [[ ! -s '$(GENERATED_DIR)/ca.crt' || ! -s '$(GENERATED_DIR)/ca.key' ]]; then \ + printf '%s\n' '[req]' 'distinguished_name=dn' 'prompt=no' 'x509_extensions=v3_ca' '[dn]' 'CN=Capsule Playground CA' '[v3_ca]' 'basicConstraints=critical,CA:TRUE' 'keyUsage=critical,keyCertSign,cRLSign' 'subjectKeyIdentifier=hash' > '$(GENERATED_DIR)/ca.cnf'; \ + openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 3650 -config '$(GENERATED_DIR)/ca.cnf' -keyout '$(GENERATED_DIR)/ca.key' -out '$(GENERATED_DIR)/ca.crt' >/dev/null 2>&1; \ + ca_changed=true; \ + echo "Generated the persistent Capsule Playground CA" >&2; \ + fi; \ + openssl dgst -sha256 -r '$(GENERATED_DIR)/ca.crt' | awk '{print $$1}' > '$(GENERATED_DIR)/ca.sha256'; \ + openssl base64 -A -in '$(GENERATED_DIR)/ca.crt' > '$(GENERATED_DIR)/ca.base64'; \ + if $$ca_changed || [[ ! -s '$(GENERATED_DIR)/dex.crt' || ! -s '$(GENERATED_DIR)/dex.key' || ! -f '$(GENERATED_DIR)/hostname' || "$$(< '$(GENERATED_DIR)/hostname')" != '$(DEX_HOST) $(HEADLAMP_HOST)' ]] || ! openssl verify -CAfile '$(GENERATED_DIR)/ca.crt' '$(GENERATED_DIR)/dex.crt' >/dev/null 2>&1; then \ + printf '%s\n' '[req]' 'distinguished_name=dn' 'prompt=no' 'req_extensions=server' '[dn]' 'CN=$(DEX_HOST)' '[server]' 'basicConstraints=critical,CA:FALSE' 'keyUsage=critical,digitalSignature,keyEncipherment' 'extendedKeyUsage=serverAuth' 'subjectAltName=DNS:$(DEX_HOST),DNS:$(HEADLAMP_HOST)' > '$(GENERATED_DIR)/dex.cnf'; \ + openssl req -new -newkey rsa:2048 -nodes -sha256 -config '$(GENERATED_DIR)/dex.cnf' -keyout '$(GENERATED_DIR)/dex.key' -out '$(GENERATED_DIR)/dex.csr' >/dev/null 2>&1; \ + openssl x509 -req -sha256 -days 825 -in '$(GENERATED_DIR)/dex.csr' -CA '$(GENERATED_DIR)/ca.crt' -CAkey '$(GENERATED_DIR)/ca.key' -set_serial 1 -extfile '$(GENERATED_DIR)/dex.cnf' -extensions server -out '$(GENERATED_DIR)/dex.crt' >/dev/null 2>&1; \ + printf '%s\n' '$(DEX_HOST) $(HEADLAMP_HOST)' > '$(GENERATED_DIR)/hostname'; \ + echo "Generated a local TLS certificate for $(DEX_HOST) and $(HEADLAMP_HOST)" >&2; \ + fi; \ + if $$ca_changed || [[ ! -s '$(GENERATED_DIR)/proxy.crt' || ! -s '$(GENERATED_DIR)/proxy.key' || ! -f '$(GENERATED_DIR)/proxy-hostname' || "$$(< '$(GENERATED_DIR)/proxy-hostname')" != '$(PROXY_HOST)' ]] || ! openssl verify -CAfile '$(GENERATED_DIR)/ca.crt' '$(GENERATED_DIR)/proxy.crt' >/dev/null 2>&1; then \ + printf '%s\n' '[req]' 'distinguished_name=dn' 'prompt=no' 'req_extensions=server' '[dn]' 'CN=capsule-proxy.capsule-system.svc' '[server]' 'basicConstraints=critical,CA:FALSE' 'keyUsage=critical,digitalSignature,keyEncipherment' 'extendedKeyUsage=serverAuth' 'subjectAltName=DNS:localhost,DNS:$(PROXY_HOST),DNS:capsule-proxy,DNS:capsule-proxy.capsule-system.svc,IP:127.0.0.1' > '$(GENERATED_DIR)/proxy.cnf'; \ + openssl req -new -newkey rsa:2048 -nodes -sha256 -config '$(GENERATED_DIR)/proxy.cnf' -keyout '$(GENERATED_DIR)/proxy.key' -out '$(GENERATED_DIR)/proxy.csr' >/dev/null 2>&1; \ + openssl x509 -req -sha256 -days 825 -in '$(GENERATED_DIR)/proxy.csr' -CA '$(GENERATED_DIR)/ca.crt' -CAkey '$(GENERATED_DIR)/ca.key' -set_serial 2 -extfile '$(GENERATED_DIR)/proxy.cnf' -extensions server -out '$(GENERATED_DIR)/proxy.crt' >/dev/null 2>&1; \ + printf '%s\n' '$(PROXY_HOST)' > '$(GENERATED_DIR)/proxy-hostname'; \ + echo "Generated a local TLS certificate for Capsule Proxy" >&2; \ + fi; \ + if $$ca_changed || [[ ! -s '$(GENERATED_DIR)/capsule.crt' || ! -s '$(GENERATED_DIR)/capsule.key' ]] || ! openssl verify -CAfile '$(GENERATED_DIR)/ca.crt' '$(GENERATED_DIR)/capsule.crt' >/dev/null 2>&1; then \ + printf '%s\n' '[req]' 'distinguished_name=dn' 'prompt=no' 'req_extensions=server' '[dn]' 'CN=capsule-webhook-service.capsule-system.svc' '[server]' 'basicConstraints=critical,CA:FALSE' 'keyUsage=critical,digitalSignature,keyEncipherment' 'extendedKeyUsage=serverAuth' 'subjectAltName=DNS:capsule-webhook-service.capsule-system.svc,DNS:capsule-webhook-service.capsule-system.svc.cluster.local' > '$(GENERATED_DIR)/capsule.cnf'; \ + openssl req -new -newkey rsa:2048 -nodes -sha256 -config '$(GENERATED_DIR)/capsule.cnf' -keyout '$(GENERATED_DIR)/capsule.key' -out '$(GENERATED_DIR)/capsule.csr' >/dev/null 2>&1; \ + openssl x509 -req -sha256 -days 825 -in '$(GENERATED_DIR)/capsule.csr' -CA '$(GENERATED_DIR)/ca.crt' -CAkey '$(GENERATED_DIR)/ca.key' -set_serial 3 -extfile '$(GENERATED_DIR)/capsule.cnf' -extensions server -out '$(GENERATED_DIR)/capsule.crt' >/dev/null 2>&1; \ + echo "Generated a local TLS certificate for the Capsule webhook" >&2; \ + fi; \ + openssl base64 -A -in '$(GENERATED_DIR)/capsule.crt' > '$(GENERATED_DIR)/capsule.crt.base64'; \ + openssl base64 -A -in '$(GENERATED_DIR)/capsule.key' > '$(GENERATED_DIR)/capsule.key.base64'; \ + if [[ ! -f '$(AUTHENTICATION_CONFIG_FILE)' ]]; then \ + printf '%s\n' 'apiVersion: apiserver.config.k8s.io/v1' 'kind: AuthenticationConfiguration' 'jwt: []' > '$(AUTHENTICATION_CONFIG_FILE)'; \ + fi + +render-kind: certificates ## Render the kind configuration with OIDC settings. + @envsubst $(SUBSTITUTION_VARIABLES) < kind.config.yaml + +render-authentication: certificates ## Render the API-server OIDC authentication config. + @printf '%s\n' 'apiVersion: apiserver.config.k8s.io/v1' 'kind: AuthenticationConfiguration' 'jwt:' ' - issuer:' ' url: "$(DEX_URL)"' ' audiences:' ' - "$(OIDC_CLIENT_ID)"' ' certificateAuthority: |' + @sed 's/^/ /' '$(DEX_CA_FILE)' + @printf '%s\n' ' claimMappings:' ' username:' ' claim: "$(OIDC_USERNAME_CLAIM)"' ' prefix: "$(OIDC_USERNAME_PREFIX)"' ' groups:' ' claim: "$(OIDC_GROUPS_CLAIM)"' ' prefix: "$(OIDC_GROUPS_PREFIX)"' + +cluster: check certificates ## Create the kind cluster unless it already exists. + @if kind get clusters 2>/dev/null | grep -qx '$(CLUSTER_NAME)'; then \ + docker exec '$(CLUSTER_NAME)-control-plane' grep -Fq -- '--authentication-config=/etc/kubernetes/pki/playground-authentication-config.yaml' /etc/kubernetes/manifests/kube-apiserver.yaml || { echo "Existing cluster does not use the playground authentication config; run make down, then make up" >&2; exit 1; }; \ + echo "kind cluster $(CLUSTER_NAME) already exists with reloadable authentication"; \ + else \ + printf '%s\n' 'apiVersion: apiserver.config.k8s.io/v1' 'kind: AuthenticationConfiguration' 'jwt: []' > '$(AUTHENTICATION_CONFIG_FILE)'; \ + kind_config="$$(mktemp)"; trap 'rm -f "$$kind_config"' EXIT; \ + envsubst $(SUBSTITUTION_VARIABLES) < kind.config.yaml > "$$kind_config"; \ + kind create cluster --config "$$kind_config"; \ + fi + @$(MAKE) node-hosts + +node-hosts: ## Make the Dex ingress host reachable from the API-server static Pod. + @worker_ip="$$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' '$(CLUSTER_NAME)-worker')"; \ + test -n "$$worker_ip" || { echo "Could not determine the kind worker IP" >&2; exit 1; }; \ + for node in $$(kind get nodes --name '$(CLUSTER_NAME)'); do \ + docker exec "$$node" grep -Fq '$(DEX_HOST)' /etc/hosts || docker exec "$$node" sh -c 'printf "%s\n" "$$1" >> /etc/hosts' sh "$$worker_ip $(DEX_HOST)"; \ + done; \ + manifest=/etc/kubernetes/manifests/kube-apiserver.yaml; \ + if ! docker exec '$(CLUSTER_NAME)-control-plane' grep -Fq ' - "$(DEX_HOST)"' "$$manifest"; then \ + if docker exec '$(CLUSTER_NAME)-control-plane' grep -q '^ hostAliases:' "$$manifest"; then \ + docker exec '$(CLUSTER_NAME)-control-plane' sed -i "/^ hostAliases:$$/a\\ - ip: \"$$worker_ip\"\\n hostnames:\\n - \"$(DEX_HOST)\"" "$$manifest"; \ + else \ + docker exec '$(CLUSTER_NAME)-control-plane' sed -i "/^ hostNetwork: true$$/i\\ hostAliases:\\n - ip: \"$$worker_ip\"\\n hostnames:\\n - \"$(DEX_HOST)\"" "$$manifest"; \ + fi; \ + fi; \ + for attempt in $$(seq 1 60); do \ + $(KUBECTL) get --raw /readyz >/dev/null 2>&1 && exit 0; \ + sleep 1; \ + done; \ + echo "API server did not become ready after configuring the Dex host alias" >&2; \ + exit 1 + +flux: ## Install the Flux controllers in the local cluster. + @flux install --context $(CONTEXT) + +render: certificates ## Render the installation manifests with environment substitution. + @kubectl kustomize installation | envsubst $(SUBSTITUTION_VARIABLES) + +apply: certificates ## Apply the rendered installation to the local cluster. + @kubectl kustomize installation | envsubst $(SUBSTITUTION_VARIABLES) | $(KUBECTL) apply -f - + +apply-headlamp: ## Apply the rendered installation to the local cluster. + @kubectl kustomize installation | envsubst $(SUBSTITUTION_VARIABLES) | $(KUBECTL) apply -f - + + +apply-platform: + @kubectl kustomize platform | envsubst $(SUBSTITUTION_VARIABLES) | $(KUBECTL) apply -f - + +apply-user: + @$(KUBECTL) get ns wind-uat >/dev/null 2>&1 || $(KUBECTL) create ns wind-uat --as gatsby --as-group projectcapsule.dev + @$(KUBECTL) label ns wind-uat env=test --overwrite + @$(KUBECTL) get ns wind-test >/dev/null 2>&1 || $(KUBECTL) create ns wind-test --as gatsby --as-group projectcapsule.dev + @$(KUBECTL) label ns wind-test env=test --overwrite + @$(KUBECTL) get ns wind-prod >/dev/null 2>&1 || $(KUBECTL) create ns wind-prod --as gatsby --as-group projectcapsule.dev + @$(KUBECTL) label ns wind-prod env=prod --overwrite + @$(KUBECTL) get ns green-uat >/dev/null 2>&1 || $(KUBECTL) create ns green-uat --as bob --as-group projectcapsule.dev + @$(KUBECTL) label ns green-uat env=test --overwrite + @$(KUBECTL) get ns green-test >/dev/null 2>&1 || $(KUBECTL) create ns green-test --as bob --as-group projectcapsule.dev + @$(KUBECTL) label ns green-test env=test --overwrite + @$(KUBECTL) get ns green-prod >/dev/null 2>&1 || $(KUBECTL) create ns green-prod --as bob --as-group projectcapsule.dev + @$(KUBECTL) label ns green-prod env=prod --overwrite + @$(KUBECTL) get ns solar-uat >/dev/null 2>&1 || $(KUBECTL) create ns solar-uat --as alice --as-group projectcapsule.dev + @$(KUBECTL) label ns solar-uat env=test --overwrite + @$(KUBECTL) get ns solar-test >/dev/null 2>&1 || $(KUBECTL) create ns solar-test --as alice --as-group projectcapsule.dev + @$(KUBECTL) label ns solar-test env=test --overwrite + @$(KUBECTL) get ns solar-prod >/dev/null 2>&1 || $(KUBECTL) create ns solar-prod --as alice --as-group projectcapsule.dev + @$(KUBECTL) label ns solar-prod env=prod --overwrite + @kubectl kustomize user | envsubst $(SUBSTITUTION_VARIABLES) | $(KUBECTL) apply -f - + + +wait-oidc: certificates ## Wait for ingress and Dex before enabling API-server OIDC. + @$(KUBECTL) wait --namespace flux-system --for=condition=ready helmrelease/ingress-nginx helmrelease/dex --timeout=$(TIMEOUT) + @for attempt in $$(seq 1 60); do \ + issuer="$$(curl --fail --silent --show-error --cacert '$(DEX_CA_FILE)' --resolve '$(DEX_HOST):443:127.0.0.1' '$(DEX_URL)/.well-known/openid-configuration' 2>/dev/null | jq --raw-output '.issuer // empty' 2>/dev/null)" || true; \ + if [[ "$$issuer" == '$(DEX_URL)' ]]; then \ + echo "Dex discovery endpoint is ready"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "Dex discovery endpoint did not advertise $(DEX_URL) within 60 seconds" >&2; \ + exit 1 + +configure-oidc: certificates ## Enable the API-server JWT authenticator after Dex is reachable. + @issuer="$$(curl --fail --silent --show-error --cacert '$(DEX_CA_FILE)' --resolve '$(DEX_HOST):443:127.0.0.1' '$(DEX_URL)/.well-known/openid-configuration' | jq --raw-output '.issuer')"; \ + [[ "$$issuer" == '$(DEX_URL)' ]] || { echo "Dex advertised unexpected issuer: $$issuer" >&2; exit 1; } + @$(MAKE) --no-print-directory render-authentication > '$(AUTHENTICATION_CONFIG_FILE)' + @docker exec '$(CLUSTER_NAME)-control-plane' grep -Fq 'url: "$(DEX_URL)"' /etc/kubernetes/pki/playground-authentication-config.yaml + @expected_hash="$$(openssl dgst -sha256 -r '$(AUTHENTICATION_CONFIG_FILE)' | awk '{print $$1}')"; \ + for attempt in $$(seq 1 120); do \ + metrics="$$( $(KUBECTL) get --raw /metrics )"; \ + if grep -Eq "^apiserver_authentication_config_controller_last_config_info\\{[^}]*hash=\\\"sha256:$$expected_hash\\\"[^}]*\\} 1$$" <<< "$$metrics"; then \ + echo "Enabled API-server OIDC authentication for $(DEX_URL)"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "API server did not activate authentication config sha256:$$expected_hash within 120 seconds" >&2; \ + exit 1 + +wait: ## Wait until every playground Helm release is ready. + @$(KUBECTL) wait --namespace flux-system --for=condition=ready helmrelease --all --timeout=$(TIMEOUT) + +status: ## Show nodes, Helm releases, ingresses, and pods. + @$(KUBECTL) get nodes + @$(KUBECTL) get helmreleases --namespace flux-system + @$(KUBECTL) get ingress --all-namespaces + @$(KUBECTL) get pods --all-namespaces + +dev-image: check-dev ## Build Capsule from the current checkout and load it into kind. + @$(MAKE) --no-print-directory -C '$(ROOT_DIR)' ko-build-capsule CAPSULE_IMG='$(DEV_IMAGE)' VERSION='$(DEV_VERSION)' GIT_TAG_COMMIT='$(DEV_GIT_COMMIT)' + @kind load docker-image '$(DEV_IMAGE):$(DEV_VERSION)' --name '$(CLUSTER_NAME)' + +dev-capsule: check-dev certificates ## Deploy the current Capsule source and chart into a running playground. + @$(KUBECTL) get helmrelease capsule --namespace flux-system >/dev/null 2>&1 || { echo "Capsule is not installed; run make up first" >&2; exit 1; } + @helm status capsule --kube-context '$(CONTEXT)' --namespace capsule-system >/dev/null 2>&1 || { echo "The Capsule Helm release is not ready; run make up first" >&2; exit 1; } + @$(MAKE) --no-print-directory dev-image + @flux suspend helmrelease capsule --context '$(CONTEXT)' --namespace flux-system + @set -e; values_file="$$(mktemp)"; \ + cleanup() { result=$$?; rm -f "$$values_file"; if [[ $$result -ne 0 ]]; then echo "Local Helm upgrade failed; resuming the Flux-managed Capsule release" >&2; flux resume helmrelease capsule --context '$(CONTEXT)' --namespace flux-system --wait --timeout '$(TIMEOUT)' || true; fi; exit $$result; }; \ + trap cleanup EXIT; \ + helm get values capsule --kube-context '$(CONTEXT)' --namespace capsule-system --output yaml > "$$values_file"; \ + post_renderer='$(CAPSULE_POST_RENDERER)'; \ + if [[ "$$(helm version --template '{{.Version}}')" == v4.* ]]; then export HELM_PLUGINS='$(HELM_PLUGINS_DIR)'; post_renderer='capsule-playground'; fi; \ + helm upgrade capsule '$(CAPSULE_CHART)' \ + --kube-context '$(CONTEXT)' \ + --namespace capsule-system \ + --dependency-update \ + --values "$$values_file" \ + --set-string 'manager.image.registry=$(DEV_IMAGE_REGISTRY)' \ + --set-string 'manager.image.repository=$(DEV_IMAGE_REPOSITORY)' \ + --set-string 'manager.image.tag=$(DEV_VERSION)' \ + --set 'manager.image.pullPolicy=Never' \ + --post-renderer "$$post_renderer" \ + --wait \ + --wait-for-jobs \ + --timeout '$(TIMEOUT)' + @$(KUBECTL) rollout restart deployment/capsule-controller-manager --namespace capsule-system + @$(KUBECTL) rollout status deployment/capsule-controller-manager --namespace capsule-system --timeout='$(TIMEOUT)' + @echo "Deployed local Capsule image $(DEV_IMAGE):$(DEV_VERSION)" + +dev: up ## Bring up the playground and deploy Capsule from the current checkout. + @$(MAKE) --no-print-directory dev-capsule + +capsule-stable: check ## Restore the Flux-managed Capsule release. + @if ! $(KUBECTL) get helmrelease capsule --namespace flux-system >/dev/null 2>&1; then \ + echo "Recreating the Flux-managed Capsule HelmRelease..."; \ + $(MAKE) --no-print-directory apply; \ + fi + @flux resume helmrelease capsule --context '$(CONTEXT)' --namespace flux-system --wait --timeout '$(TIMEOUT)' + @flux reconcile helmrelease capsule --context '$(CONTEXT)' --namespace flux-system --with-source --timeout '$(TIMEOUT)' + @$(KUBECTL) rollout status deployment/capsule-controller-manager --namespace capsule-system --timeout='$(TIMEOUT)' + @echo "Restored the Flux-managed Capsule release" + +up: cluster ## Install Flux and bring up the local playground. + @$(MAKE) flux + @$(MAKE) apply + @$(MAKE) wait-oidc + @$(MAKE) configure-oidc + @$(MAKE) wait + @$(MAKE) apply-platform + @$(MAKE) apply-user + @echo + @echo "Headlamp: $(HEADLAMP_URL)" + @echo "Dex: $(DEX_URL)" + @echo "Proxy: $(PROXY_URL)" + +down: ## Delete the local kind cluster. + @kind delete cluster --name $(CLUSTER_NAME) diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 00000000..44cd5ba8 --- /dev/null +++ b/playground/README.md @@ -0,0 +1,179 @@ +# Capsule local playground + +This playground creates a two-node kind cluster and installs Capsule, Capsule +Proxy, Dex, Headlamp, and ingress-nginx through Flux. HTTP services share ports +80 and 443 through host-based ingress routing. Capsule Proxy is the only +component exposed on its own port (`9001`). + +The kind API server is configured as an OIDC relying party for Dex after Dex and +ingress become ready. It uses Kubernetes' reloadable authentication config, so +there is no API-server/Dex bootstrap cycle. Dex users are mapped from the `name` +claim to the sample Capsule owners (`alice`, `bob`, and `gatsby`). These local +usernames are registered explicitly as Capsule users, and authorization comes +from tenant ownership and the RBAC managed by Capsule. The pinned Dex version +does not emit the `groups` entries from `staticPasswords`, so the `admin` login +is deliberately not granted Kubernetes `cluster-admin` by default. + +## Prerequisites + +- Docker +- kind +- kubectl +- Flux CLI +- GNU `envsubst` (usually provided by `gettext`) +- OpenSSL +- curl and jq +- make + +The development targets (`make dev` and `make dev-capsule`) additionally need +Go and Helm. The pinned `ko` binary is installed automatically by the root +Makefile when it is not already available. + +The default hostnames need to resolve to loopback. Register them in +`/etc/hosts` with: + +```console +make hosts +``` + +This target is idempotent and prompts for `sudo` only when a hostname is +missing. The equivalent manual entry is: + +```text +127.0.0.1 dex.capsule.local headlamp.capsule.local proxy.capsule.local gangplank.capsule.local +``` + +On macOS, verify resolution through the system resolver with: + +```console +dscacheutil -q host -a name headlamp.capsule.local +``` + +`host` and `dig` query DNS servers directly, so they can report `NXDOMAIN` for +entries that work correctly through `/etc/hosts`. + +Inside kind, CoreDNS maps the configured Dex hostname to the ingress controller. +The worker address is also registered as a host alias on the kube-apiserver +static Pod, allowing it to discover Dex's signing keys at the same issuer URL. + +Then start the environment from this directory: + +```console +make up +``` + +To start the same environment with Capsule built from the current checkout, use: + +```console +make dev +``` + +The default endpoints are: + +- Headlamp: +- Dex: +- Capsule Proxy: + +Headlamp uses Dex for login. These local-only accounts use the username as the +password; enter the email address in Dex's login form: + +| Email | Username | Password | Access | +| --- | --- | --- | --- | +| `alice@projectcapsule.dev` | `alice` | `alice` | Owns `solar`; also sees the `green` namespaces shared by the sample proxy rule | +| `bob@projectcapsule.dev` | `bob` | `bob` | Owns `green` | +| `gatsby@projectcapsule.dev` | `gatsby` | `gatsby` | Owns `wind`; also sees the `solar` namespaces shared by the sample proxy rule | +| `renewable@projectcapsule.dev` | `renewable` | `renewable` | Authenticates as the local `renewable` user | +| `admin@example.com` | `admin` | `admin` | Authenticates successfully but has no elevated Kubernetes RBAC by default | + +The setup generates one persistent local CA under `installation/.generated/` +and reuses it for Dex, Headlamp, Capsule, Capsule Proxy, and kube-apiserver OIDC trust. +Changing a hostname reissues only the affected service certificate; it does not +rotate the CA. A workstation browser will report the certificate as untrusted +unless that CA is imported locally. + +The reloadable API-server configuration is generated at +`installation/.generated/authentication-config.yaml`. It starts with an empty +`jwt` list while the cluster bootstraps, then `make up` replaces it with the Dex +issuer, shared CA, audience, and claim mappings after Dex is reachable. + +## Configuration + +Copy `.env.example` to `.env` and change the hostnames or public URLs as needed. +The Makefile supplies the shown defaults even when `.env` does not exist. +`CLUSTER_NAME` and `PROXY_PORT` can also be overridden when another local kind +cluster already uses the defaults. + +To inspect the exact resources after Kustomize and environment substitution: + +```console +make render +``` + +Substitution is restricted to the declared host and URL variables. This keeps +other dollar-prefixed content, including Dex password hashes, unchanged. + +Useful lifecycle commands: + +```console +make status # inspect the cluster and Flux releases +make apply # reapply playground configuration after editing it +make dev-capsule # rebuild and redeploy only Capsule from the current checkout +make capsule-stable # return Capsule to the pinned Flux-managed release +make down # delete the kind cluster +``` + +## Developing Capsule in the playground + +`make dev` first performs the normal playground setup, including the platform +and user examples, and then builds the Capsule controller with `ko`. The image +is loaded directly into the kind nodes and the release is upgraded from the +local `../charts/capsule` chart. Local chart templates and CRDs are therefore +deployed together with the controller code. + +While a development build is installed, reconciliation of the `capsule` +HelmRelease is suspended so Flux cannot replace it with the pinned chart. The +other playground releases, including Capsule Proxy, remain managed by Flux. +The local Helm upgrade reuses the playground values and its persistent CA. + +After changing the source, rebuild and roll out Capsule without recreating the +cluster: + +```console +make dev-capsule +``` + +From the repository root, the existing `make dev-setup-capsule` target delegates +to this playground target. + +The repository root's legacy `make dev-setup` target uses a different handoff: +it runs the Capsule controller on the workstation and points admission webhooks +at `LAPTOP_HOST_IP`. Before installing that development release, it waits for +`flux-system/capsule` to become ready and deletes only that HelmRelease. Waiting +for deletion lets the Flux Helm controller finish uninstalling the pinned +release before local Helm takes ownership. All other playground HelmReleases +remain managed by Flux. + +```console +LAPTOP_HOST_IP=192.168.1.10 make dev-setup +``` + +Each invocation uses a timestamped development tag. Set one explicitly when a +predictable image name is useful: + +```console +make dev-capsule DEV_VERSION=my-branch +``` + +The default local image is `ko.local/capsule`. Its registry and repository can +be changed with `DEV_IMAGE_REGISTRY` and `DEV_IMAGE_REPOSITORY`. + +To leave development mode and restore the release declared in +`installation/capsule/release.flux.yaml`, run: + +```console +make capsule-stable +``` + +If root `make dev-setup` deleted the Capsule HelmRelease, `capsule-stable` +recreates it from the playground installation manifests before asking Flux to +reconcile it. diff --git a/playground/helm-plugins/capsule-playground/plugin.yaml b/playground/helm-plugins/capsule-playground/plugin.yaml new file mode 100644 index 00000000..51000b94 --- /dev/null +++ b/playground/helm-plugins/capsule-playground/plugin.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +type: postrenderer/v1 +name: capsule-playground +version: 0.1.0 +runtime: subprocess +runtimeConfig: + platformCommand: + - command: ${HELM_PLUGIN_DIR}/../../scripts/capsule-post-renderer.sh diff --git a/playground/installation/capsule/kustomization.yaml b/playground/installation/capsule/kustomization.yaml new file mode 100644 index 00000000..a75eab3e --- /dev/null +++ b/playground/installation/capsule/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - release.flux.yaml + - rbac.yaml diff --git a/playground/installation/capsule/rbac.yaml b/playground/installation/capsule/rbac.yaml new file mode 100644 index 00000000..58fbc52f --- /dev/null +++ b/playground/installation/capsule/rbac.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: allow-tenant-resources + labels: + rbac.authorization.k8s.io/aggregate-to-admin: "true" +rules: +- apiGroups: ["capsule.clastix.io"] + resources: ["tenantresources"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: playground-oidc-admins +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - apiGroup: rbac.authorization.k8s.io + kind: Group + name: oidc:capsule-admins diff --git a/playground/installation/capsule/release.flux.yaml b/playground/installation/capsule/release.flux.yaml new file mode 100644 index 00000000..85deccb4 --- /dev/null +++ b/playground/installation/capsule/release.flux.yaml @@ -0,0 +1,132 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: capsule + namespace: flux-system +spec: + serviceAccountName: kustomize-controller + interval: 30s + timeout: 10m + targetNamespace: capsule-system + releaseName: "capsule" + chart: + spec: + chart: capsule + version: "0.13.11" + sourceRef: + kind: HelmRepository + name: capsule + interval: 24h + install: + createNamespace: true + remediation: + retries: -1 + upgrade: + remediation: + remediateLastFailure: true + retries: -1 + driftDetection: + mode: enabled + ignore: + - paths: ["/spec/replicas"] + postRenderers: + - kustomize: + patches: + - target: + version: v1 + kind: Secret + name: capsule-tls + patch: | + - op: add + path: /data + value: + ca.crt: "${PLAYGROUND_CA_BUNDLE}" + tls.crt: "${CAPSULE_TLS_CERT_BUNDLE}" + tls.key: "${CAPSULE_TLS_KEY_BUNDLE}" + values: + replicaCount: 2 + certManager: + generateCertificates: false + tls: + enableController: false + create: false + name: capsule-tls + webhooks: + service: + caBundle: "${PLAYGROUND_CA_BUNDLE}" + conversions: + service: + caBundle: "${PLAYGROUND_CA_BUNDLE}" + manager: + options: + allowServiceAccountPromotion: true + forceTenantPrefix: true + administrators: + - kind: User + name: "admin" + users: + - kind: Group + name: "capsule-users" +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: capsule-proxy + namespace: flux-system +spec: + serviceAccountName: kustomize-controller + interval: 30s + timeout: 10m + targetNamespace: capsule-system + releaseName: "capsule-proxy" + chart: + spec: + chart: capsule-proxy + version: "0.13.9" + sourceRef: + kind: HelmRepository + name: capsule + interval: 24h + install: + createNamespace: true + remediation: + retries: -1 + upgrade: + remediation: + remediateLastFailure: true + retries: -1 + driftDetection: + mode: enabled + ignore: + - paths: ["/spec/replicas"] + values: + certManager: + generateCertificates: false + options: + additionalSANs: + - "localhost" + - "${PROXY_HOST}" + generateCertificates: false + leaderElection: true + oidcUsernameClaim: "${OIDC_USERNAME_CLAIM}" + roleBindingReflector: true + extraArgs: + - "--feature-gates=ProxyClusterScoped=true" + service: + type: ClusterIP + kind: DaemonSet + daemonset: + hostNetwork: true + serviceMonitor: + enabled: false +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: capsule + namespace: flux-system +spec: + type: "oci" + interval: 12h0m0s + url: oci://ghcr.io/projectcapsule/charts diff --git a/playground/installation/dex/ingress.yaml b/playground/installation/dex/ingress.yaml new file mode 100644 index 00000000..4c8e670e --- /dev/null +++ b/playground/installation/dex/ingress.yaml @@ -0,0 +1,22 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dex + namespace: dex +spec: + ingressClassName: nginx + tls: + - hosts: + - "${DEX_HOST}" + secretName: dex-tls + rules: + - host: "${DEX_HOST}" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: dex-dex + port: + number: 80 diff --git a/playground/installation/dex/kustomization.yaml b/playground/installation/dex/kustomization.yaml new file mode 100644 index 00000000..b52c77a5 --- /dev/null +++ b/playground/installation/dex/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - release.flux.yaml + - ingress.yaml diff --git a/playground/installation/dex/release.flux.yaml b/playground/installation/dex/release.flux.yaml new file mode 100644 index 00000000..442fa50c --- /dev/null +++ b/playground/installation/dex/release.flux.yaml @@ -0,0 +1,107 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: dex + namespace: flux-system +spec: + interval: 1h + url: https://charts.dexidp.io +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: dex + namespace: flux-system +spec: + interval: 10m + serviceAccountName: kustomize-controller + targetNamespace: dex + dependsOn: + - name: ingress-nginx + chart: + spec: + chart: dex + version: "0.24.1" + sourceRef: + kind: HelmRepository + name: dex + interval: 1h + install: + createNamespace: true + remediation: + retries: -1 + values: + replicaCount: 1 + service: + type: ClusterIP + ports: + http: + port: 80 + targetPort: 5556 + config: + issuer: "${DEX_URL}" + storage: + type: kubernetes + config: + inCluster: true + + web: + http: 0.0.0.0:5556 + + oauth2: + skipApprovalScreen: true + passwordConnector: local + + enablePasswordDB: true + staticClients: + - id: "${OIDC_CLIENT_ID}" + name: Kubernetes + secret: "${OIDC_CLIENT_SECRET}" + redirectURIs: + - "${PROXY_URL}/*" + - "${HEADLAMP_URL}/oidc-callback" + - id: gangplank + name: Gangplank + secret: gangplank-secret + redirectURIs: + - "${GANGPLANK_URL}/callback" + staticPasswords: + - email: bob@projectcapsule.dev + username: bob + userID: 11111111-1111-1111-1111-111111111111 + hash: '$2y$10$lgNluw1j3KhMp1r7epBCFuQiPcdKnybFqDEIFMnoXXsDBD.QLeR7.' + groups: + - capsule-users + - green-users + + - email: alice@projectcapsule.dev + username: alice + userID: 22222222-2222-2222-2222-222222222222 + hash: '$2y$10$7Q7yIi/QgY6t9n2bSqZVVewVmoxoNUfj3cxwhtXUhe/ArTeZ3dJM2' + groups: + - capsule-users + - solar-users + + - email: gatsby@projectcapsule.dev + username: gatsby + userID: 33333333-3333-3333-3333-333333333333 + hash: '$2y$10$njJhZ9HalGwwkF/uGKh3jOguYq2TRbmV2ECu6Mnfo32JagiESNh4O' + groups: + - capsule-users + - wind-users + + - email: admin@projectcapsule.dev + username: admin + userID: 44444444-4444-4444-4444-444444444444 + hash: '$2y$10$lGHaUNVKMRRPIaXGDk3.JOPBws6CzmX6080GLEDMetx.LzgPsq6DK' + groups: + - capsule-admins + + - email: renewable@projectcapsule.dev + username: renewable + userID: 55555555-5555-5555-5555-555555555555 + hash: '$2y$10$MDRVJ8JruEb4nuaRNomue.8OibUXzVpGqnc6cMS29vk6uQkvuCIP2' + groups: + - capsule-users + - renewable-users diff --git a/playground/installation/dns.yaml b/playground/installation/dns.yaml new file mode 100644 index 00000000..9fcee95f --- /dev/null +++ b/playground/installation/dns.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: coredns + namespace: kube-system +data: + Corefile: | + .:53 { + errors + health { + lameduck 5s + } + ready + # Let in-cluster OIDC clients reach the same issuer URL as the browser. + rewrite name exact ${DEX_HOST} ingress-nginx-controller.ingress-nginx.svc.cluster.local + kubernetes cluster.local in-addr.arpa ip6.arpa { + pods insecure + fallthrough in-addr.arpa ip6.arpa + ttl 30 + } + prometheus :9153 + forward . /etc/resolv.conf { + max_concurrent 1000 + } + cache 30 + loop + reload + loadbalance + } diff --git a/playground/installation/headlamp/ingress.yaml b/playground/installation/headlamp/ingress.yaml new file mode 100644 index 00000000..00ff11aa --- /dev/null +++ b/playground/installation/headlamp/ingress.yaml @@ -0,0 +1,22 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: headlamp + namespace: capsule-system +spec: + ingressClassName: nginx + tls: + - hosts: + - "${HEADLAMP_HOST}" + secretName: headlamp-tls + rules: + - host: "${HEADLAMP_HOST}" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: headlamp + port: + number: 80 diff --git a/playground/installation/headlamp/kustomization.yaml b/playground/installation/headlamp/kustomization.yaml new file mode 100644 index 00000000..b52c77a5 --- /dev/null +++ b/playground/installation/headlamp/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - release.flux.yaml + - ingress.yaml diff --git a/playground/installation/headlamp/release.flux.yaml b/playground/installation/headlamp/release.flux.yaml new file mode 100644 index 00000000..e86c72b5 --- /dev/null +++ b/playground/installation/headlamp/release.flux.yaml @@ -0,0 +1,155 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: headlamp + namespace: flux-system +spec: + interval: 10m + releaseName: "headlamp" + targetNamespace: capsule-system + serviceAccountName: kustomize-controller + dependsOn: + - name: capsule + - name: dex + chart: + spec: + chart: headlamp + version: "0.42.0" + sourceRef: + kind: HelmRepository + name: headlamp-charts + interval: 24h + install: + createNamespace: true + remediation: + retries: -1 + upgrade: + remediation: + remediateLastFailure: true + driftDetection: + mode: enabled + values: + replicaCount: 1 + automountServiceAccountToken: true + podAnnotations: + playground.capsule.clastix.io/ca-sha256: "${PLAYGROUND_CA_SHA256}" + pluginsManager: + enabled: true + configContent: | + plugins: + - name: capsule + source: https://artifacthub.io/packages/headlamp/headlamp-capsule/capsule + version: 0.2.0 + installOptions: + parallel: true + maxConcurrent: 2 + service: + type: ClusterIP + port: 80 + targetPort: 4466 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + volumeMounts: + - mountPath: /var/run/secrets/kubernetes.io/serviceaccount + name: token-ca + - name: ca-store + mountPath: /etc/ssl/ + env: + - name: KUBERNETES_SERVICE_HOST + value: capsule-proxy.capsule-system.svc + - name: KUBERNETES_SERVICE_PORT + value: "9001" + volumes: + - name: ca-store + emptyDir: {} + - name: capsule-proxy + secret: + secretName: capsule-proxy + items: + - key: ca + path: ca.crt + - name: dex-ca + secret: + secretName: dex-ca + - name: token-ca + projected: + sources: + - serviceAccountToken: + path: token + - secret: + name: capsule-proxy + items: + - key: ca + path: ca.crt + - downwardAPI: null + items: + - fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + path: namespace + initContainers: + - name: add-ca + image: docker.io/alpine:3 + command: + - /bin/sh + - -c + args: + - | + set -e + cp -R /etc/ssl/* /work/ + cat /ca/ca.crt /dex-ca/ca.crt >> /work/certs/ca-certificates.crt + volumeMounts: + - name: ca-store + mountPath: /work + - name: capsule-proxy + mountPath: /ca + - name: dex-ca + mountPath: /dex-ca + securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + privileged: false + runAsUser: 65534 + runAsGroup: 65534 + podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + privileged: false + runAsUser: 100 + runAsGroup: 101 + config: + pluginsDir: /build/plugins + inCluster: true + extraArgs: + - -oidc-ca-file=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt + oidc: + clientID: "${OIDC_CLIENT_ID}" + clientSecret: "${OIDC_CLIENT_SECRET}" + issuerURL: "${DEX_URL}" + callbackURL: "${HEADLAMP_URL}/oidc-callback" + scopes: "email,profile,groups" +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: headlamp-charts + namespace: flux-system +spec: + interval: 24h0m0s + url: https://kubernetes-sigs.github.io/headlamp/ diff --git a/hack/distro/capsule/kustomization.yaml b/playground/installation/ingress-nginx/kustomization.yaml similarity index 100% rename from hack/distro/capsule/kustomization.yaml rename to playground/installation/ingress-nginx/kustomization.yaml diff --git a/hack/distro/capsule/release.flux.yaml b/playground/installation/ingress-nginx/release.flux.yaml similarity index 50% rename from hack/distro/capsule/release.flux.yaml rename to playground/installation/ingress-nginx/release.flux.yaml index 89a93cd5..5e386b9e 100644 --- a/hack/distro/capsule/release.flux.yaml +++ b/playground/installation/ingress-nginx/release.flux.yaml @@ -1,42 +1,44 @@ --- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: ingress-nginx + namespace: flux-system +spec: + interval: 24h + url: https://kubernetes.github.io/ingress-nginx +--- apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: - name: capsule + name: ingress-nginx namespace: flux-system spec: + interval: 10m + releaseName: ingress-nginx + targetNamespace: ingress-nginx serviceAccountName: kustomize-controller - interval: 30s - timeout: 10m - targetNamespace: capsule-system - releaseName: "capsule" chart: spec: - chart: capsule - version: "0.12.4" + chart: ingress-nginx + version: "4.15.1" sourceRef: kind: HelmRepository - name: capsule + name: ingress-nginx interval: 24h install: - createNamespace: true remediation: retries: -1 upgrade: remediation: remediateLastFailure: true retries: -1 - driftDetection: - mode: enabled - ignore: - - paths: ["/spec/replicas"] ---- -apiVersion: source.toolkit.fluxcd.io/v1 -kind: HelmRepository -metadata: - name: capsule - namespace: flux-system -spec: - type: "oci" - interval: 12h0m0s - url: oci://ghcr.io/projectcapsule/charts + values: + controller: + hostPort: + enabled: true + nodeSelector: + ingress-ready: "true" + kubernetes.io/os: linux + service: + type: ClusterIP diff --git a/playground/installation/kustomization.yaml b/playground/installation/kustomization.yaml new file mode 100644 index 00000000..24839928 --- /dev/null +++ b/playground/installation/kustomization.yaml @@ -0,0 +1,47 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespaces.yaml + - dns.yaml + - ingress-nginx/ + - capsule/ + - dex/ + - headlamp/ +generatorOptions: + disableNameSuffixHash: true +secretGenerator: + - name: dex-tls + namespace: dex + type: kubernetes.io/tls + files: + - tls.crt=.generated/dex.crt + - tls.key=.generated/dex.key + - name: headlamp-tls + namespace: capsule-system + type: kubernetes.io/tls + files: + - tls.crt=.generated/dex.crt + - tls.key=.generated/dex.key + - name: dex-ca + namespace: capsule-system + files: + - ca.crt=.generated/ca.crt + - name: capsule-proxy + namespace: capsule-system + files: + - ca=.generated/ca.crt + - tls.crt=.generated/proxy.crt + - tls.key=.generated/proxy.key + - name: capsule-tls + namespace: capsule-system + type: kubernetes.io/tls + files: + - ca.crt=.generated/ca.crt + - tls.crt=.generated/capsule.crt + - tls.key=.generated/capsule.key + options: + labels: + app.kubernetes.io/managed-by: Helm + annotations: + meta.helm.sh/release-name: capsule + meta.helm.sh/release-namespace: capsule-system diff --git a/playground/installation/namespaces.yaml b/playground/installation/namespaces.yaml new file mode 100644 index 00000000..f266ae95 --- /dev/null +++ b/playground/installation/namespaces.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: capsule-system +--- +apiVersion: v1 +kind: Namespace +metadata: + name: dex +--- +apiVersion: v1 +kind: Namespace +metadata: + name: ingress-nginx diff --git a/playground/kind.config.yaml b/playground/kind.config.yaml new file mode 100644 index 00000000..21621c1a --- /dev/null +++ b/playground/kind.config.yaml @@ -0,0 +1,38 @@ +--- +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: ${CLUSTER_NAME} +featureGates: + ImageVolume: true + # Alpha and disabled by default on the Kubernetes 1.32 and 1.33 e2e jobs. + PodLevelResources: true +kubeadmConfigPatches: + - | + kind: ClusterConfiguration + apiServer: + extraArgs: + authentication-config: /etc/kubernetes/pki/playground-authentication-config.yaml +nodes: + - role: control-plane + extraMounts: + - hostPath: "${DEX_CA_FILE}" + containerPath: /etc/kubernetes/pki/playground-oidc-ca.crt + readOnly: true + - hostPath: "${AUTHENTICATION_CONFIG_FILE}" + containerPath: /etc/kubernetes/pki/playground-authentication-config.yaml + readOnly: true + - role: worker + labels: + ingress-ready: "true" + extraPortMappings: + # HTTP(S) traffic is routed by the ingress controller. + - hostPort: 80 + containerPort: 80 + protocol: TCP + - hostPort: 443 + containerPort: 443 + protocol: TCP + # The Kubernetes API proxy is intentionally exposed directly. + - hostPort: ${PROXY_PORT} + containerPort: 9001 + protocol: TCP diff --git a/playground/platform/classes/kustomization.yaml b/playground/platform/classes/kustomization.yaml new file mode 100644 index 00000000..083b3c50 --- /dev/null +++ b/playground/platform/classes/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - priority.yaml diff --git a/playground/platform/classes/priority.yaml b/playground/platform/classes/priority.yaml new file mode 100644 index 00000000..5ba7d649 --- /dev/null +++ b/playground/platform/classes/priority.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: scheduling.k8s.io/v1 +kind: PriorityClass +metadata: + name: best-effort + labels: + consumer: "customer" +value: 1000000 +globalDefault: true +description: "Best-Effort Priority" +preemptionPolicy: PreemptLowerPriority +--- +apiVersion: scheduling.k8s.io/v1 +kind: PriorityClass +metadata: + name: customer + labels: + consumer: "customer" +value: 10000000 +globalDefault: false +description: "Customer Workload Priority" +preemptionPolicy: PreemptLowerPriority +--- +apiVersion: scheduling.k8s.io/v1 +kind: PriorityClass +metadata: + name: operations-critical + labels: + name: operations-critical + consumer: "operations" +value: 10000000 +preemptionPolicy: PreemptLowerPriority +globalDefault: false +description: "This priority class is used for cluster operations, which are not system critical" diff --git a/playground/platform/customquotas/kustomization.yaml b/playground/platform/customquotas/kustomization.yaml new file mode 100644 index 00000000..0b5fa58e --- /dev/null +++ b/playground/platform/customquotas/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - storage-aggregate.yaml + - service-aggregate.yaml diff --git a/playground/platform/customquotas/service-aggregate.yaml b/playground/platform/customquotas/service-aggregate.yaml new file mode 100644 index 00000000..a8bd63f1 --- /dev/null +++ b/playground/platform/customquotas/service-aggregate.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalCustomQuota +metadata: + name: service-aggregate +spec: + limit: 5 + namespaceSelectors: + - matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists + sources: + - apiVersion: v1 + kind: Service + op: count + selectors: + - fieldSelectors: + - .spec.type=="ClusterIP" diff --git a/playground/platform/customquotas/storage-aggregate.yaml b/playground/platform/customquotas/storage-aggregate.yaml new file mode 100644 index 00000000..4c415763 --- /dev/null +++ b/playground/platform/customquotas/storage-aggregate.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalCustomQuota +metadata: + name: storage-aggregate +spec: + limit: 5Gi + namespaceSelectors: + - matchLabels: + capsule.clastix.io/tenant: wind + sources: + - apiVersion: v1 + kind: Pod + op: add + path: ".spec.volumes[*].ephemeral.volumeClaimTemplate.spec.resources.requests.storage" + - apiVersion: v1 + kind: PersistentVolumeClaim + op: add + path: ".spec.resources.requests.storage" + selectors: + - fieldSelectors: + - '.spec.accessModes[?(@=="ReadWriteOnce")]' diff --git a/hack/distro/capsule/example-setup/global-resource-quotas.yaml b/playground/platform/globalresourcequotas/green-shared-compute.yaml similarity index 87% rename from hack/distro/capsule/example-setup/global-resource-quotas.yaml rename to playground/platform/globalresourcequotas/green-shared-compute.yaml index 3b2c69a2..b57666b3 100644 --- a/hack/distro/capsule/example-setup/global-resource-quotas.yaml +++ b/playground/platform/globalresourcequotas/green-shared-compute.yaml @@ -3,6 +3,8 @@ apiVersion: capsule.clastix.io/v1beta2 kind: GlobalResourceQuota metadata: name: green-shared-compute + labels: + projectcapsule.dev/tenant: green spec: namespaceSelectors: - matchLabels: diff --git a/playground/platform/globalresourcequotas/kustomization.yaml b/playground/platform/globalresourcequotas/kustomization.yaml new file mode 100644 index 00000000..0a80d655 --- /dev/null +++ b/playground/platform/globalresourcequotas/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - green-shared-compute.yaml diff --git a/playground/platform/globaltenantresources/gtr-limitranges.yaml b/playground/platform/globaltenantresources/gtr-limitranges.yaml new file mode 100644 index 00000000..453ca3bf --- /dev/null +++ b/playground/platform/globaltenantresources/gtr-limitranges.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: tenant-limitranges +spec: + resyncPeriod: 60s + serviceAccount: + name: gtr-reconciler + namespace: capsule-system + resources: + - namespaceSelector: + matchLabels: + company.com/environment: test + rawItems: + - apiVersion: v1 + kind: LimitRange + metadata: + name: service-level-best-effort + spec: + limits: + - max: + cpu: "0" + memory: "0" + min: + cpu: "0" + memory: "0" + type: Container diff --git a/playground/platform/globaltenantresources/gtr-netpols.yaml b/playground/platform/globaltenantresources/gtr-netpols.yaml new file mode 100644 index 00000000..f15d1ecb --- /dev/null +++ b/playground/platform/globaltenantresources/gtr-netpols.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: zero-trust-netpol +spec: + resyncPeriod: 60s + serviceAccount: + name: gtr-reconciler + namespace: capsule-system + resources: + - rawItems: + - apiVersion: networking.k8s.io/v1 + kind: NetworkPolicy + metadata: + name: zero-trust + spec: + podSelector: {} # This applies to all pods in the namespace + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: {} # Only allow traffic from pods in the same namespace + egress: + - to: + - podSelector: {} # Only allow traffic to pods in the same namespace diff --git a/playground/platform/globaltenantresources/gtr-proxy.yaml b/playground/platform/globaltenantresources/gtr-proxy.yaml new file mode 100644 index 00000000..780f8e47 --- /dev/null +++ b/playground/platform/globaltenantresources/gtr-proxy.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: capsule-proxy-settings +spec: + scope: Tenant + resyncPeriod: 30s + serviceAccount: + name: gtr-reconciler + namespace: capsule-system + resources: + - generators: + - missingKey: zero + template: | + --- + apiVersion: capsule.clastix.io/v1beta1 + kind: GlobalProxySettings + metadata: + name: {{ $.tenant.metadata.name }}-proxy-settings + spec: + rules: + - subjects: + {{- range $.tenant.status.owners }} + - kind: {{ .kind }} + name: {{ .name }} + {{- end }} + clusterResources: + - apiGroups: + - "" + resources: + - "namespaces" + operations: + - List + selector: + matchLabels: + capsule.clastix.io/tenant: {{ $.tenant.metadata.name }} + - apiGroups: + - "capsule.clastix.io" + resources: + - "resourcepools" + - "globalcustomquotas" + - "globalresourcequotas" + - "tenantowners" + operations: + - List + selector: + matchLabels: + projectcapsule.dev/tenant: {{ $.tenant.metadata.name }} diff --git a/hack/distro/capsule/example-setup/resource-legacy.yaml b/playground/platform/globaltenantresources/gtr-sa-owner.yaml similarity index 51% rename from hack/distro/capsule/example-setup/resource-legacy.yaml rename to playground/platform/globaltenantresources/gtr-sa-owner.yaml index 2f0e3a5c..37372067 100644 --- a/hack/distro/capsule/example-setup/resource-legacy.yaml +++ b/playground/platform/globaltenantresources/gtr-sa-owner.yaml @@ -2,29 +2,12 @@ apiVersion: capsule.clastix.io/v1beta2 kind: GlobalTenantResource metadata: - name: custom-cm -spec: - resyncPeriod: 60s - resources: - - additionalMetadata: - labels: - "replicated-by": "capsule" - rawItems: - - apiVersion: v1 - kind: ConfigMap - metadata: - name: game-demo - data: - # property-like keys; each key maps to a simple value - player_initial_lives: "3" - ui_properties_file_name: "user-interface.properties" ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: GlobalTenantResource -metadata: - name: gitops-owners + name: gitops-sa-owners spec: resyncPeriod: 60s + serviceAccount: + name: gtr-reconciler + namespace: capsule-system resources: - additionalMetadata: labels: diff --git a/playground/platform/globaltenantresources/kustomization.yaml b/playground/platform/globaltenantresources/kustomization.yaml new file mode 100644 index 00000000..9f6ee498 --- /dev/null +++ b/playground/platform/globaltenantresources/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - rbac.yaml + - gtr-netpols.yaml + - gtr-sa-owner.yaml + - gtr-proxy.yaml + - gtr-limitranges.yaml diff --git a/playground/platform/globaltenantresources/rbac.yaml b/playground/platform/globaltenantresources/rbac.yaml new file mode 100644 index 00000000..1974e8e1 --- /dev/null +++ b/playground/platform/globaltenantresources/rbac.yaml @@ -0,0 +1,45 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: gtr-reconciler + namespace: capsule-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: capsule-replicator + labels: + projectcapsule.dev/aggregate-to-controller: "true" +rules: +- apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["get", "create", "patch", "watch", "list", "delete"] +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "patch", "watch", "list", "delete"] +- apiGroups: ["capsule.clastix.io"] + resources: ["globalproxysettings", "tenantowners"] + verbs: ["get", "create", "patch", "watch", "list", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: capsule-replicator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: capsule-replicator +subjects: + - kind: ServiceAccount + name: gtr-reconciler + namespace: capsule-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: custom:proxy-viewer +rules: +- apiGroups: [""] + resources: ["*"] + verbs: ["list"] diff --git a/playground/platform/kustomization.yaml b/playground/platform/kustomization.yaml new file mode 100644 index 00000000..c2b3d02e --- /dev/null +++ b/playground/platform/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - rbac.yaml + - classes/ + - customquotas/ + - globalresourcequotas/ + - globaltenantresources/ + - owners/ + - resourcepools/ + - tenants/ diff --git a/playground/platform/owners/kustomization.yaml b/playground/platform/owners/kustomization.yaml new file mode 100644 index 00000000..9542b648 --- /dev/null +++ b/playground/platform/owners/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - owner-alice.yaml + - owner-bob.yaml + - owner-gatsby.yaml + - owner-devops.yaml + - owner-platform-admin.yaml + - owner-renewable.yaml diff --git a/playground/platform/owners/owner-alice.yaml b/playground/platform/owners/owner-alice.yaml new file mode 100644 index 00000000..0164209f --- /dev/null +++ b/playground/platform/owners/owner-alice.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: TenantOwner +metadata: + labels: + projectcapsule.dev/tenant: solar + name: alice +spec: + kind: User + name: "alice" diff --git a/playground/platform/owners/owner-bob.yaml b/playground/platform/owners/owner-bob.yaml new file mode 100644 index 00000000..b711f822 --- /dev/null +++ b/playground/platform/owners/owner-bob.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: TenantOwner +metadata: + labels: + projectcapsule.dev/tenant: green + name: bob +spec: + kind: User + name: "bob" diff --git a/hack/distro/capsule/example-setup/owners.yaml b/playground/platform/owners/owner-devops.yaml similarity index 65% rename from hack/distro/capsule/example-setup/owners.yaml rename to playground/platform/owners/owner-devops.yaml index cf472fb6..ce6eff52 100644 --- a/hack/distro/capsule/example-setup/owners.yaml +++ b/playground/platform/owners/owner-devops.yaml @@ -18,13 +18,3 @@ metadata: spec: kind: Group name: "oidc:org:devops" ---- -apiVersion: capsule.clastix.io/v1beta2 -kind: TenantOwner -metadata: - labels: - team: platform - name: platform -spec: - kind: Group - name: "oidc:org:platform" diff --git a/playground/platform/owners/owner-gatsby.yaml b/playground/platform/owners/owner-gatsby.yaml new file mode 100644 index 00000000..8a10f05c --- /dev/null +++ b/playground/platform/owners/owner-gatsby.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: TenantOwner +metadata: + labels: + projectcapsule.dev/tenant: wind + name: gatsby +spec: + kind: User + name: "gatsby" diff --git a/playground/platform/owners/owner-platform-admin.yaml b/playground/platform/owners/owner-platform-admin.yaml new file mode 100644 index 00000000..0cba168f --- /dev/null +++ b/playground/platform/owners/owner-platform-admin.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: TenantOwner +metadata: + labels: + team: platform + name: platform +spec: + kind: Group + name: "oidc:org:platform" diff --git a/playground/platform/owners/owner-renewable.yaml b/playground/platform/owners/owner-renewable.yaml new file mode 100644 index 00000000..c08ecd14 --- /dev/null +++ b/playground/platform/owners/owner-renewable.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: TenantOwner +metadata: + labels: + customer: renewable + name: renewable-user +spec: + kind: User + name: "renewable" diff --git a/playground/platform/rbac.yaml b/playground/platform/rbac.yaml new file mode 100644 index 00000000..f8f59f81 --- /dev/null +++ b/playground/platform/rbac.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: oidc:admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: User + name: admin diff --git a/playground/platform/resourcepools/kustomization.yaml b/playground/platform/resourcepools/kustomization.yaml new file mode 100644 index 00000000..6833a657 --- /dev/null +++ b/playground/platform/resourcepools/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - pool-renewable.yaml + - pool-solar.yaml diff --git a/playground/platform/resourcepools/pool-renewable.yaml b/playground/platform/resourcepools/pool-renewable.yaml new file mode 100644 index 00000000..6300d7b0 --- /dev/null +++ b/playground/platform/resourcepools/pool-renewable.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: ResourcePool +metadata: + name: renewable +spec: + quota: + hard: + limits.cpu: "2" + limits.memory: 2Gi + requests.cpu: "2" + requests.memory: 2Gi + requests.storage: "5Gi" + selectors: + - matchLabels: + customer: renewable diff --git a/playground/platform/resourcepools/pool-solar.yaml b/playground/platform/resourcepools/pool-solar.yaml new file mode 100644 index 00000000..34db6252 --- /dev/null +++ b/playground/platform/resourcepools/pool-solar.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: ResourcePool +metadata: + name: solar + labels: + projectcapsule.dev/tenant: solar +spec: + quota: + hard: + limits.cpu: "2" + limits.memory: 2Gi + requests.cpu: "2" + requests.memory: 2Gi + requests.storage: "5Gi" + selectors: + - matchLabels: + capsule.clastix.io/tenant: solar diff --git a/playground/platform/tenants/green.yaml b/playground/platform/tenants/green.yaml new file mode 100644 index 00000000..043448f7 --- /dev/null +++ b/playground/platform/tenants/green.yaml @@ -0,0 +1,74 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: green + labels: + customer: renewable + annotations: + info.projectcapsule.dev/icon: https://www.pngall.com/wp-content/uploads/12/Green-Energy-Environment-PNG-Photo-thumb.webp + info.projectcapsule.dev/description: "Production tenant for the wind team" + info.projectcapsule.dev/links: '[{"title":"Grafana","url":"https://grafana.example.com/d/payments"},{"title":"Runbook","url":"https://wiki.example.com/payments-runbook"}]' +spec: + permissions: + matchOwners: + - matchLabels: + team: devops + - matchLabels: + customer: renewable + rules: + - enforce: + action: allow + metadata: + - kinds: + - Pod + annotations: + storagelocationtype: + managed: s3 + labels: + projectcapsule.dev/backup: + managed: "true" + - kinds: + - Service + - Endpoints + annotations: + customer.corp/routable: + managed: "true" + labels: + customer.corp/network-tenant: + managed: "{{$.tenant.metadata.name}}" + - apiGroups: + - "discovery.k8s.io/v1" + kinds: + - EndpointSlice + annotations: + customer.corp/routable: + managed: "true" + labels: + customer.corp/network-tenant: + managed: "{{$.tenant.metadata.name}}" + - permissions: + bindings: + - clusterRoleName: 'custom:proxy-viewer' + subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: alice + labels: + reflection.proxy.projectcapsule.dev/enabled: "true" + - enforce: + action: "allow" + services: + types: ["ClusterIP", "NodePort", "LoadBalancer", "ExternalName"] + loadBalancers: + cidrs: + - 10.0.0.2/32 + externalNames: + hostnames: + - exp: ".*\\.example\\.com" + exact: + - "internal.git.com" + nodePorts: + ports: + - from: 30000 + to: 32767 diff --git a/playground/platform/tenants/kustomization.yaml b/playground/platform/tenants/kustomization.yaml new file mode 100644 index 00000000..117b030d --- /dev/null +++ b/playground/platform/tenants/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - green.yaml + - solar.yaml + - wind.yaml diff --git a/playground/platform/tenants/solar.yaml b/playground/platform/tenants/solar.yaml new file mode 100644 index 00000000..5608dae4 --- /dev/null +++ b/playground/platform/tenants/solar.yaml @@ -0,0 +1,73 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: solar + labels: + customer: renewable + annotations: + info.projectcapsule.dev/icon: https://cdn-icons-png.flaticon.com/512/3463/3463440.png + info.projectcapsule.dev/description: "Production tenant for the solar team" + info.projectcapsule.dev/links: '[{"title":"Grafana","url":"https://grafana.example.com/d/payments","icon":"mdi:grafana"},{"title":"Runbook","url":"https://wiki.example.com/payments-runbook", "icon": "icon": "fa-solid fa-chart-line"}]' +spec: + permissions: + matchOwners: + - matchLabels: + team: platform + - matchLabels: + customer: renewable + rules: + - namespaceSelector: + matchExpressions: + - key: env + operator: In + values: + - "test" + permissions: + promotions: + - clusterRoles: + - "secret-replicator" + - permissions: + bindings: + - clusterRoleName: 'custom:proxy-viewer' + subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: gatsby + labels: + reflection.proxy.projectcapsule.dev/enabled: "true" + enforce: + action: deny + metadata: + - apiGroups: + - "v1" + kinds: + - "Namespace" + labels: + pod-security.kubernetes.io/enforce: + managed: "baseline" + - audience: + - kind: "Custom" + name: "CapsuleUser" + enforce: + action: deny + metadata: + - apiGroups: + - "v1" + kinds: + - "Namespace" + labels: + "openshift.io/*": + required: false + values: + - exp: "^.*$" + - namespaceSelector: + matchExpressions: + - key: env + operator: In + values: + - "test" + permissions: + promotions: + - clusterRoles: + - "secret-replicator" diff --git a/playground/platform/tenants/wind.yaml b/playground/platform/tenants/wind.yaml new file mode 100644 index 00000000..06793049 --- /dev/null +++ b/playground/platform/tenants/wind.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: wind + labels: + customer: renewable + annotations: + info.projectcapsule.dev/icon: https://www.hiclipart.com/search?clipart=wind+Icon + info.projectcapsule.dev/description: "Production tenant for the wind team" + info.projectcapsule.dev/links: '[{"title":"Grafana","url":"https://grafana.example.com/d/payments"},{"title":"Runbook","url":"https://wiki.example.com/payments-runbook"}]' +spec: + permissions: + matchOwners: + - matchLabels: + customer: renewable + - matchLabels: + team: devops + rules: + - namespaceSelector: + matchLabels: + company.example/tier: application + quota: + - name: billable-compute + hard: + requests.cpu: "8" + requests.memory: 16Gi + limits.cpu: "8" + limits.memory: 16Gi + - permissions: + bindings: + - clusterRoleName: 'view' + subjects: + - apiGroup: rbac.authorization.k8s.io + kind: Group + name: wind-users diff --git a/playground/scripts/capsule-post-renderer.sh b/playground/scripts/capsule-post-renderer.sh new file mode 100755 index 00000000..850c1836 --- /dev/null +++ b/playground/scripts/capsule-post-renderer.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +for required_variable in PLAYGROUND_CA_BUNDLE CAPSULE_TLS_CERT_BUNDLE CAPSULE_TLS_KEY_BUNDLE; do + if [[ -z "${!required_variable:-}" ]]; then + echo "Missing required environment variable: ${required_variable}" >&2 + exit 1 + fi +done + +render_dir="$(mktemp -d)" +trap 'rm -rf "${render_dir}"' EXIT + +tee "${render_dir}/rendered.yaml" >/dev/null + +{ + printf '%s\n' \ + 'apiVersion: kustomize.config.k8s.io/v1beta1' \ + 'kind: Kustomization' \ + 'resources:' \ + ' - rendered.yaml' \ + 'patches:' \ + ' - target:' \ + ' version: v1' \ + ' kind: Secret' \ + ' name: capsule-tls' \ + ' patch: |' \ + ' - op: add' \ + ' path: /data' \ + ' value:' + printf ' ca.crt: %s\n' "${PLAYGROUND_CA_BUNDLE}" + printf ' tls.crt: %s\n' "${CAPSULE_TLS_CERT_BUNDLE}" + printf ' tls.key: %s\n' "${CAPSULE_TLS_KEY_BUNDLE}" +} > "${render_dir}/kustomization.yaml" + +kubectl kustomize "${render_dir}" diff --git a/hack/distro/capsule/example-setup/claims.yaml b/playground/user/claims/claims.yaml similarity index 90% rename from hack/distro/capsule/example-setup/claims.yaml rename to playground/user/claims/claims.yaml index 3e0cb1a8..1e01de72 100644 --- a/hack/distro/capsule/example-setup/claims.yaml +++ b/playground/user/claims/claims.yaml @@ -5,7 +5,7 @@ metadata: name: get-me-solar namespace: solar-test spec: - pool: "solar-pool" + pool: "solar" claim: requests.cpu: "2" requests.memory: 2Gi @@ -16,7 +16,7 @@ metadata: name: get-me-solar-2 namespace: solar-test spec: - pool: "solar-pool" + pool: "solar" claim: requests.cpu: "2" requests.memory: 2Gi @@ -29,7 +29,7 @@ metadata: "projectcapsule.dev/release": "true" namespace: solar-test spec: - pool: "customer-pool" + pool: "renewable" claim: requests.cpu: "2" requests.memory: 2Gi diff --git a/playground/user/kustomization.yaml b/playground/user/kustomization.yaml new file mode 100644 index 00000000..121dd784 --- /dev/null +++ b/playground/user/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - claims/claims.yaml