kaniop_operator/kanidm/crd.rs
1use crate::crd::is_default;
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use k8s_openapi::api::apps::v1::StatefulSetPersistentVolumeClaimRetentionPolicy;
6use k8s_openapi::api::core::v1::{
7 Affinity, Container, EmptyDirVolumeSource, EnvVar, EphemeralVolumeSource, HostAlias,
8 PersistentVolumeClaim, PersistentVolumeClaimSpec, PodDNSConfig, PodSecurityContext,
9 ResourceRequirements, SecretKeySelector, Toleration, TopologySpreadConstraint, Volume,
10 VolumeMount,
11};
12use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, LabelSelector};
13use kube::api::ObjectMeta;
14use kube::CustomResource;
15#[cfg(feature = "schemars")]
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19/// Specification of the desired behavior of the Kanidm cluster. More info:
20/// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
21#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, Default)]
22#[cfg_attr(feature = "schemars", derive(JsonSchema))]
23// workaround: '`' character is not allowed in the kube `doc` attribute during doctests
24#[cfg_attr(
25 not(doctest),
26 kube(
27 doc = r#"The `Kanidm` custom resource definition (CRD) defines a desired [Kanidm](https://kanidm.com)
28 setup to run in a Kubernetes cluster. It allows to specify many options such as the number of replicas,
29 persistent storage, and many more.
30
31 For each `Kanidm` resource, the Operator deploys one or several `StatefulSet` objects in the same namespace. The number of StatefulSets is equal to the number of replicas.
32 "#
33 )
34)]
35#[kube(
36 category = "kaniop",
37 group = "kaniop.rs",
38 version = "v1beta1",
39 kind = "Kanidm",
40 plural = "kanidms",
41 singular = "kanidm",
42 shortname = "idm",
43 namespaced,
44 status = "KanidmStatus",
45 printcolumn = r#"{"name":"Replicas","type":"string","description":"The number of replicas: ready/desired","jsonPath":".status.replicaColumn"}"#,
46 printcolumn = r#"{"name":"Domain","type":"string","jsonPath":".spec.domain"}"#,
47 printcolumn = r#"{"name":"Secret","type":"string","jsonPath":".status.secretName"}"#,
48 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#,
49 derive = "Default"
50)]
51#[serde(rename_all = "camelCase")]
52pub struct KanidmSpec {
53 /// The DNS domain name of the server. This is used in a number of security-critical
54 /// contexts such as webauthn, so it *must* match your DNS hostname. It is used to
55 /// create security principal names such as `william@idm.example.com` so that in a
56 /// (future) trust configuration it is possible to have unique Security Principal
57 /// Names (spns) throughout the topology.
58 ///
59 /// This cannot be changed after creation.
60 #[schemars(extend("x-kubernetes-validations" = [
61 {
62 "message": "Domain cannot be changed.",
63 "rule": "self == oldSelf"},
64 {
65 "message": "Domain must be a valid DNS name",
66 "rule": "self.matches(r'^([a-z0-9]([-a-z0-9]*[a-z0-9])?\\.)*[a-z0-9]([-a-z0-9]*[a-z0-9])?$')"
67 }
68 ]))]
69 pub domain: String,
70
71 /// The origin for webauthn. This is the url to the server,
72 /// with the port included if it is non-standard (any port
73 /// except 443). This must match or be a descendent of the
74 /// domain name you configure above. If these two items are
75 /// not consistent, the server WILL refuse to start!
76 /// origin = "https://idm.example.com"
77 /// # OR
78 /// origin = "https://idm.example.com:8443"
79 ///
80 /// Defaults to `https://<domain>` if not specified.
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub origin: Option<String>,
83
84 /// Different group of replicas with specific configuration as role, resources, affinity rules, and more.
85 /// Each group will be deployed as a separate StatefulSet.
86 #[schemars(extend("x-kubernetes-validations" = [{"message": "At least one ReplicaGroup is required", "rule": "self.size() > 0"}]))]
87 // max is defined for allowing CEL expression in validation admission policy estimate
88 // expression costs
89 #[validate(length(min = 1, max = 100))]
90 pub replica_groups: Vec<ReplicaGroup>,
91
92 /// List of external replication nodes. This is used to configure replication between
93 /// different Kanidm clusters.
94 ///
95 /// **WARNING**: `admin` and `idm_admin` passwords are going to be reset.
96 // max is defined for allowing CEL expression in validation admission policy estimate
97 // expression costs.
98 #[validate(length(max = 100))]
99 #[serde(default, skip_serializing_if = "is_default")]
100 pub external_replication_nodes: Vec<ExternalReplicationNode>,
101
102 /// Container image name. More info: https://kubernetes.io/docs/concepts/containers/images
103 /// This field is optional to allow higher level config management to default or override
104 /// container images in workload controllers like StatefulSets.
105 #[serde(default = "default_image", skip_serializing_if = "is_default")]
106 pub image: String,
107
108 /// Before starting an upgrade, perform pre-upgrade checks to ensure that data can be
109 /// safely migrated to the new version. If pre-checks fail, image change is disallowed.
110 /// If set to true, upgrade pre-checks are skipped. Defaults to false.
111 #[serde(default, skip_serializing_if = "is_default")]
112 pub disable_upgrade_checks: bool,
113
114 /// Log level for Kanidm.
115 #[serde(default, skip_serializing_if = "is_default")]
116 pub log_level: KanidmLogLevel,
117
118 /// Port name used for the pods and governing service. Defaults to https.
119 #[serde(default = "default_port_name", skip_serializing_if = "is_default")]
120 pub port_name: String,
121
122 /// Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always if :latest tag
123 /// is specified, or IfNotPresent otherwise. Cannot be updated.
124 /// More info: https://kubernetes.io/docs/concepts/containers/images#updating-images
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub image_pull_policy: Option<String>,
127
128 /// List of environment variables to set in the `kanidm` container.
129 /// This can be used to set Kanidm configuration options.
130 /// More info: https://kanidm.github.io/kanidm/master/server_configuration.html
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub env: Option<Vec<EnvVar>>,
133
134 /// Namespaces to match for KanidmOAuth2Clients discovery.
135 ///
136 /// - Not defined (default): matches only the current namespace where this Kanidm resource is deployed
137 /// - Empty selector `{}`: matches all namespaces in the cluster
138 /// - Selector with labels: matches namespaces with matching labels
139 ///
140 /// Example for all namespaces: `oauth2ClientNamespaceSelector: {}`
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub oauth2_client_namespace_selector: Option<LabelSelector>,
143
144 /// Namespaces to match for KanidmGroups discovery.
145 ///
146 /// - Not defined (default): matches only the current namespace where this Kanidm resource is deployed
147 /// - Empty selector `{}`: matches all namespaces in the cluster
148 /// - Selector with labels: matches namespaces with matching labels
149 ///
150 /// Example for all namespaces: `groupNamespaceSelector: {}`
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub group_namespace_selector: Option<LabelSelector>,
153
154 /// Namespaces to match for KanidmPersonAccounts discovery.
155 ///
156 /// - Not defined (default): matches only the current namespace where this Kanidm resource is deployed
157 /// - Empty selector `{}`: matches all namespaces in the cluster
158 /// - Selector with labels: matches namespaces with matching labels
159 ///
160 /// Example for all namespaces: `personNamespaceSelector: {}`
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub person_namespace_selector: Option<LabelSelector>,
163
164 /// Namespaces to match for KanidmServiceAccounts discovery.
165 ///
166 /// - Not defined (default): matches only the current namespace where this Kanidm resource is deployed
167 /// - Empty selector `{}`: matches all namespaces in the cluster
168 /// - Selector with labels: matches namespaces with matching labels
169 ///
170 /// Example for all namespaces: `serviceAccountNamespaceSelector: {}`
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub service_account_namespace_selector: Option<LabelSelector>,
173
174 /// StorageSpec defines the configured storage for a group Kanidm servers.
175 /// If no storage option is specified, then by default an
176 /// [EmptyDir](https://kubernetes.io/docs/concepts/storage/volumes/#emptydir) will be used.
177 ///
178 /// If multiple storage options are specified, priority will be given as follows:
179 /// 1. emptyDir
180 /// 2. ephemeral
181 /// 3. volumeClaimTemplate
182 ///
183 /// Note: Kaniop does not resize PVCs until Kubernetes fix
184 /// [KEP-4650](https://github.com/kubernetes/enhancements/issues/4650).
185 /// Although, StatefulSet will be recreated if the PVC is resized.
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub storage: Option<KanidmStorage>,
188
189 /// Defines the port name used for the LDAP service. If not defined, LDAP service will not be
190 /// configured. Service port will be `3636`.
191 ///
192 /// StartTLS is not supported due to security risks such as credential leakage and MITM attacks
193 /// that are fundamental in how StartTLS works. StartTLS can not be repaired to prevent this.
194 /// LDAPS is the only secure method of communicating to any LDAP server. Kanidm will use its
195 /// certificates for both HTTPS and LDAPS.
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub ldap_port_name: Option<String>,
198
199 /// Specifies the name of the secret holding the TLS private key and certificate for the server.
200 /// If not provided, the ingress secret will be used. The server will not start if the secret
201 /// is missing.
202 #[schemars(extend("x-kubernetes-validations" = [
203 {
204 "message": "tlsSecretName must be a valid Kubernetes resource name.",
205 "rule": "self.matches(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')"
206 }
207 ]))]
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub tls_secret_name: Option<String>,
210
211 /// Service defines the service configuration for the Kanidm server.
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub service: Option<KanidmService>,
214
215 /// Ingress configuration for the Kanidm cluster.
216 ///
217 /// The domain specified in the Kanidm spec will be used as the ingress host.
218 /// TLS is required and must be configured at the ingress controller level (termination or
219 /// passthrough).
220 /// When running multiple replicas, configure session affinity on your ingress controller to
221 /// ensure proper session handling.
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub ingress: Option<KanidmIngress>,
224
225 /// Region-specific ingress configuration for multi-region deployments.
226 ///
227 /// Allows defining ingress settings for a specific region, using a subdomain of the main
228 /// Kanidm domain.
229 /// TLS is required and must be configured at the ingress controller level (termination or
230 /// passthrough).
231 /// For multi-region deployments, configure session affinity on your ingress controller to ensure proper session handling.
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub region_ingress: Option<KanidmRegionIngress>,
234
235 /// Volumes allows the configuration of additional volumes on the output StatefulSet
236 /// definition. Volumes specified will be appended to other volumes that are generated as a
237 /// result of StorageSpec objects.
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub volumes: Option<Vec<Volume>>,
240
241 /// VolumeMounts allows the configuration of additional VolumeMounts.
242 ///
243 /// VolumeMounts will be appended to other VolumeMounts in the kanidm' container, that are
244 /// generated as a result of StorageSpec objects.
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub volume_mounts: Option<Vec<VolumeMount>>,
247
248 /// The field controls if and how PVCs are deleted during the lifecycle of a StatefulSet.
249 /// The default behavior is all PVCs are retained.
250 /// This is a beta field from 1.27. It requires enabling the StatefulSetAutoDeletePVC feature
251 /// gate.
252 #[serde(skip_serializing_if = "Option::is_none")]
253 pub persistent_volume_claim_retention_policy:
254 Option<StatefulSetPersistentVolumeClaimRetentionPolicy>,
255
256 /// SecurityContext holds pod-level security attributes and common container settings.
257 /// This defaults to the default PodSecurityContext.
258 #[serde(skip_serializing_if = "Option::is_none")]
259 pub security_context: Option<PodSecurityContext>,
260
261 /// Defines the DNS policy for the pods.
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub dns_policy: Option<String>,
264
265 /// Defines the DNS configuration for the pods.
266 #[serde(skip_serializing_if = "Option::is_none")]
267 pub dns_config: Option<PodDNSConfig>,
268
269 /// Containers allows injecting additional containers or modifying operator generated
270 /// containers. This can be used to allow adding an authentication proxy to the Pods or to
271 /// change the behavior of an operator generated container. Containers described here modify
272 /// an operator generated container if they share the same name and modifications are done
273 /// via a strategic merge patch.
274 ///
275 /// The name of container managed by the operator is: kanidm
276 ///
277 /// Overriding containers is entirely outside the scope of what the maintainers will support
278 /// and by doing so, you accept that this behaviour may break at any time without notice.
279 #[serde(skip_serializing_if = "Option::is_none")]
280 pub containers: Option<Vec<Container>>,
281
282 /// InitContainers allows injecting initContainers to the Pod definition. Those can be used to
283 /// e.g. fetch secrets for injection into the Kanidm configuration from external sources.
284 /// Any errors during the execution of an initContainer will lead to a restart of the Pod.
285 /// More info: https://kubernetes.io/docs/concepts/workloads/pods/init-containers/
286 /// InitContainers described here modify an operator generated init containers if they share
287 /// the same name and modifications are done via a strategic merge patch.
288 ///
289 /// The names of init container name managed by the operator are: * init-config-reloader.
290 ///
291 /// Overriding init containers is entirely outside the scope of what the maintainers will
292 /// support and by doing so, you accept that this behaviour may break at any time without notice.
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub init_containers: Option<Vec<Container>>,
295
296 /// Minimum number of seconds for which a newly created Pod should be ready without any of its
297 /// container crashing for it to be considered available. Defaults to 0 (pod will be considered
298 /// available as soon as it is ready)
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub min_ready_seconds: Option<i32>,
301
302 /// Optional list of hosts and IPs that will be injected into the Pod's hosts file if specified.
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub host_aliases: Option<Vec<HostAlias>>,
305
306 /// Use the host's network namespace if true.
307 ///
308 /// Make sure to understand the security implications if you want to enable it
309 /// (https://kubernetes.io/docs/concepts/configuration/overview/).
310 ///
311 /// When hostNetwork is enabled, this will set the DNS policy to ClusterFirstWithHostNet
312 /// automatically.
313 #[serde(default, skip_serializing_if = "is_default")]
314 pub host_network: Option<bool>,
315
316 /// IP family for bind addresses. Defaults to IPv4.
317 ///
318 /// - `ipv4`: Uses 0.0.0.0 for bind addresses (default)
319 /// - `ipv6`: Uses [::] for bind addresses
320 #[serde(default, skip_serializing_if = "is_default")]
321 pub ip_family: IpFamily,
322}
323
324#[derive(Serialize, Deserialize, Clone, Debug, Default)]
325#[cfg_attr(feature = "schemars", derive(JsonSchema))]
326#[serde(rename_all = "camelCase")]
327pub struct ReplicaGroup {
328 /// The name of the replica group.
329 pub name: String,
330
331 /// Number of replicas to deploy for a Kanidm replica group.
332 pub replicas: i32,
333
334 /// The Kanidm role of each node in the replica group.
335 #[serde(default)]
336 pub role: KanidmServerRole,
337
338 /// If true, the first pod of the StatefulSet will be considered as the primary node.
339 /// The rest of the nodes are considered as secondary nodes.
340 /// This means that if database issues occur the content of the primary will take precedence
341 /// over the rest of the nodes.
342 /// This is only valid for the WriteReplica role and can only be set to true for one
343 /// replica group or external replication node.
344 /// Defaults to false.
345 #[serde(default)]
346 pub primary_node: bool,
347
348 /// Service configuration for the replica group.
349 /// - If not specified, pods use the default StatefulSet DNS for internal communication.
350 /// - If specified, a Kubernetes Service of type LoadBalancer is created to expose replica
351 /// group pods externally.
352 /// This enables cross-cluster or multi-region access to replicas.
353 #[serde(skip_serializing_if = "Option::is_none")]
354 pub services: Option<KanidmReplicaGroupServices>,
355
356 /// Annotations to add to the StatefulSet created for this replica group.
357 ///
358 /// Each replica group gets its own StatefulSet; these annotations are applied only to the
359 /// StatefulSet for this group.
360 /// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations
361 #[serde(skip_serializing_if = "Option::is_none")]
362 pub stateful_set_annotations: Option<BTreeMap<String, String>>,
363
364 /// Defines the resources requests and limits of the kanidm' container.
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub resources: Option<ResourceRequirements>,
367
368 /// Defines on which Nodes the Pods are scheduled.
369 #[serde(skip_serializing_if = "Option::is_none")]
370 pub node_selector: Option<BTreeMap<String, String>>,
371
372 /// Defines the Pods' affinity scheduling rules if specified.
373 #[serde(skip_serializing_if = "Option::is_none")]
374 pub affinity: Option<Affinity>,
375
376 /// Defines the Pods' tolerations if specified.
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub tolerations: Option<Vec<Toleration>>,
379
380 /// Defines the pod's topology spread constraints if specified.
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub topology_spread_constraints: Option<Vec<TopologySpreadConstraint>>,
383}
384
385// re-implementation of kanidmd_core::config::ServerRole because it is not Serialize
386#[derive(Serialize, Deserialize, Clone, Debug, Default)]
387#[cfg_attr(feature = "schemars", derive(JsonSchema))]
388#[serde(rename_all = "snake_case")]
389pub enum KanidmServerRole {
390 /// Full read-write replica with web UI
391 #[default]
392 WriteReplica,
393 /// Read-write replica without the web UI
394 WriteReplicaNoUi,
395 /// Read-only replica for load balancing read operations.
396 /// **WARNING**: read_only_replica is currently a placeholder and not yet implemented in Kanidm.
397 /// Using this role may lead to divergent data across replicas. Kaniop uses it to configure the
398 /// replication type to pull.
399 ReadOnlyReplica,
400}
401
402#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
403#[cfg_attr(feature = "schemars", derive(JsonSchema))]
404#[serde(rename_all = "camelCase")]
405pub struct KanidmReplicaGroupServices {
406 /// Annotations to apply to each Service for replica group pods.
407 ///
408 /// Available template variables:
409 /// - `{replica_index}`: Index of the pod in the replica group
410 /// - `{pod_name}`: Name of the pod
411 /// - `{domain}`: Domain name
412 #[serde(skip_serializing_if = "Option::is_none")]
413 pub annotations_template: Option<BTreeMap<String, String>>,
414
415 /// Hostname template for each Service created for replica group pods.
416 ///
417 /// Available template variables:
418 /// - `{replica_index}`: Index of the pod in the replica group
419 /// - `{pod_name}`: Name of the pod
420 /// - `{domain}`: Domain name
421 ///
422 /// If not set, the replication hostname defaults to the Service's external IP.
423 #[serde(skip_serializing_if = "Option::is_none")]
424 pub replication_hostname_template: Option<String>,
425
426 /// Map of string keys and values that can be used to organize and categorize (scope and
427 /// select) objects. May match selectors of replication controllers and services.
428 /// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels
429 #[serde(skip_serializing_if = "Option::is_none")]
430 pub additional_labels: Option<BTreeMap<String, String>>,
431}
432
433#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
434#[cfg_attr(feature = "schemars", derive(JsonSchema))]
435#[serde(rename_all = "camelCase")]
436pub struct ExternalReplicationNode {
437 /// Name of the external replication node. This just have internal use.
438 pub name: String,
439
440 /// The hostname of the external replication node.
441 pub hostname: String,
442
443 /// The replication port of the external replication node.
444 pub port: i32,
445
446 /// Defines the secret that contains the identity certificate of the external replication node.
447 pub certificate: SecretKeySelector,
448
449 /// Defines the type of replication to use. Defaults to MutualPull.
450 #[serde(default)]
451 pub _type: ReplicationType,
452
453 /// Select external replication node as the primary node. This means that if database conflicts
454 /// occur the content of the primary will take precedence over the rest of the nodes.
455 /// Note: just one external replication node or replication group can be selected as primary.
456 /// Defaults to false.
457 #[serde(default)]
458 pub automatic_refresh: bool,
459}
460
461// re-implementation of kanidmd_core::repl::config::RepNodeConfig because it is not Serialize and
462// attributes changed
463#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
464#[cfg_attr(feature = "schemars", derive(JsonSchema))]
465#[serde(rename_all = "kebab-case")]
466pub enum ReplicationType {
467 /// Both nodes can initiate replication with each other
468 #[default]
469 MutualPull,
470 /// This node allows the external node to pull changes, but won't initiate
471 AllowPull,
472 /// This node will pull changes from the external node
473 Pull,
474}
475
476fn default_image() -> String {
477 "kanidm/server:latest".to_string()
478}
479
480// re-implementation of sketching::LogLevel because it is not Serialize
481#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
482#[cfg_attr(feature = "schemars", derive(JsonSchema))]
483#[serde(rename_all = "snake_case")]
484pub enum KanidmLogLevel {
485 /// Most verbose logging level, includes all debug and info messages
486 Trace,
487 /// Debug level logging, useful for troubleshooting
488 Debug,
489 /// Standard informational logging level
490 #[default]
491 Info,
492}
493
494fn default_port_name() -> String {
495 "https".to_string()
496}
497
498#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
499#[cfg_attr(feature = "schemars", derive(JsonSchema))]
500#[serde(rename_all = "kebab-case")]
501pub enum IpFamily {
502 #[default]
503 Ipv4,
504 Ipv6,
505}
506
507/// PersistentVolumeClaimTemplate defines a PVC template with optional metadata.
508/// This allows users to specify a PVC template without requiring metadata to be explicitly set.
509#[derive(Serialize, Deserialize, Clone, Debug, Default)]
510#[cfg_attr(feature = "schemars", derive(JsonSchema))]
511#[serde(rename_all = "camelCase")]
512pub struct PersistentVolumeClaimTemplate {
513 /// Standard object's metadata. More info:
514 /// https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
515 #[serde(skip_serializing_if = "Option::is_none")]
516 pub metadata: Option<ObjectMeta>,
517
518 /// spec defines the desired characteristics of a volume requested by a pod author. More info:
519 /// https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims
520 pub spec: Option<PersistentVolumeClaimSpec>,
521}
522
523impl PersistentVolumeClaimTemplate {
524 /// Convert this template into a k8s-openapi PersistentVolumeClaim
525 pub fn to_persistent_volume_claim(&self) -> PersistentVolumeClaim {
526 PersistentVolumeClaim {
527 metadata: self.metadata.clone().unwrap_or_default(),
528 spec: self.spec.clone(),
529 ..Default::default()
530 }
531 }
532}
533
534#[derive(Serialize, Deserialize, Clone, Debug, Default)]
535#[cfg_attr(feature = "schemars", derive(JsonSchema))]
536#[serde(rename_all = "camelCase")]
537pub struct KanidmStorage {
538 /// EmptyDirVolumeSource to be used by the StatefulSet. If specified, it takes precedence over
539 /// `ephemeral` and `volumeClaimTemplate`.
540 /// More info: https://kubernetes.io/docs/concepts/storage/volumes/#emptydir
541 #[serde(skip_serializing_if = "Option::is_none")]
542 pub empty_dir: Option<EmptyDirVolumeSource>,
543
544 /// EphemeralVolumeSource to be used by the StatefulSet.
545 /// More info: https://kubernetes.io/docs/concepts/storage/ephemeral-volumes/#generic-ephemeral-volumes
546 #[serde(skip_serializing_if = "Option::is_none")]
547 pub ephemeral: Option<EphemeralVolumeSource>,
548
549 /// Defines the PVC spec to be used by the Kanidm StatefulSets. The easiest way to use a volume
550 /// that cannot be automatically provisioned is to use a label selector alongside manually
551 /// created PersistentVolumes.
552 #[serde(skip_serializing_if = "Option::is_none")]
553 pub volume_claim_template: Option<PersistentVolumeClaimTemplate>,
554}
555
556#[derive(Serialize, Deserialize, Clone, Debug, Default)]
557#[cfg_attr(feature = "schemars", derive(JsonSchema))]
558#[serde(rename_all = "camelCase")]
559pub struct KanidmService {
560 /// Annotations is an unstructured key value map stored with a resource that may be set by
561 /// external tools to store and retrieve arbitrary metadata. They are not queryable and should
562 /// be preserved when modifying objects.
563 /// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations
564 #[serde(skip_serializing_if = "Option::is_none")]
565 pub annotations: Option<BTreeMap<String, String>>,
566
567 /// Specify the Service's type where the Kanidm Service is exposed
568 /// Please note that some Ingress controllers like https://github.com/kubernetes/ingress-gce
569 /// forces you to expose your Service on a NodePort
570 /// Defaults to ClusterIP. Valid options are ExternalName, ClusterIP, NodePort, and
571 /// LoadBalancer. "ClusterIP" allocates a cluster-internal IP address for load-balancing to
572 /// endpoints. Endpoints are determined by the selector or if that is not specified, by manual
573 /// construction of an Endpoints object or EndpointSlice objects. If clusterIP is "None",
574 /// no virtual IP is allocated and the endpoints are published as a set of endpoints rather
575 /// than a virtual IP. "NodePort" builds on ClusterIP and allocates a port on every node which
576 /// routes to the same endpoints as the clusterIP. "LoadBalancer" builds on NodePort and creates
577 /// an external load-balancer (if supported in the current cloud) which routes to the same
578 /// endpoints as the clusterIP. "ExternalName" aliases this service to the specified
579 /// externalName. Several other fields do not apply to ExternalName services.
580 /// More info: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types
581 #[serde(skip_serializing_if = "Option::is_none")]
582 pub type_: Option<String>,
583
584 /// Map of string keys and values that can be used to organize and categorize (scope and
585 /// select) objects. May match selectors of replication controllers and services.
586 /// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels
587 #[serde(skip_serializing_if = "Option::is_none")]
588 pub additional_labels: Option<BTreeMap<String, String>>,
589}
590
591#[derive(Serialize, Deserialize, Clone, Debug, Default)]
592#[cfg_attr(feature = "schemars", derive(JsonSchema))]
593#[serde(rename_all = "camelCase")]
594pub struct KanidmIngress {
595 /// Annotations is an unstructured key value map stored with a resource that may be set by
596 /// external tools to store and retrieve arbitrary metadata. They are not queryable and should
597 /// be preserved when modifying objects.
598 /// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations
599 #[serde(skip_serializing_if = "Option::is_none")]
600 pub annotations: Option<BTreeMap<String, String>>,
601
602 /// ingressClassName is the name of an IngressClass cluster resource. Ingress controller
603 /// implementations use this field to know whether they should be serving this Ingress resource,
604 /// by a transitive connection (controller -\> IngressClass -\> Ingress resource). Although the
605 /// `kubernetes.io/ingress.class` annotation (simple constant name) was never formally defined,
606 /// it was widely supported by Ingress controllers to create a direct binding between Ingress
607 /// controller and Ingress resources. Newly created Ingress resources should prefer using the
608 /// field. However, even though the annotation is officially deprecated, for backwards
609 /// compatibility reasons, ingress controllers should still honor that annotation if present.
610 #[serde(skip_serializing_if = "Option::is_none")]
611 pub ingress_class_name: Option<String>,
612 /// Defines the name of the secret that contains the TLS private key and certificate for the
613 /// server. If not defined, the default will be the Kanidm name appended with `-tls`.
614 #[schemars(extend("x-kubernetes-validations" = [
615 {
616 "message": "ingress.tlsSecretName must be a valid Kubernetes resource name.",
617 "rule": "self.matches(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')"
618 }
619 ]))]
620 #[serde(skip_serializing_if = "Option::is_none")]
621 pub tls_secret_name: Option<String>,
622 /// Additional Subject Alternative Names (SANs) to include in the TLS certificate.
623 /// The main domain from the Kanidm spec is automatically included.
624 /// This does not add additional hosts to the ingress resource, only certificate SANs.
625 #[serde(skip_serializing_if = "Option::is_none")]
626 pub extra_tls_hosts: Option<BTreeSet<String>>,
627}
628
629#[derive(Serialize, Deserialize, Clone, Debug, Default)]
630#[cfg_attr(feature = "schemars", derive(JsonSchema))]
631#[serde(rename_all = "camelCase")]
632pub struct KanidmRegionIngress {
633 /// Region identifier for this ingress. Used as a subdomain of the main Kanidm domain to route
634 /// traffic for a specific region.
635 pub region: String,
636
637 /// Annotations is an unstructured key value map stored with a resource that may be set by
638 /// external tools to store and retrieve arbitrary metadata. They are not queryable and should
639 /// be preserved when modifying objects.
640 /// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations
641 #[serde(skip_serializing_if = "Option::is_none")]
642 pub annotations: Option<BTreeMap<String, String>>,
643
644 /// ingressClassName is the name of an IngressClass cluster resource. Ingress controller
645 /// implementations use this field to know whether they should be serving this Ingress resource,
646 /// by a transitive connection (controller -\> IngressClass -\> Ingress resource). Although the
647 /// `kubernetes.io/ingress.class` annotation (simple constant name) was never formally defined,
648 /// it was widely supported by Ingress controllers to create a direct binding between Ingress
649 /// controller and Ingress resources. Newly created Ingress resources should prefer using the
650 /// field. However, even though the annotation is officially deprecated, for backwards
651 /// compatibility reasons, ingress controllers should still honor that annotation if present.
652 #[serde(skip_serializing_if = "Option::is_none")]
653 pub ingress_class_name: Option<String>,
654 /// Defines the name of the secret that contains the TLS private key and certificate for the
655 /// server. If not defined, the default will be the Kanidm name appended with `-region-tls`.
656 #[schemars(extend("x-kubernetes-validations" = [
657 {
658 "message": "ingress.tlsSecretName must be a valid Kubernetes resource name.",
659 "rule": "self.matches(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$')"
660 }
661 ]))]
662 #[serde(skip_serializing_if = "Option::is_none")]
663 pub tls_secret_name: Option<String>,
664}
665
666/// Most recent observed status of the Kanidm cluster. Read-only.
667/// More info:
668/// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
669#[derive(Serialize, Deserialize, Clone, Debug, Default)]
670#[cfg_attr(feature = "schemars", derive(JsonSchema))]
671#[serde(rename_all = "camelCase")]
672pub struct KanidmStatus {
673 /// Total number of available pods (ready for at least minReadySeconds) targeted by this Kanidm
674 /// deployment.
675 pub available_replicas: i32,
676
677 #[serde(skip_serializing_if = "Option::is_none")]
678 pub conditions: Option<Vec<Condition>>,
679
680 /// Total number of non-terminated pods targeted by this Kanidm cluster.
681 pub replicas: i32,
682
683 /// Total number of unavailable pods targeted by this Kanidm cluster.
684 pub unavailable_replicas: i32,
685
686 /// Total number of non-terminated pods targeted by this Kanidm cluster that have the
687 /// desired version spec.
688 pub updated_replicas: i32,
689
690 /// Status per replica in the Kanidm cluster.
691 pub replica_statuses: Vec<KanidmReplicaStatus>,
692
693 /// Ready vs desired replicas.
694 pub replica_column: String,
695
696 /// Admin users secret name.
697 pub secret_name: Option<String>,
698
699 /// The current version of the Kanidm server.
700 pub version: Option<KanidmVersionStatus>,
701}
702
703#[derive(Serialize, Deserialize, Clone, Debug)]
704#[cfg_attr(feature = "schemars", derive(JsonSchema))]
705#[serde(rename_all = "camelCase")]
706pub struct KanidmReplicaStatus {
707 /// Pod name: replica group StatefulSet name plus the pod index.
708 pub pod_name: String,
709
710 /// StatefulSet name: Kanidm name plus the replica group name.
711 pub statefulset_name: String,
712
713 /// The current state of the replica.
714 pub state: KanidmReplicaState,
715}
716
717#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
718#[cfg_attr(feature = "schemars", derive(JsonSchema))]
719#[serde(rename_all = "camelCase")]
720pub enum KanidmReplicaState {
721 Ready,
722 Pending,
723 CertificateExpiring,
724 CertificateHostInvalid,
725}
726
727#[derive(Serialize, Deserialize, Clone, Debug)]
728#[cfg_attr(feature = "schemars", derive(JsonSchema))]
729#[serde(rename_all = "camelCase")]
730pub struct KanidmVersionStatus {
731 pub image_tag: String,
732
733 pub upgrade_check_result: KanidmUpgradeCheckResult,
734
735 #[serde(default)]
736 pub compatibility_result: VersionCompatibilityResult,
737}
738
739#[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)]
740#[cfg_attr(feature = "schemars", derive(JsonSchema))]
741#[serde(rename_all = "snake_case")]
742pub enum VersionCompatibilityResult {
743 #[default]
744 Compatible,
745 Incompatible,
746}
747
748#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
749#[cfg_attr(feature = "schemars", derive(JsonSchema))]
750#[serde(rename_all = "snake_case")]
751pub enum KanidmUpgradeCheckResult {
752 Passed,
753 Failed,
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use k8s_openapi::api::core::v1::PersistentVolumeClaimSpec;
760 use serde_json::json;
761
762 #[test]
763 fn test_pvc_template_optional_metadata() {
764 // Test that we can create a PVC template without metadata
765 let pvc_template = PersistentVolumeClaimTemplate {
766 metadata: None,
767 spec: Some(PersistentVolumeClaimSpec::default()),
768 };
769
770 // Test that it can be converted to a k8s PVC
771 let k8s_pvc = pvc_template.to_persistent_volume_claim();
772 assert_eq!(k8s_pvc.metadata, ObjectMeta::default());
773 assert_eq!(k8s_pvc.spec, pvc_template.spec);
774 }
775
776 #[test]
777 fn test_pvc_template_with_metadata() {
778 let custom_metadata = ObjectMeta {
779 name: Some("test-pvc".to_string()),
780 labels: Some([("app".to_string(), "test".to_string())].into()),
781 ..ObjectMeta::default()
782 };
783
784 // Test that we can create a PVC template with metadata
785 let pvc_template = PersistentVolumeClaimTemplate {
786 metadata: Some(custom_metadata.clone()),
787 spec: Some(PersistentVolumeClaimSpec::default()),
788 };
789
790 // Test that it can be converted to a k8s PVC
791 let k8s_pvc = pvc_template.to_persistent_volume_claim();
792 assert_eq!(k8s_pvc.metadata, custom_metadata);
793 assert_eq!(k8s_pvc.spec, pvc_template.spec);
794 }
795
796 #[test]
797 fn test_kanidm_version_status_backward_compatible() {
798 let legacy_payload = json!({
799 "imageTag": "1.9.0",
800 "upgradeCheckResult": "passed"
801 });
802
803 let status: KanidmVersionStatus =
804 serde_json::from_value(legacy_payload).expect("legacy status should deserialize");
805
806 assert_eq!(status.image_tag, "1.9.0");
807 assert_eq!(
808 status.upgrade_check_result,
809 KanidmUpgradeCheckResult::Passed
810 );
811 assert_eq!(
812 status.compatibility_result,
813 VersionCompatibilityResult::Compatible
814 );
815 }
816}