bindy/reconcilers/bind9instance/
mod.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! BIND9 instance reconciliation logic.
5//!
6//! This module handles the lifecycle of BIND9 DNS server deployments in Kubernetes.
7//! It creates and manages Deployments, `ConfigMaps`, and Services for each `Bind9Instance`.
8//!
9//! ## Module Structure
10//!
11//! - [`cluster_helpers`] - Cluster integration and reference management
12//! - [`config`] - RNDC configuration precedence resolution
13//! - [`resources`] - Resource lifecycle (`ConfigMap`, Deployment, Service)
14//! - [`status_helpers`] - Status calculation and updates
15//! - [`types`] - Shared types and imports
16//! - [`zones`] - Zone reconciliation logic
17
18// Submodules
19pub mod cluster_helpers;
20pub mod config;
21pub mod resources;
22pub mod status_helpers;
23pub mod types;
24pub mod zones;
25
26// Re-export public APIs for external use
27pub use zones::reconcile_instance_zones;
28
29// Internal imports
30use cluster_helpers::{build_cluster_reference, fetch_cluster_info};
31use resources::{create_or_update_resources, delete_resources};
32use status_helpers::{update_status, update_status_from_deployment};
33#[allow(clippy::wildcard_imports)]
34use types::*;
35use zones::reconcile_instance_zones as reconcile_zones_internal;
36
37use crate::reconcilers::finalizers::{ensure_finalizer, handle_deletion, FinalizerCleanup};
38
39/// Calculate the requeue duration for the next reconciliation based on RNDC rotation schedule.
40///
41/// If auto-rotation is enabled and a rotation time is scheduled, this function calculates
42/// the duration until that rotation time. If the rotation is overdue, it returns a minimal
43/// duration to trigger immediate reconciliation.
44///
45/// # Arguments
46///
47/// * `config` - RNDC configuration with rotation settings
48/// * `secret` - The RNDC Secret with rotation annotations
49///
50/// # Returns
51///
52/// Duration until next reconciliation. Returns `None` if rotation is disabled or Secret
53/// has no rotation annotations.
54///
55/// # Examples
56///
57/// ```rust,ignore
58/// use bindy::crd::RndcKeyConfig;
59/// use k8s_openapi::api::core::v1::Secret;
60/// use bindy::reconcilers::bind9instance::calculate_requeue_duration;
61///
62/// let config = RndcKeyConfig {
63///     auto_rotate: true,
64///     rotate_after: "720h".to_string(),
65///     ..Default::default()
66/// };
67///
68/// // Create a secret with rotation annotations
69/// let secret = Secret {
70///     metadata: ObjectMeta {
71///         annotations: Some(BTreeMap::from([
72///             ("bindy.firestoned.io/rotation-created-at".to_string(), "2025-01-01T00:00:00Z".to_string()),
73///             ("bindy.firestoned.io/rotation-rotate-at".to_string(), "2025-02-01T00:00:00Z".to_string()),
74///         ])),
75///         ..Default::default()
76///     },
77///     ..Default::default()
78/// };
79///
80/// // Returns duration until rotate_at timestamp
81/// let duration = calculate_requeue_duration(&config, &secret);
82/// ```
83#[allow(dead_code)] // Will be used when requeue logic is integrated
84fn calculate_requeue_duration(
85    config: &crate::crd::RndcKeyConfig,
86    secret: &Secret,
87) -> Option<std::time::Duration> {
88    use chrono::Utc;
89
90    // Only calculate requeue if auto-rotation is enabled
91    if !config.auto_rotate {
92        return None;
93    }
94
95    // Extract rotation annotations from Secret
96    let annotations = secret.metadata.annotations.as_ref()?;
97    let (_created_at, rotate_at, _rotation_count) =
98        crate::bind9::rndc::parse_rotation_annotations(annotations).ok()?;
99
100    // If no rotation scheduled, no need for specific requeue
101    let rotate_at = rotate_at?;
102
103    let now = Utc::now();
104    let time_until_rotation = rotate_at.signed_duration_since(now);
105
106    // If rotation is overdue or very soon, reconcile quickly (30 seconds)
107    if time_until_rotation.num_seconds() <= 0 {
108        return Some(std::time::Duration::from_secs(30));
109    }
110
111    // Otherwise, schedule reconciliation slightly before rotation time (5 minutes early)
112    let requeue_secs = time_until_rotation
113        .num_seconds()
114        .saturating_sub(300) // 5 minutes early
115        .max(30); // At least 30 seconds
116
117    #[allow(clippy::cast_sign_loss)] // Value is guaranteed non-negative by max(30)
118    Some(std::time::Duration::from_secs(requeue_secs as u64))
119}
120
121/// Detects whether the parent cluster's configuration changed since it was last observed.
122///
123/// Compares the parent's current `metadata.generation` against the parent
124/// generation recorded in the instance's `status.observedParentGeneration`.
125/// These are the ONLY two values that may be compared: the instance's own
126/// `observed_generation` tracks a different, unrelated counter.
127///
128/// # Arguments
129///
130/// * `parent_generation` - Current `metadata.generation` of the referenced
131///   `Bind9Cluster`/`ClusterBind9Provider` (`None` if no parent exists)
132/// * `observed_parent_generation` - Parent generation recorded during the last
133///   successful reconciliation (`None` if never recorded)
134///
135/// # Returns
136///
137/// `true` if the parent exists and its generation differs from the recorded
138/// value (or was never recorded), `false` otherwise.
139#[must_use]
140pub fn parent_generation_changed(
141    parent_generation: Option<i64>,
142    observed_parent_generation: Option<i64>,
143) -> bool {
144    match (parent_generation, observed_parent_generation) {
145        (Some(parent), Some(observed)) => parent != observed,
146        (Some(_), None) => true, // Parent exists but was never observed
147        (None, _) => false,      // No parent - nothing to track
148    }
149}
150
151/// Update the `Bind9Instance` status with RNDC key rotation information.
152///
153/// Reads rotation metadata from the RNDC Secret annotations and updates the instance
154/// status with current rotation state. This provides visibility into key age and
155/// rotation schedule.
156///
157/// # Arguments
158///
159/// * `client` - Kubernetes API client
160/// * `instance` - The `Bind9Instance` resource to update
161/// * `secret` - The RNDC Secret containing rotation annotations
162/// * `config` - RNDC configuration with rotation settings
163///
164/// # Returns
165///
166/// `Ok(())` on success, error if status update fails.
167///
168/// # Errors
169///
170/// Returns an error if:
171/// - Secret annotations are missing or malformed
172/// - Status patch API call fails
173async fn update_rotation_status(
174    client: &Client,
175    instance: &Bind9Instance,
176    secret: &Secret,
177    config: &crate::crd::RndcKeyConfig,
178) -> Result<()> {
179    use crate::crd::RndcKeyRotationStatus;
180
181    // Only update status if auto-rotation is enabled
182    if !config.auto_rotate {
183        return Ok(());
184    }
185
186    let Some(annotations) = &secret.metadata.annotations else {
187        debug!("Secret has no annotations, skipping rotation status update");
188        return Ok(());
189    };
190
191    let (created_at, rotate_at, rotation_count) =
192        crate::bind9::rndc::parse_rotation_annotations(annotations)?;
193
194    // Determine last_rotated_at: if rotation_count > 0, the current created_at is when it was last rotated
195    let last_rotated_at = if rotation_count > 0 {
196        Some(created_at.to_rfc3339())
197    } else {
198        None
199    };
200
201    let rotation_status = RndcKeyRotationStatus {
202        created_at: created_at.to_rfc3339(),
203        rotate_at: rotate_at.map(|dt| dt.to_rfc3339()),
204        last_rotated_at,
205        rotation_count,
206    };
207
208    // Prepare status update
209    let namespace = instance.namespace().unwrap_or_default();
210    let name = instance.name_any();
211
212    let status = serde_json::json!({
213        "status": {
214            "rndcKeyRotation": rotation_status
215        }
216    });
217
218    let api: Api<Bind9Instance> = Api::namespaced(client.clone(), &namespace);
219    api.patch_status(
220        &name,
221        &PatchParams::default(),
222        &kube::api::Patch::Merge(&status),
223    )
224    .await?;
225
226    debug!(
227        "Updated rotation status for {}/{}: rotation_count={}, rotate_at={:?}",
228        namespace, name, rotation_count, rotate_at
229    );
230
231    Ok(())
232}
233
234/// Implement cleanup trait for `Bind9Instance` finalizer management.
235#[async_trait::async_trait]
236impl FinalizerCleanup for Bind9Instance {
237    async fn cleanup(&self, client: &Client) -> Result<()> {
238        let namespace = self.namespace().unwrap_or_default();
239        let name = self.name_any();
240
241        // Check if this instance is managed by a Bind9Cluster
242        let is_managed: bool = self
243            .metadata
244            .labels
245            .as_ref()
246            .and_then(|labels| labels.get(BINDY_MANAGED_BY_LABEL))
247            .is_some();
248
249        if is_managed {
250            info!(
251                "Bind9Instance {}/{} is managed by a Bind9Cluster, skipping resource cleanup (cluster will handle it)",
252                namespace, name
253            );
254            Ok(())
255        } else {
256            info!(
257                "Running cleanup for standalone Bind9Instance {}/{}",
258                namespace, name
259            );
260            delete_resources(client, &namespace, &name).await
261        }
262    }
263}
264
265/// Reconciles a `Bind9Instance` resource.
266///
267/// Creates or updates all Kubernetes resources needed to run a BIND9 DNS server:
268/// - `ConfigMap` with BIND9 configuration files
269/// - Deployment with BIND9 container pods
270/// - Service for DNS traffic (TCP/UDP port 53)
271///
272/// # Arguments
273///
274/// * `ctx` - Operator context with Kubernetes client and reflector stores
275/// * `instance` - The `Bind9Instance` resource to reconcile
276///
277/// # Returns
278///
279/// * `Ok(())` - If reconciliation succeeded
280/// * `Err(_)` - If resource creation/update failed
281///
282/// # Example
283///
284/// ```rust,no_run
285/// use bindy::reconcilers::reconcile_bind9instance;
286/// use bindy::crd::Bind9Instance;
287/// use bindy::context::Context;
288/// use std::sync::Arc;
289///
290/// async fn handle_instance(ctx: Arc<Context>, instance: Bind9Instance) -> anyhow::Result<()> {
291///     reconcile_bind9instance(ctx, instance).await?;
292///     Ok(())
293/// }
294/// ```
295///
296/// # Errors
297///
298/// Returns an error if Kubernetes API operations fail or resource creation/update fails.
299#[allow(clippy::too_many_lines)]
300pub async fn reconcile_bind9instance(ctx: Arc<Context>, instance: Bind9Instance) -> Result<()> {
301    let client = ctx.client.clone();
302    let namespace = instance.namespace().unwrap_or_default();
303    let name = instance.name_any();
304
305    info!("Reconciling Bind9Instance: {}/{}", namespace, name);
306    debug!(
307        namespace = %namespace,
308        name = %name,
309        generation = ?instance.metadata.generation,
310        "Starting Bind9Instance reconciliation"
311    );
312
313    // Check if the instance is being deleted
314    if instance.metadata.deletion_timestamp.is_some() {
315        return handle_deletion(&client, &instance, FINALIZER_BIND9_INSTANCE).await;
316    }
317
318    // Add finalizer if not present
319    ensure_finalizer(&client, &instance, FINALIZER_BIND9_INSTANCE).await?;
320
321    let spec = &instance.spec;
322    let replicas = spec.replicas.unwrap_or(1);
323    let version = spec
324        .version
325        .as_deref()
326        .unwrap_or(crate::constants::DEFAULT_BIND9_VERSION);
327
328    debug!(
329        cluster_ref = %spec.cluster_ref,
330        replicas,
331        version = %version,
332        role = ?spec.role,
333        "Instance configuration"
334    );
335
336    info!(
337        "Bind9Instance {} configured with {} replicas, version {}",
338        name, replicas, version
339    );
340
341    // Check if spec has changed using the standard generation check
342    let current_generation = instance.metadata.generation;
343    let observed_generation = instance.status.as_ref().and_then(|s| s.observed_generation);
344
345    // Check if this instance is managed by a Bind9Cluster
346    let is_managed: bool = instance
347        .metadata
348        .labels
349        .as_ref()
350        .and_then(|labels| labels.get(BINDY_MANAGED_BY_LABEL))
351        .is_some();
352
353    // Fetch cluster information early for rotation checking and zone reconciliation
354    // We need this to set the cluster reference in DNSZone status
355    let (cluster, cluster_provider) = fetch_cluster_info(&client, &namespace, &instance).await;
356
357    // Check if parent cluster configuration has changed since last reconciliation
358    // This is critical for detecting when RNDC config is added/changed at the cluster level.
359    //
360    // The parent's generation is tracked SEPARATELY from the instance's own
361    // observed_generation via status.observedParentGeneration - the two counters
362    // are unrelated and must never be compared against each other.
363    let parent_generation = cluster
364        .as_ref()
365        .and_then(|c| c.metadata.generation)
366        .or_else(|| {
367            cluster_provider
368                .as_ref()
369                .and_then(|cp| cp.metadata.generation)
370        });
371    let observed_parent_generation = instance
372        .status
373        .as_ref()
374        .and_then(|s| s.observed_parent_generation);
375
376    let parent_config_changed =
377        parent_generation_changed(parent_generation, observed_parent_generation);
378
379    if parent_config_changed {
380        debug!(
381            "Parent cluster generation ({:?}) differs from last observed parent generation ({:?})",
382            parent_generation, observed_parent_generation
383        );
384    }
385
386    if parent_config_changed {
387        info!(
388            "Parent cluster configuration may have changed for Bind9Instance {}/{}, will check for drift",
389            namespace, name
390        );
391    }
392
393    // Check if ALL required resources actually exist AND match desired state (drift detection)
394    let (all_resources_exist, deployment_labels_match, rotation_needed) = {
395        let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
396        let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
397        let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &namespace);
398        let secret_api: Api<Secret> = Api::namespaced(client.clone(), &namespace);
399
400        // Fetch deployment to check if it exists AND if OUR labels match
401        let (deployment_exists, labels_match) = match deployment_api.get(&name).await {
402            Ok(deployment) => (
403                true,
404                deployment_labels_are_current(&deployment, &name, &instance),
405            ),
406            Err(_) => (false, false),
407        };
408
409        let service_exists = service_api.get(&name).await.is_ok();
410
411        // Check ConfigMap - managed instances use cluster ConfigMap, standalone use instance ConfigMap
412        let configmap_name = if is_managed {
413            format!("{}-config", spec.cluster_ref)
414        } else {
415            format!("{name}-config")
416        };
417        let configmap_exists = configmap_api.get(&configmap_name).await.is_ok();
418
419        // Check Secret existence AND rotation status
420        let secret_name = format!("{name}-rndc-key");
421        let (secret_exists, needs_rotation) = match secret_api.get(&secret_name).await {
422            Ok(secret) => {
423                // Resolve RNDC config to check if rotation is due
424                let rndc_config = resources::resolve_full_rndc_config(
425                    &instance,
426                    cluster.as_ref(),
427                    cluster_provider.as_ref(),
428                );
429
430                // Check if rotation is needed using the existing function
431                let needs_rotation =
432                    resources::should_rotate_secret(&secret, &rndc_config).unwrap_or(false);
433
434                if needs_rotation {
435                    debug!(
436                        "RNDC Secret {}/{} rotation is due, will trigger reconciliation",
437                        namespace, secret_name
438                    );
439                }
440
441                (true, needs_rotation)
442            }
443            Err(_) => (false, false),
444        };
445
446        let all_exist = deployment_exists && service_exists && configmap_exists && secret_exists;
447        (all_exist, labels_match, needs_rotation)
448    };
449    let cluster_ref = build_cluster_reference(cluster.as_ref(), cluster_provider.as_ref());
450
451    if let Some(ref cr) = cluster_ref {
452        debug!(
453            "Built cluster reference for instance {}/{}: {}/{} in namespace {:?}",
454            namespace, name, cr.kind, cr.name, cr.namespace
455        );
456    } else {
457        debug!(
458            "No cluster reference built for instance {}/{} - spec.clusterRef may be empty or cluster not found",
459            namespace, name
460        );
461    }
462
463    // Only reconcile resources if:
464    // 1. Spec changed (generation mismatch), OR
465    // 2. We haven't processed this resource yet (no observed_generation), OR
466    // 3. Resources are missing (drift detected), OR
467    // 4. RNDC Secret rotation is due, OR
468    // 5. Parent cluster configuration has changed
469    let should_reconcile =
470        crate::reconcilers::should_reconcile(current_generation, observed_generation);
471
472    // REMOVED: Zone discovery logic - instances no longer select zones
473    // Zone selection is now reversed: DNSZone.spec.bind9_instances_from selects instances
474    // This logic was removed as part of the architectural change to reverse selector direction
475
476    if !should_reconcile
477        && all_resources_exist
478        && deployment_labels_match
479        && !rotation_needed
480        && !parent_config_changed
481    {
482        debug!(
483            "Spec unchanged (generation={:?}), all resources exist, deployment labels match, no rotation needed, and parent config unchanged - skipping resource reconciliation",
484            current_generation
485        );
486        // Update status from current deployment state (only patches if status changed)
487        // Preserve existing cluster_ref from instance status if available
488        let cluster_ref = instance.status.as_ref().and_then(|s| s.cluster_ref.clone());
489        update_status_from_deployment(
490            &client,
491            &namespace,
492            &name,
493            &instance,
494            cluster_ref,
495            parent_generation,
496        )
497        .await?;
498
499        // Reconcile zones after status update
500        reconcile_zones_internal(&client, &ctx.stores, &instance).await?;
501
502        return Ok(());
503    }
504
505    // If we reach here, reconciliation is needed because:
506    // - Spec changed (generation mismatch), OR
507    // - Resources don't exist (drift), OR
508    // - Deployment labels don't match desired state (drift), OR
509    // - RNDC Secret rotation is due, OR
510    // - Parent cluster configuration has changed
511    if !deployment_labels_match && all_resources_exist {
512        info!(
513            "Deployment labels don't match desired state for {}/{}, triggering reconciliation to update labels",
514            namespace, name
515        );
516    }
517
518    if !should_reconcile && !all_resources_exist {
519        info!(
520            "Drift detected for Bind9Instance {}/{}: One or more resources missing, will recreate",
521            namespace, name
522        );
523    }
524
525    if rotation_needed {
526        info!(
527            "RNDC Secret rotation is due for Bind9Instance {}/{}, triggering reconciliation",
528            namespace, name
529        );
530    }
531
532    if parent_config_changed {
533        info!(
534            "Parent cluster configuration changed for Bind9Instance {}/{}, triggering reconciliation to apply new config",
535            namespace, name
536        );
537    }
538
539    debug!(
540        "Reconciliation needed: current_generation={:?}, observed_generation={:?}",
541        current_generation, observed_generation
542    );
543
544    // Create or update resources
545    match create_or_update_resources(&client, &namespace, &name, &instance).await {
546        Ok((cluster, cluster_provider, secret)) => {
547            info!(
548                "Successfully created/updated resources for {}/{}",
549                namespace, name
550            );
551
552            // Build cluster reference for status
553            let cluster_ref = build_cluster_reference(cluster.as_ref(), cluster_provider.as_ref());
554
555            // Record the parent generation observed during this successful
556            // reconciliation. Use the freshly fetched parent (it may have been
557            // re-fetched by create_or_update_resources).
558            let observed_parent_generation = cluster
559                .as_ref()
560                .and_then(|c| c.metadata.generation)
561                .or_else(|| {
562                    cluster_provider
563                        .as_ref()
564                        .and_then(|cp| cp.metadata.generation)
565                });
566
567            // Update status based on actual deployment state
568            update_status_from_deployment(
569                &client,
570                &namespace,
571                &name,
572                &instance,
573                cluster_ref,
574                observed_parent_generation,
575            )
576            .await?;
577
578            // Update rotation status if Secret is available
579            if let Some(ref secret) = secret {
580                // Resolve RNDC config for rotation status update
581                let rndc_config = resources::resolve_full_rndc_config(
582                    &instance,
583                    cluster.as_ref(),
584                    cluster_provider.as_ref(),
585                );
586
587                if let Err(e) =
588                    update_rotation_status(&client, &instance, secret, &rndc_config).await
589                {
590                    warn!(
591                        "Failed to update rotation status for {}/{}: {}",
592                        namespace, name, e
593                    );
594                    // Non-fatal error, continue reconciliation
595                }
596            }
597
598            // Reconcile zones after deployment creation/update
599            reconcile_zones_internal(&client, &ctx.stores, &instance).await?;
600        }
601        Err(e) => {
602            error!(
603                "Failed to create/update resources for {}/{}: {}",
604                namespace, name, e
605            );
606
607            // Update status to show error
608            let error_condition = Condition {
609                r#type: CONDITION_TYPE_READY.to_string(),
610                status: "False".to_string(),
611                reason: Some(REASON_NOT_READY.to_string()),
612                message: Some(format!("Failed to create resources: {e}")),
613                last_transition_time: Some(Utc::now().to_rfc3339()),
614            };
615            // No cluster info available on error, pass None for cluster_ref.
616            // Preserve the previously observed parent generation: the new parent
617            // config was NOT applied, so it must not be recorded as observed.
618            update_status(
619                &client,
620                &instance,
621                vec![error_condition],
622                None,
623                observed_parent_generation,
624            )
625            .await?;
626
627            return Err(e);
628        }
629    }
630
631    Ok(())
632}
633
634/// Delete handler for `Bind9Instance` resources (cleanup logic).
635///
636/// This function is kept for backward compatibility but deletion is now handled
637/// by the finalizer in `reconcile_bind9instance`.
638///
639/// # Errors
640///
641/// This function currently never returns an error, but returns `Result` for API consistency.
642pub async fn delete_bind9instance(ctx: Arc<Context>, instance: Bind9Instance) -> Result<()> {
643    let _client = ctx.client.clone();
644    let namespace = instance.namespace().unwrap_or_default();
645    let name = instance.name_any();
646
647    info!(
648        "Delete called for Bind9Instance {}/{} (handled by finalizer)",
649        namespace, name
650    );
651
652    // Deletion is now handled by the finalizer in reconcile_bind9instance
653    Ok(())
654}
655
656/// Whether a Deployment already carries every label the operator manages.
657///
658/// Used by the reconcile short-circuit: when this returns `false`, resource
659/// reconciliation runs even though nothing else looks stale.
660///
661/// Two label sets are checked, and the second is what makes an operator
662/// upgrade converge. `spec.selector` is immutable, so labels added after a
663/// Deployment exists can only go on the Pod template
664/// (`build_pod_labels_from_instance`, a superset of the metadata set). A
665/// Deployment created before topology spreading has correct *metadata* labels
666/// but no `bindy.firestoned.io/cluster` on its Pod template; checking metadata
667/// alone would let the short-circuit skip it forever, leaving it permanently
668/// without spread constraints.
669///
670/// Both checks are subset tests, not equality: other controllers, `kubectl`,
671/// and Helm add labels of their own, and those are none of our business.
672fn deployment_labels_are_current(
673    deployment: &Deployment,
674    name: &str,
675    instance: &Bind9Instance,
676) -> bool {
677    let desired_metadata = crate::bind9_resources::build_labels_from_instance(name, instance);
678    let metadata_matches = deployment.metadata.labels.as_ref().is_some_and(|actual| {
679        desired_metadata
680            .iter()
681            .all(|(key, value)| actual.get(key) == Some(value))
682    });
683
684    let desired_pod = crate::bind9_resources::build_pod_labels_from_instance(name, instance);
685    let pod_matches = deployment
686        .spec
687        .as_ref()
688        .and_then(|s| s.template.metadata.as_ref())
689        .and_then(|m| m.labels.as_ref())
690        .is_some_and(|actual| {
691            desired_pod
692                .iter()
693                .all(|(key, value)| actual.get(key) == Some(value))
694        });
695
696    if metadata_matches && !pod_matches {
697        debug!(
698            "Deployment {name} Pod template labels are stale (missing operator-managed labels); \
699             reconciling resources"
700        );
701    }
702
703    metadata_matches && pod_matches
704}
705
706/// Test-only re-export of `deployment_labels_are_current`.
707#[cfg(test)]
708pub(crate) fn deployment_labels_are_current_for_test(
709    deployment: &Deployment,
710    name: &str,
711    instance: &Bind9Instance,
712) -> bool {
713    deployment_labels_are_current(deployment, name, instance)
714}
715
716#[cfg(test)]
717#[path = "mod_tests.rs"]
718mod mod_tests;