use tracing::{info, warn};
use url::Url;
use super::assignment::{AssignOutcome, assign, list};
use super::error::AppError;
use super::store::Store;
#[derive(Debug, PartialEq, Eq)]
pub enum AssignmentSeedTier {
AlreadySeeded,
FromBootstrapDomains,
FromLegacyPublicUrl,
NoSeed,
}
#[derive(Debug)]
pub struct AssignmentSeedOutcome {
pub tier: AssignmentSeedTier,
pub final_count: usize,
pub domains: Vec<String>,
}
pub async fn seed_assignments_first_boot(
store: &Store,
bootstrap_domains: &[String],
legacy_public_url: Option<&str>,
now_epoch: u64,
) -> Result<AssignmentSeedOutcome, AppError> {
let existing = list(store).await?;
if !existing.is_empty() {
return Ok(AssignmentSeedOutcome {
tier: AssignmentSeedTier::AlreadySeeded,
final_count: existing.len(),
domains: existing.into_iter().map(|e| e.domain).collect(),
});
}
if !bootstrap_domains.is_empty() {
let mut seeded = Vec::with_capacity(bootstrap_domains.len());
for d in bootstrap_domains {
match assign(store, d, "bootstrap-config", now_epoch).await? {
AssignOutcome::Created(e) | AssignOutcome::Existing(e) => seeded.push(e.domain),
}
}
info!(
tier = "bootstrap_domains",
count = seeded.len(),
"first-boot assignment seed complete"
);
return Ok(AssignmentSeedOutcome {
tier: AssignmentSeedTier::FromBootstrapDomains,
final_count: seeded.len(),
domains: seeded,
});
}
if let Some(url) = legacy_public_url
&& let Some(host) = host_from_public_url(url)
{
warn!(
legacy_public_url = %url,
derived_host = %host,
"first-boot assignment seed: bootstrap_domains empty — \
falling back to legacy public_url host. Consider moving \
'{host}' to config `[hosting] bootstrap_domains`."
);
let outcome = assign(store, &host, "legacy-public-url", now_epoch).await?;
let domain = match outcome {
AssignOutcome::Created(e) | AssignOutcome::Existing(e) => e.domain,
};
return Ok(AssignmentSeedOutcome {
tier: AssignmentSeedTier::FromLegacyPublicUrl,
final_count: 1,
domains: vec![domain],
});
}
warn!(
"first-boot assignment seed: no domains configured — neither \
[hosting] bootstrap_domains nor a legacy public_url is set. \
The server will reject every resolve until either the control \
plane assigns a domain or an admin pushes one through the \
control plane."
);
Ok(AssignmentSeedOutcome {
tier: AssignmentSeedTier::NoSeed,
final_count: 0,
domains: Vec::new(),
})
}
fn host_from_public_url(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase();
Some(match parsed.port() {
Some(p) => format!("{host}:{p}"),
None => host,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::config::StoreConfig;
async fn fjall_store() -> Store {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
Store::open(&cfg).await.expect("open fjall")
}
#[tokio::test]
async fn tier_1_seeds_each_bootstrap_domain() {
let store = fjall_store().await;
let outcome =
seed_assignments_first_boot(&store, &["a.example".into(), "b.example".into()], None, 1)
.await
.unwrap();
assert_eq!(outcome.tier, AssignmentSeedTier::FromBootstrapDomains);
assert_eq!(outcome.final_count, 2);
let mut domains = outcome.domains.clone();
domains.sort();
assert_eq!(domains, vec!["a.example", "b.example"]);
}
#[tokio::test]
async fn tier_2_seeds_host_from_legacy_public_url() {
let store = fjall_store().await;
let outcome = seed_assignments_first_boot(
&store,
&[],
Some("https://legacy.example.com:8443/path"),
1,
)
.await
.unwrap();
assert_eq!(outcome.tier, AssignmentSeedTier::FromLegacyPublicUrl);
assert_eq!(outcome.domains, vec!["legacy.example.com:8443"]);
}
#[tokio::test]
async fn tier_3_empty_warns_no_seed() {
let store = fjall_store().await;
let outcome = seed_assignments_first_boot(&store, &[], None, 1)
.await
.unwrap();
assert_eq!(outcome.tier, AssignmentSeedTier::NoSeed);
assert!(outcome.domains.is_empty());
}
#[tokio::test]
async fn tier_0_already_seeded_short_circuits() {
let store = fjall_store().await;
seed_assignments_first_boot(&store, &["x.example".into()], None, 1)
.await
.unwrap();
let outcome =
seed_assignments_first_boot(&store, &["y.example".into(), "z.example".into()], None, 2)
.await
.unwrap();
assert_eq!(outcome.tier, AssignmentSeedTier::AlreadySeeded);
assert_eq!(outcome.domains, vec!["x.example"]);
}
#[tokio::test]
async fn bootstrap_takes_priority_over_legacy_public_url() {
let store = fjall_store().await;
let outcome = seed_assignments_first_boot(
&store,
&["preferred.example".into()],
Some("https://legacy.example.com/path"),
1,
)
.await
.unwrap();
assert_eq!(outcome.tier, AssignmentSeedTier::FromBootstrapDomains);
assert_eq!(outcome.domains, vec!["preferred.example"]);
}
#[tokio::test]
async fn unparseable_legacy_url_falls_through_to_no_seed() {
let store = fjall_store().await;
let outcome = seed_assignments_first_boot(&store, &[], Some("not a url"), 1)
.await
.unwrap();
assert_eq!(outcome.tier, AssignmentSeedTier::NoSeed);
}
}