Skip to main content

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}