use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use uuid::Uuid;
use crate::capability::Capabilities;
use crate::config::{DnsConfig, DnsScope, validate_against};
use crate::error::{ConflictReason, Error, Result};
use crate::fault::{CrashSignal, FaultAction, FaultHook, TxPoint};
use crate::fsutil::ensure_private_dir;
use crate::interface::InterfaceInfo;
use crate::journal::{JournalRecord, JournalStore, Phase, SCHEMA_VERSION};
use crate::lease::{Lease, LiveRecord};
use crate::normalize::NormalizedConfig;
use crate::ownership::{ResourceId, ResourceLockManager};
use crate::platform::{Backend, PlatformSnapshot, select_default_backend};
use crate::reconciliation::Reconciler;
use crate::watch::SuppressionRegistry;
use crate::watch::{WatchCallback, WatchHandle};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictPolicy {
#[default]
Cooperative,
Enforce,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RecoveryOutcome {
Restored {
resource: ResourceId,
lease_id: Uuid,
},
JournalCleared {
resource: ResourceId,
lease_id: Uuid,
},
ExternalConflict {
resource: ResourceId,
lease_id: Uuid,
},
Busy {
resource: ResourceId,
},
}
pub(crate) struct Inner {
pub(crate) owner: String,
pub(crate) backend: Arc<dyn Backend>,
pub(crate) locks: ResourceLockManager,
pub(crate) journal: JournalStore,
pub(crate) conflict_policy: ConflictPolicy,
pub(crate) hook: Mutex<Option<Arc<dyn FaultHook>>>,
pub(crate) suppressions: Arc<SuppressionRegistry>,
pub(crate) active: Mutex<HashMap<ResourceId, Arc<Mutex<LiveRecord>>>>,
pub(crate) lease_tokens: Mutex<HashMap<ResourceId, Arc<Mutex<()>>>>,
#[allow(dead_code)]
pub(crate) reconciler: Reconciler,
pub(crate) enforce: Mutex<EnforceState>,
}
#[derive(Default)]
pub(crate) struct EnforceState {
refs: usize,
handle: Option<WatchHandle>,
feed: Option<std::sync::mpsc::Sender<ResourceId>>,
}
impl std::fmt::Debug for EnforceState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnforceState")
.field("refs", &self.refs)
.field("watching", &self.handle.is_some())
.finish()
}
}
const COALESCE_WINDOW: Duration = Duration::from_millis(50);
impl Inner {
pub(crate) fn share_records(&self, records: Vec<JournalRecord>) -> Vec<Arc<Mutex<LiveRecord>>> {
let mut live = Vec::with_capacity(records.len());
for record in records {
let shared = Arc::new(Mutex::new(LiveRecord { record }));
self.register_active(Arc::clone(&shared));
live.push(shared);
}
live
}
pub(crate) fn ensure_enforce_watch(self: &Arc<Self>) -> Result<()> {
if self.conflict_policy != ConflictPolicy::Enforce {
return Ok(());
}
if !self.backend.capabilities().watch {
return Err(Error::unsupported(
self.backend.kind(),
"ConflictPolicy::Enforce requires change notifications, which this backend does not support",
));
}
let mut enforce = self
.enforce
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if enforce.refs == 0 {
debug_assert!(enforce.handle.is_none() && enforce.feed.is_none());
let feed = crate::reconciliation::spawn_reconciler(Arc::clone(self))?;
let feed_clone = feed.clone();
let callback: WatchCallback = Arc::new(move |event| {
let _ = feed_clone.send(event.resource().clone());
});
match self.backend.start_watch(callback) {
Ok(handle) => {
enforce.handle = Some(handle);
enforce.feed = Some(feed);
}
Err(error) => {
drop(feed);
return Err(error);
}
}
}
enforce.refs += 1;
Ok(())
}
pub(crate) fn release_enforce_watch(&self) {
if self.conflict_policy != ConflictPolicy::Enforce {
return;
}
let mut enforce = self
.enforce
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if enforce.refs == 0 {
return;
}
enforce.refs -= 1;
if enforce.refs == 0 {
enforce.handle = None;
enforce.feed = None;
}
}
pub(crate) fn enforce_feed(&self) -> Option<std::sync::mpsc::Sender<ResourceId>> {
self.enforce
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.feed
.clone()
}
#[cfg(feature = "test-util")]
#[allow(dead_code)]
pub(crate) fn enforce_refs(&self) -> usize {
self.enforce
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.refs
}
#[cfg(feature = "test-util")]
#[allow(dead_code)]
pub(crate) fn suspend_enforce_watch(&self) {
let mut enforce = self
.enforce
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
enforce.handle = None;
enforce.feed = None;
}
#[cfg(feature = "test-util")]
#[allow(dead_code)]
pub(crate) fn enforce_watching(&self) -> bool {
self.enforce
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.handle
.is_some()
}
}
pub(crate) struct MutatePoints {
apply: TxPoint,
readback: TxPoint,
verify: TxPoint,
}
pub(crate) const INITIAL_POINTS: MutatePoints = MutatePoints {
apply: TxPoint::AfterApply,
readback: TxPoint::AfterReadback,
verify: TxPoint::AfterVerify,
};
const UPDATE_POINTS: MutatePoints = MutatePoints {
apply: TxPoint::AfterUpdateApply,
readback: TxPoint::AfterUpdateReadback,
verify: TxPoint::AfterUpdateVerify,
};
enum RecoverBlock {
Corrupt(Error),
Conflict(String),
}
impl Inner {
pub(crate) fn fire(&self, point: TxPoint) -> Result<()> {
let hook = self
.hook
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
if let Some(hook) = hook {
match hook.on_point(point) {
FaultAction::Continue => {}
FaultAction::Crash => std::panic::panic_any(CrashSignal),
FaultAction::Fail(message) => {
return Err(Error::platform(
self.backend.kind(),
format_args!("injected transaction failure: {message}"),
));
}
}
}
Ok(())
}
pub(crate) fn mutate_and_verify(
&self,
resource: &ResourceId,
plan: &NormalizedConfig,
rollback_to: Option<&PlatformSnapshot>,
points: MutatePoints,
) -> Result<PlatformSnapshot> {
self.suppressions.suppress(resource);
let _receipt = self.backend.apply(resource, plan)?;
self.fire(points.apply)?;
let actual = match self.backend.readback(resource) {
Ok(actual) => actual,
Err(error) => {
if let Some(target) = rollback_to {
self.attempt_rollback(resource, target);
}
return Err(error);
}
};
self.fire(points.readback)?;
if !self.backend.matches_desired(&actual, plan) {
if let Some(target) = rollback_to {
self.attempt_rollback(resource, target);
}
return Err(Error::VerificationFailed {
resource: resource.clone(),
detail:
"the state read back from the system does not match the desired configuration"
.to_string(),
});
}
self.fire(points.verify)?;
Ok(actual)
}
#[allow(unused_variables)]
fn attempt_rollback(&self, resource: &ResourceId, target: &PlatformSnapshot) {
let current = match self.backend.readback(resource) {
Ok(current) => current,
Err(error) => {
osdns_warn!(
resource = %resource,
error = %error,
"rollback after failed verification could not read back the current state"
);
return;
}
};
if self.backend.equivalent(¤t, target) {
return;
}
if let Err(error) = self.backend.restore(resource, target) {
osdns_warn!(
resource = %resource,
error = %error,
"rollback after failed verification could not restore the previous state"
);
return;
}
match self.backend.readback(resource) {
Ok(now) if self.backend.equivalent(&now, target) => {}
Ok(_) => {
osdns_warn!(
resource = %resource,
"rollback after failed verification did not read back as the previous state"
);
}
Err(error) => {
osdns_warn!(
resource = %resource,
error = %error,
"rollback after failed verification could not read back the restored state"
);
}
}
}
pub(crate) fn transact_with_locks(
&self,
resources: Vec<ResourceId>,
plan: &NormalizedConfig,
befores: Vec<PlatformSnapshot>,
) -> Result<Vec<JournalRecord>> {
let lease_id = Uuid::new_v4();
let mut records: Vec<JournalRecord> = resources
.into_iter()
.zip(befores)
.map(|(resource, before)| JournalRecord {
schema_version: SCHEMA_VERSION,
owner: self.owner.clone(),
lease_id,
resource,
backend: self.backend.kind(),
phase: Phase::Prepared,
before,
desired: plan.clone(),
applied: None,
})
.collect();
for record in &records {
self.journal.write(record)?;
}
self.fire(TxPoint::AfterPrepared)?;
let mut actuals: Vec<PlatformSnapshot> = Vec::new();
for index in 0..records.len() {
match self.mutate_and_verify(
&records[index].resource,
plan,
Some(&records[index].before),
INITIAL_POINTS,
) {
Ok(actual) => actuals.push(actual),
Err(error) => {
for record in &records[..=index] {
self.revert_record(record);
}
for record in &records[index + 1..] {
let _ = self.journal.remove(&record.lease_id, &record.resource);
}
return Err(error);
}
}
}
for (record, actual) in records.iter_mut().zip(actuals) {
record.phase = Phase::Applied;
record.applied = Some(actual);
}
let written = records.clone();
for record in &written {
self.journal.write(record)?;
}
self.fire(TxPoint::AfterApplied)?;
Ok(records)
}
#[allow(unused_variables)]
fn revert_record(&self, record: &JournalRecord) {
self.suppressions.suppress(&record.resource);
if let Err(error) = self.backend.restore(&record.resource, &record.before) {
osdns_warn!(
resource = %record.resource,
error = %error,
"transaction rollback could not restore the previous state; the journal record was kept for later recovery"
);
return;
}
match self.backend.readback(&record.resource) {
Ok(current) if self.backend.equivalent(¤t, &record.before) => {
let _ = self.journal.remove(&record.lease_id, &record.resource);
}
Ok(_) => {
osdns_warn!(
resource = %record.resource,
"transaction rollback did not read back as the previous state; the journal record was kept for later recovery"
);
}
Err(error) => {
osdns_warn!(
resource = %record.resource,
error = %error,
"transaction rollback could not read back the restored state; the journal record was kept for later recovery"
);
}
}
}
pub(crate) fn transact_update(
&self,
live: &[Arc<Mutex<LiveRecord>>],
plan: &NormalizedConfig,
) -> Result<()> {
self.fire(TxPoint::AfterUpdateResolve)?;
let mut olds: Vec<JournalRecord> = Vec::with_capacity(live.len());
let mut applieds: Vec<PlatformSnapshot> = Vec::with_capacity(live.len());
for record in live {
let guard = record
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let applied = guard.record.applied.clone().ok_or_else(|| {
Error::platform(
self.backend.kind(),
format_args!(
"owned lease record for {} is missing its applied snapshot",
guard.record.resource
),
)
})?;
olds.push(guard.record.clone());
applieds.push(applied);
}
let mut currents = Vec::with_capacity(live.len());
for index in 0..live.len() {
let resource = olds[index].resource.clone();
let current = self.backend.readback(&resource)?;
self.fire(TxPoint::AfterUpdateCapture)?;
if !self.backend.equivalent(¤t, &applieds[index]) {
return Err(Error::ExternalModification {
resource,
detail: "the current state no longer matches the state applied by this lease"
.to_string(),
});
}
currents.push(current);
}
if currents
.iter()
.all(|current| self.backend.matches_desired(current, plan))
{
self.fire(TxPoint::AfterUpdateNoopCheck)?;
return Ok(());
}
for record in live {
let mut guard = record
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
guard.record.desired = plan.clone();
guard.record.phase = Phase::Prepared;
if let Err(error) = self.journal.write(&guard.record) {
drop(guard);
for (old, live_record) in olds.iter().zip(live.iter()) {
let mut guard = live_record
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
guard.record = old.clone();
let _ = self.journal.write(&guard.record);
}
return Err(error);
}
}
self.fire(TxPoint::AfterUpdatePrepared)?;
let mut actuals: Vec<Option<PlatformSnapshot>> = vec![None; live.len()];
for index in 0..live.len() {
let resource = olds[index].resource.clone();
match self.mutate_and_verify(&resource, plan, Some(&applieds[index]), UPDATE_POINTS) {
Ok(actual) => actuals[index] = Some(actual),
Err(error) => {
for (rollback_index, old) in olds.iter().enumerate().take(index) {
let resource = &old.resource;
self.suppressions.suppress(resource);
if self.backend.readback(resource).is_ok() {
let _ = self.backend.restore(resource, &applieds[rollback_index]);
}
}
self.fire(TxPoint::AfterUpdateVerify).ok();
for (old, live_record) in olds.iter().zip(live.iter()) {
let mut guard = live_record
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
guard.record = old.clone();
let _ = self.journal.write(&guard.record);
}
return Err(error);
}
}
}
for (index, record) in live.iter().enumerate() {
let mut guard = record
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
guard.record.phase = Phase::Applied;
guard.record.applied = actuals[index].clone();
if let Err(error) = self.journal.write(&guard.record) {
drop(guard);
return Err(error);
}
}
self.fire(TxPoint::AfterUpdateApplied)?;
Ok(())
}
pub(crate) fn restore_lease_state(&self, record: &JournalRecord) -> Result<()> {
let resource = &record.resource;
self.suppressions.suppress(resource);
let current = self.backend.readback(resource)?;
self.fire(TxPoint::AfterRestoreReadback)?;
if self.backend.equivalent(¤t, &record.before) {
self.journal.remove(&record.lease_id, resource)?;
self.fire(TxPoint::AfterRestoreJournal)?;
return Ok(());
}
let applied_ours = record
.applied
.as_ref()
.is_some_and(|applied| self.backend.equivalent(¤t, applied));
if !applied_ours {
return Err(Error::ExternalModification {
resource: resource.clone(),
detail: "the current state is neither the state applied by this lease nor the original state"
.to_string(),
});
}
self.backend.restore(resource, &record.before)?;
self.fire(TxPoint::AfterRestoreRestore)?;
let now = self.backend.readback(resource)?;
if !self.backend.equivalent(&now, &record.before) {
return Err(Error::VerificationFailed {
resource: resource.clone(),
detail: "the restored state failed read-back verification".to_string(),
});
}
self.journal.remove(&record.lease_id, resource)?;
self.fire(TxPoint::AfterRestoreJournal)?;
Ok(())
}
#[allow(unused_variables)]
pub(crate) fn best_effort_restore(&self, record: &JournalRecord) {
if let Err(error) = self.restore_lease_state(record) {
osdns_warn!(
owner = %self.owner,
resource = %record.resource,
error = %error,
"best-effort restore on lease drop failed; the journal record was kept for later recovery"
);
}
}
fn recover_record(&self, record: JournalRecord) -> Result<RecoveryOutcome> {
let resource = record.resource.clone();
let current = self.backend.capture(&resource)?;
self.fire(TxPoint::AfterRecoveryReadback)?;
if self.backend.equivalent(¤t, &record.before) {
self.journal.remove(&record.lease_id, &resource)?;
self.fire(TxPoint::AfterRecoveryJournal)?;
return Ok(RecoveryOutcome::JournalCleared {
resource,
lease_id: record.lease_id,
});
}
let applied_ours = record
.applied
.as_ref()
.is_some_and(|applied| self.backend.equivalent(¤t, applied));
if applied_ours || self.backend.matches_desired(¤t, &record.desired) {
self.suppressions.suppress(&resource);
self.backend.restore(&resource, &record.before)?;
self.fire(TxPoint::AfterRecoveryRestore)?;
let now = self.backend.readback(&resource)?;
if !self.backend.equivalent(&now, &record.before) {
return Err(Error::VerificationFailed {
resource,
detail: "the recovery restore did not read back as the original state"
.to_string(),
});
}
self.journal.remove(&record.lease_id, &resource)?;
self.fire(TxPoint::AfterRecoveryJournal)?;
return Ok(RecoveryOutcome::Restored {
resource,
lease_id: record.lease_id,
});
}
Ok(RecoveryOutcome::ExternalConflict {
resource,
lease_id: record.lease_id,
})
}
fn recover_for_resource(&self, resource: &ResourceId) -> std::result::Result<(), RecoverBlock> {
let records = self
.journal
.records_for(resource)
.map_err(RecoverBlock::Corrupt)?;
let mut conflict = None;
for record in records {
match self.recover_record(record) {
Ok(RecoveryOutcome::ExternalConflict { .. }) => {
conflict = Some(
"the current state matches neither the journal's applied state nor its original state"
.to_string(),
);
}
Ok(_) => {}
Err(error @ Error::JournalCorrupt(_)) => {
return Err(RecoverBlock::Corrupt(error));
}
Err(error) => {
return Err(RecoverBlock::Conflict(error.to_string()));
}
}
}
match conflict {
Some(detail) => Err(RecoverBlock::Conflict(detail)),
None => Ok(()),
}
}
pub(crate) fn recover_stale(&self) -> Result<Vec<RecoveryOutcome>> {
let records = self.journal.records()?;
let mut outcomes = Vec::new();
for record in records {
let resource = record.resource.clone();
match self.locks.try_acquire(&resource) {
Ok(Some(lock)) => {
let outcome = self.recover_record(record)?;
drop(lock);
outcomes.push(outcome);
}
Ok(None) => outcomes.push(RecoveryOutcome::Busy { resource }),
Err(Error::Conflict { .. }) => {
outcomes.push(RecoveryOutcome::Busy { resource });
}
Err(error) => return Err(error),
}
}
Ok(outcomes)
}
pub(crate) fn abandon_journal(&self, resource: &ResourceId) -> Result<()> {
let lock = self.locks.acquire(resource)?;
let records = self.journal.records_for(resource)?;
let mut result = Ok(());
for record in records {
if let Err(error) = self.journal.remove(&record.lease_id, resource) {
result = Err(error);
break;
}
}
drop(lock);
result
}
}
#[derive(Clone)]
pub struct DnsManager {
inner: Arc<Inner>,
}
impl DnsManager {
pub(crate) fn from_inner(inner: Arc<Inner>) -> Self {
Self { inner }
}
pub fn builder() -> DnsManagerBuilder {
DnsManagerBuilder::new()
}
pub fn owner(&self) -> &str {
&self.inner.owner
}
pub fn conflict_policy(&self) -> ConflictPolicy {
self.inner.conflict_policy
}
pub fn capabilities(&self) -> Result<Capabilities> {
Ok(self.inner.backend.capabilities())
}
pub fn interfaces(&self) -> Result<Vec<InterfaceInfo>> {
self.inner.backend.list_interfaces()
}
pub fn snapshot(&self, scope: &DnsScope) -> Result<DnsConfig> {
let resources = self
.inner
.backend
.resolve_resources(scope, &NormalizedConfig::default())?;
let resource = resources.first().ok_or_else(|| {
Error::invalid_config("the backend resolved the scope to no resources")
})?;
let snapshot = self.inner.backend.capture(resource)?;
self.inner.backend.public_state(&snapshot, scope)
}
pub fn validate(&self, config: &DnsConfig) -> Result<()> {
let caps = self.inner.backend.capabilities();
let plan = validate_against(config, &caps)?;
self.inner.backend.validate_plan(config.scope(), &plan)?;
Ok(())
}
pub fn apply(&self, config: &DnsConfig) -> Result<Lease> {
let caps = self.inner.backend.capabilities();
let plan = validate_against(config, &caps)?;
self.inner.backend.validate_plan(config.scope(), &plan)?;
self.inner.fire(TxPoint::AfterValidate)?;
let resources = self
.inner
.backend
.resolve_resources(config.scope(), &plan)?;
self.inner.fire(TxPoint::AfterResolve)?;
let locks = self.inner.locks.acquire_all(&resources)?;
self.inner.fire(TxPoint::AfterLock)?;
for resource in &resources {
match self.inner.recover_for_resource(resource) {
Ok(()) => {}
Err(RecoverBlock::Corrupt(error)) => return Err(error),
Err(RecoverBlock::Conflict(detail)) => {
return Err(Error::Conflict {
resource: resource.clone(),
reason: ConflictReason::StaleJournalUnresolved { detail },
});
}
}
}
self.inner.fire(TxPoint::AfterRecovery)?;
let mut befores = Vec::with_capacity(resources.len());
for resource in &resources {
befores.push(self.inner.backend.capture(resource)?);
self.inner.fire(TxPoint::AfterCapture)?;
}
if resources
.iter()
.zip(&befores)
.all(|(_resource, before)| self.inner.backend.matches_desired(before, &plan))
{
self.inner.fire(TxPoint::AfterNoopDecision)?;
let lease = Lease::new_noop(self.inner.clone(), resources, locks);
self.inner.ensure_enforce_watch()?;
return Ok(lease);
}
match self.inner.transact_with_locks(resources, &plan, befores) {
Ok(records) => {
let lease = Lease::new_owned(self.inner.clone(), records, locks);
if let Err(error) = self.inner.ensure_enforce_watch() {
let _ = lease.restore();
return Err(error);
}
Ok(lease)
}
Err(error) => Err(error),
}
}
pub fn watch(&self, callback: WatchCallback) -> Result<WatchHandle> {
let feed = if self.inner.conflict_policy == ConflictPolicy::Enforce {
match self.inner.enforce_feed() {
Some(existing) => Some(existing),
None => Some(crate::reconciliation::spawn_reconciler(self.inner.clone())?),
}
} else {
None
};
let coalescer =
crate::watch::spawn_coalescer(self.inner.backend.kind(), callback, COALESCE_WINDOW)?;
let suppressions = Arc::clone(&self.inner.suppressions);
let filtered: WatchCallback = Arc::new(move |event| {
if let Some(feed) = &feed {
let _ = feed.send(event.resource().clone());
}
if suppressions.is_suppressed(event.resource()) {
return;
}
coalescer(event);
});
self.inner.backend.start_watch(filtered)
}
pub fn flush_cache(&self) -> Result<()> {
self.inner.backend.flush_cache()
}
pub fn recover_stale(&self) -> Result<Vec<RecoveryOutcome>> {
self.inner.recover_stale()
}
pub fn abandon_journal(&self, resource: &ResourceId) -> Result<()> {
self.inner.abandon_journal(resource)
}
#[cfg(feature = "test-util")]
pub fn install_fault_injector(&self, injector: Arc<crate::testing::FaultInjector>) {
let hook: Arc<dyn FaultHook> = injector;
*self
.inner
.hook
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(hook);
}
#[cfg(feature = "test-util")]
pub fn set_journal_fail_writes(&self, fail: bool) {
self.inner.journal.set_fail_writes(fail);
}
#[cfg(feature = "test-util")]
pub fn debug_enforce_refs(&self) -> usize {
self.inner.enforce_refs()
}
#[cfg(feature = "test-util")]
pub fn debug_enforce_watching(&self) -> bool {
self.inner.enforce_watching()
}
#[cfg(feature = "test-util")]
pub fn suspend_enforce_background(&self) {
self.inner.suspend_enforce_watch();
}
#[cfg(feature = "test-util")]
pub fn debug_reconcile(&self, resource: &str) -> Result<crate::testing::DebugReconcile> {
use crate::reconciliation::ReconcileOutcome;
let resource: ResourceId = resource.parse().map_err(|e| {
Error::invalid_config(format_args!("invalid resource id {resource:?}: {e}"))
})?;
Ok(
match self
.inner
.reconcile_resource(&resource, &self.inner.reconciler)
{
ReconcileOutcome::NoActiveLease => crate::testing::DebugReconcile::NotOwned,
ReconcileOutcome::StillOurs => crate::testing::DebugReconcile::StillOurs,
ReconcileOutcome::Rebased => crate::testing::DebugReconcile::Rebased,
ReconcileOutcome::Deferred => crate::testing::DebugReconcile::Deferred,
ReconcileOutcome::Failed => crate::testing::DebugReconcile::Failed,
},
)
}
}
impl std::fmt::Debug for DnsManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DnsManager")
.field("owner", &self.inner.owner)
.field("conflict_policy", &self.inner.conflict_policy)
.finish_non_exhaustive()
}
}
pub struct DnsManagerBuilder {
owner: Option<String>,
state_dir: Option<PathBuf>,
lock_timeout: Duration,
conflict_policy: ConflictPolicy,
}
impl DnsManagerBuilder {
pub(crate) fn new() -> Self {
Self {
owner: None,
state_dir: None,
lock_timeout: Duration::from_secs(30),
conflict_policy: ConflictPolicy::default(),
}
}
pub fn owner(mut self, owner: impl Into<String>) -> Self {
self.owner = Some(owner.into());
self
}
pub fn state_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.state_dir = Some(dir.into());
self
}
pub fn lock_timeout(mut self, timeout: Duration) -> Self {
self.lock_timeout = timeout;
self
}
pub fn conflict_policy(mut self, policy: ConflictPolicy) -> Self {
self.conflict_policy = policy;
self
}
pub fn build(self) -> Result<DnsManager> {
let owner = self
.owner
.ok_or_else(|| Error::invalid_config("an owner identifier is required"))?;
validate_owner(&owner)?;
if self.lock_timeout.is_zero() {
return Err(Error::invalid_config(
"lock_timeout must be greater than zero",
));
}
let state_dir = match self.state_dir {
Some(dir) => dir,
None => default_state_dir()?,
};
ensure_private_dir(&state_dir)?;
let locks = ResourceLockManager::new(state_dir.join("locks"), self.lock_timeout);
locks.ensure_dir()?;
let journal = JournalStore::open(state_dir.join("journal"))?;
let backend = select_default_backend(&owner)?;
if self.conflict_policy == ConflictPolicy::Enforce && !backend.capabilities().watch {
return Err(Error::unsupported(
backend.kind(),
"ConflictPolicy::Enforce requires change notifications, which this backend does not support",
));
}
Ok(DnsManager::from_inner(Arc::new(Inner {
owner,
backend,
locks,
journal,
conflict_policy: self.conflict_policy,
hook: Mutex::new(None),
suppressions: Arc::new(SuppressionRegistry::new()),
active: Mutex::new(HashMap::new()),
lease_tokens: Mutex::new(HashMap::new()),
reconciler: Reconciler::default(),
enforce: Mutex::new(EnforceState::default()),
})))
}
}
impl Default for DnsManagerBuilder {
fn default() -> Self {
Self::new()
}
}
fn validate_owner(owner: &str) -> Result<()> {
if owner.is_empty() || owner.len() > 255 {
return Err(Error::invalid_config(
"owner identifier must be 1-255 characters",
));
}
for c in owner.chars() {
if !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) {
return Err(Error::invalid_config(format_args!(
"owner identifier {owner:?} contains invalid character {c:?}"
)));
}
}
Ok(())
}
#[cfg(target_os = "windows")]
fn default_state_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os("PROGRAMDATA") {
return Ok(PathBuf::from(dir).join("osdns"));
}
if let Some(dir) = std::env::var_os("LOCALAPPDATA") {
return Ok(PathBuf::from(dir).join("osdns"));
}
Err(Error::RequiresPrivilege(
"cannot determine the default state directory; set one explicitly with state_dir()"
.to_string(),
))
}
#[cfg(target_os = "macos")]
fn default_state_dir() -> Result<PathBuf> {
Ok(PathBuf::from("/Library/Application Support/osdns"))
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn default_state_dir() -> Result<PathBuf> {
Ok(PathBuf::from("/var/lib/osdns"))
}