use crate::routing::UtilityVmRole;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkloadRoleLookup {
Found(UtilityVmRole),
Missing,
Ambiguous,
}
impl WorkloadRoleLookup {
#[must_use]
pub const fn role(self) -> Option<UtilityVmRole> {
match self {
Self::Found(role) => Some(role),
_ => None,
}
}
}
#[derive(Debug, Default)]
pub struct WorkloadRoleRegistry {
inner: RwLock<RegistryInner>,
}
#[derive(Debug, Default)]
struct RegistryInner {
roles: HashMap<String, UtilityVmRole>,
aliases: HashMap<String, Vec<String>>,
alias_owner: HashMap<String, String>,
}
impl WorkloadRoleRegistry {
#[must_use]
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub async fn record(&self, id: impl Into<String>, role: UtilityVmRole) {
let id = id.into();
let previous = self.inner.write().await.roles.insert(id.clone(), role);
if let Some(previous) = previous
&& previous != role
{
tracing::warn!(
workload_id = %id,
previous = previous.as_str(),
new = role.as_str(),
"workload role record replaced with a different role",
);
}
}
pub async fn add_alias(&self, canonical: &str, alias: impl Into<String>) {
let alias = alias.into();
if alias.is_empty() || alias == canonical {
return;
}
let mut guard = self.inner.write().await;
let Some(role) = guard.roles.get(canonical).copied() else {
tracing::debug!(
canonical,
alias = %alias,
"skipping alias registration: canonical ID has no role binding",
);
return;
};
detach_alias_from_previous_owner(&mut guard, &alias);
guard.roles.insert(alias.clone(), role);
guard
.alias_owner
.insert(alias.clone(), canonical.to_string());
let entry = guard.aliases.entry(canonical.to_string()).or_default();
if !entry.iter().any(|existing| existing == &alias) {
entry.push(alias);
}
}
pub async fn rename_alias(&self, canonical: &str, new_alias: impl Into<String>) {
let new_alias = new_alias.into();
let mut guard = self.inner.write().await;
let Some(role) = guard.roles.get(canonical).copied() else {
return;
};
if let Some(old_aliases) = guard.aliases.remove(canonical) {
for old in old_aliases {
guard.roles.remove(&old);
guard.alias_owner.remove(&old);
}
}
if new_alias.is_empty() || new_alias == canonical {
return;
}
detach_alias_from_previous_owner(&mut guard, &new_alias);
guard.roles.insert(new_alias.clone(), role);
guard
.alias_owner
.insert(new_alias.clone(), canonical.to_string());
guard.aliases.insert(canonical.to_string(), vec![new_alias]);
}
pub async fn lookup(&self, id: &str) -> WorkloadRoleLookup {
let guard = self.inner.read().await;
if let Some(role) = guard.roles.get(id).copied() {
return WorkloadRoleLookup::Found(role);
}
if !is_hex_short_id(id) {
return WorkloadRoleLookup::Missing;
}
let mut resolved: Option<UtilityVmRole> = None;
for (key, role) in &guard.roles {
if !is_canonical_id(key) || !key.starts_with(id) {
continue;
}
match resolved {
None => resolved = Some(*role),
Some(existing) if existing != *role => {
tracing::warn!(
prefix = %id,
"short ID prefix matches workloads on multiple roles; refusing to guess",
);
return WorkloadRoleLookup::Ambiguous;
}
_ => {}
}
}
match resolved {
Some(role) => WorkloadRoleLookup::Found(role),
None => WorkloadRoleLookup::Missing,
}
}
pub async fn forget(&self, id: &str) -> Option<UtilityVmRole> {
let mut guard = self.inner.write().await;
let role = guard.roles.remove(id);
if let Some(alias_list) = guard.aliases.remove(id) {
for alias in alias_list {
if guard.alias_owner.get(&alias).map(String::as_str) == Some(id) {
guard.roles.remove(&alias);
guard.alias_owner.remove(&alias);
}
}
}
if let Some(owner) = guard.alias_owner.remove(id)
&& let Some(owner_aliases) = guard.aliases.get_mut(&owner)
{
owner_aliases.retain(|a| a != id);
}
role
}
}
fn detach_alias_from_previous_owner(inner: &mut RegistryInner, alias: &str) {
let Some(previous_owner) = inner.alias_owner.remove(alias) else {
return;
};
if let Some(previous_list) = inner.aliases.get_mut(&previous_owner) {
previous_list.retain(|existing| existing != alias);
}
}
fn is_hex_short_id(id: &str) -> bool {
let len = id.len();
(4..64).contains(&len) && id.bytes().all(|b| b.is_ascii_hexdigit())
}
fn is_canonical_id(id: &str) -> bool {
id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
const CANONICAL_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const CANONICAL_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
#[tokio::test]
async fn lookup_returns_none_for_unknown_id() {
let registry = WorkloadRoleRegistry::new();
assert_eq!(
registry.lookup("missing").await,
WorkloadRoleLookup::Missing
);
}
#[tokio::test]
async fn record_then_lookup_returns_stored_role() {
let registry = WorkloadRoleRegistry::new();
registry.record("abc", UtilityVmRole::Rosetta).await;
assert_eq!(
registry.lookup("abc").await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
}
#[tokio::test]
async fn forget_removes_record_and_returns_previous() {
let registry = WorkloadRoleRegistry::new();
registry.record("abc", UtilityVmRole::Native).await;
assert_eq!(registry.forget("abc").await, Some(UtilityVmRole::Native));
assert_eq!(registry.lookup("abc").await, WorkloadRoleLookup::Missing);
}
#[tokio::test]
async fn record_overwrites_existing_role() {
let registry = WorkloadRoleRegistry::new();
registry.record("abc", UtilityVmRole::Native).await;
registry.record("abc", UtilityVmRole::Rosetta).await;
assert_eq!(
registry.lookup("abc").await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
}
#[tokio::test]
async fn alias_lookup_returns_canonical_role() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Rosetta).await;
registry.add_alias(CANONICAL_A, "web").await;
assert_eq!(
registry.lookup("web").await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
}
#[tokio::test]
async fn add_alias_is_noop_without_canonical_record() {
let registry = WorkloadRoleRegistry::new();
registry.add_alias(CANONICAL_A, "ghost").await;
assert_eq!(registry.lookup("ghost").await, WorkloadRoleLookup::Missing);
}
#[tokio::test]
async fn forget_canonical_drops_aliases() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Rosetta).await;
registry.add_alias(CANONICAL_A, "web").await;
registry.add_alias(CANONICAL_A, "frontend").await;
assert_eq!(
registry.forget(CANONICAL_A).await,
Some(UtilityVmRole::Rosetta)
);
assert_eq!(registry.lookup("web").await, WorkloadRoleLookup::Missing);
assert_eq!(
registry.lookup("frontend").await,
WorkloadRoleLookup::Missing
);
}
#[tokio::test]
async fn rename_alias_drops_old_and_adds_new() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Native).await;
registry.add_alias(CANONICAL_A, "old-name").await;
registry.rename_alias(CANONICAL_A, "new-name").await;
assert_eq!(
registry.lookup("old-name").await,
WorkloadRoleLookup::Missing
);
assert_eq!(
registry.lookup("new-name").await,
WorkloadRoleLookup::Found(UtilityVmRole::Native)
);
assert_eq!(
registry.lookup(CANONICAL_A).await,
WorkloadRoleLookup::Found(UtilityVmRole::Native)
);
}
#[tokio::test]
async fn short_hex_prefix_resolves_to_canonical_role() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Rosetta).await;
assert_eq!(
registry.lookup(&CANONICAL_A[..12]).await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
assert_eq!(
registry.lookup(&CANONICAL_A[..4]).await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
}
#[tokio::test]
async fn short_prefix_does_not_match_non_canonical_keys() {
let registry = WorkloadRoleRegistry::new();
registry.record("abcd", UtilityVmRole::Rosetta).await;
assert_eq!(registry.lookup("abc").await, WorkloadRoleLookup::Missing);
}
#[tokio::test]
async fn prefix_picks_correct_canonical_among_many() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Native).await;
registry.record(CANONICAL_B, UtilityVmRole::Rosetta).await;
assert_eq!(
registry.lookup(&CANONICAL_B[..8]).await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
assert_eq!(
registry.lookup(&CANONICAL_A[..8]).await,
WorkloadRoleLookup::Found(UtilityVmRole::Native)
);
}
#[tokio::test]
async fn non_hex_strings_skip_prefix_scan() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Rosetta).await;
assert_eq!(registry.lookup("alpine").await, WorkloadRoleLookup::Missing);
}
#[tokio::test]
async fn cross_role_prefix_collision_is_ambiguous() {
let prefix = "abcd";
let canonical_x = format!("{prefix}{}", "1".repeat(60));
let canonical_y = format!("{prefix}{}", "2".repeat(60));
let registry = WorkloadRoleRegistry::new();
registry.record(canonical_x, UtilityVmRole::Native).await;
registry.record(canonical_y, UtilityVmRole::Rosetta).await;
assert_eq!(registry.lookup(prefix).await, WorkloadRoleLookup::Ambiguous);
}
#[tokio::test]
async fn same_role_prefix_collision_resolves() {
let prefix = "deed";
let canonical_x = format!("{prefix}{}", "1".repeat(60));
let canonical_y = format!("{prefix}{}", "2".repeat(60));
let registry = WorkloadRoleRegistry::new();
registry.record(canonical_x, UtilityVmRole::Rosetta).await;
registry.record(canonical_y, UtilityVmRole::Rosetta).await;
assert_eq!(
registry.lookup(prefix).await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
}
#[tokio::test]
async fn alias_reassignment_survives_old_owner_forget() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Native).await;
registry.add_alias(CANONICAL_A, "web").await;
registry.record(CANONICAL_B, UtilityVmRole::Rosetta).await;
registry.add_alias(CANONICAL_B, "web").await;
assert_eq!(
registry.lookup("web").await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
registry.forget(CANONICAL_A).await;
assert_eq!(
registry.lookup("web").await,
WorkloadRoleLookup::Found(UtilityVmRole::Rosetta)
);
}
#[tokio::test]
async fn rename_alias_steals_from_previous_owner() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Native).await;
registry.record(CANONICAL_B, UtilityVmRole::Rosetta).await;
registry.add_alias(CANONICAL_B, "web").await;
registry.rename_alias(CANONICAL_A, "web").await;
assert_eq!(
registry.lookup("web").await,
WorkloadRoleLookup::Found(UtilityVmRole::Native)
);
registry.forget(CANONICAL_B).await;
assert_eq!(
registry.lookup("web").await,
WorkloadRoleLookup::Found(UtilityVmRole::Native)
);
}
#[tokio::test]
async fn duplicate_alias_for_same_canonical_is_deduped() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Native).await;
registry.add_alias(CANONICAL_A, "web").await;
registry.add_alias(CANONICAL_A, "web").await;
registry.forget(CANONICAL_A).await;
assert_eq!(registry.lookup("web").await, WorkloadRoleLookup::Missing);
assert_eq!(
registry.lookup(CANONICAL_A).await,
WorkloadRoleLookup::Missing
);
}
#[tokio::test]
async fn forget_alias_only_removes_alias() {
let registry = WorkloadRoleRegistry::new();
registry.record(CANONICAL_A, UtilityVmRole::Native).await;
registry.add_alias(CANONICAL_A, "web").await;
assert_eq!(registry.forget("web").await, Some(UtilityVmRole::Native));
assert_eq!(registry.lookup("web").await, WorkloadRoleLookup::Missing);
assert_eq!(
registry.lookup(CANONICAL_A).await,
WorkloadRoleLookup::Found(UtilityVmRole::Native)
);
registry.forget(CANONICAL_A).await;
}
}