From 5c8303ae59d7727128bd515d9e33b81bee559fea Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 31 Oct 2026 09:55:08 +0200 Subject: [PATCH 01/43] feat(physical): add rack and bay planning --- crates/omloom-contracts/build.rs | 11 + crates/omloom-contracts/src/ids.rs | 1 + crates/omloom-store/src/disk.rs | 30 +- crates/omloom-store/src/disk/inventory.rs | 4 +- crates/omloom-store/src/disk/workspace.rs | 7 + .../omloom-store/src/disk/workspace/assets.rs | 7 + .../src/disk/workspace/definitions.rs | 5 +- .../src/disk/workspace/physical.rs | 391 ++++++ .../src/disk/workspace/physical/lifecycle.rs | 381 ++++++ .../src/disk/workspace/physical/positions.rs | 98 ++ .../disk/workspace/physical/replacements.rs | 66 + .../src/disk/workspace/working.rs | 9 +- crates/omloom-store/src/lib.rs | 1 + crates/omloom-store/src/physical.rs | 131 ++ crates/omloom-store/src/physical/tests.rs | 91 ++ crates/omloom-store/tests/workspace.rs | 2 + .../omloom-store/tests/workspace/physical.rs | 427 +++++++ package.json | 2 +- proto/omloom/v1/contracts.proto | 4 + proto/omloom/v1/workspace.proto | 257 ++++ web/app/App.vue | 6 + web/app/PhysicalFacts.vue | 97 ++ web/app/PhysicalPlanner.vue | 174 +++ web/app/PhysicalPositions.vue | 49 + web/app/PlacementSearch.vue | 15 +- web/app/physical-units.ts | 17 + web/browser/inventory.spec.mjs | 74 ++ web/src/generated/omloom/v1/contracts.ts | 14 +- web/src/generated/omloom/v1/workspace.ts | 1088 ++++++++++++++++- web/tests/physical-units.test.mjs | 23 + 30 files changed, 3466 insertions(+), 16 deletions(-) create mode 100644 crates/omloom-store/src/disk/workspace/physical.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/lifecycle.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/positions.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/replacements.rs create mode 100644 crates/omloom-store/src/physical.rs create mode 100644 crates/omloom-store/src/physical/tests.rs create mode 100644 crates/omloom-store/tests/workspace/physical.rs create mode 100644 web/app/PhysicalFacts.vue create mode 100644 web/app/PhysicalPlanner.vue create mode 100644 web/app/PhysicalPositions.vue create mode 100644 web/app/physical-units.ts create mode 100644 web/tests/physical-units.test.mjs diff --git a/crates/omloom-contracts/build.rs b/crates/omloom-contracts/build.rs index 986183d..9d174ee 100644 --- a/crates/omloom-contracts/build.rs +++ b/crates/omloom-contracts/build.rs @@ -67,8 +67,19 @@ fn main() -> Result<(), Box> { 16 } else if (message.name() == "EvaluateRequirements" && field.name() == "nodes") || (message.name() == "RequirementGroup" && field.name() == "members") + || (message.name() == "PhysicalPlan" && matches!(field.name(),"placements"|"replacements")) + || (message.name() == "PhysicalView" && field.name() == "definitions") + || (message.name() == "PhysicalCompletion" && field.name()=="completed_replacements") { 64 + } else if (message.name() == "PhysicalCompletion" && field.name() == "installed") + || (message.name() == "PhysicalView" && field.name() == "occupancy") + { + 128 + } else if message.name() == "PhysicalView" && matches!(field.name(),"plans"|"positions") { + 25 + } else if message.name() == "PhysicalView" && field.name()=="items" { + 256 } else { 0 } diff --git a/crates/omloom-contracts/src/ids.rs b/crates/omloom-contracts/src/ids.rs index 9cba3fb..6a45d56 100644 --- a/crates/omloom-contracts/src/ids.rs +++ b/crates/omloom-contracts/src/ids.rs @@ -52,6 +52,7 @@ id!(SourceId); id!(AuthorityId); id!(RelationId); id!(OperationId); +id!(PlanId); id!(ScanId); id!(TrustId); id!(CredentialBindingId); diff --git a/crates/omloom-store/src/disk.rs b/crates/omloom-store/src/disk.rs index 75126e6..4adf6b7 100644 --- a/crates/omloom-store/src/disk.rs +++ b/crates/omloom-store/src/disk.rs @@ -22,7 +22,24 @@ use std::{ type Properties = BTreeMap; type Result = std::result::Result; const ROOT: &str = "installation"; -const SCHEMA_VERSION: u64 = 14; +const SCHEMA_VERSION: u64 = 15; +const PHYSICAL_TEXT: &[&str] = &["basis", "mounting", "item", "enclosure", "old_item"]; +const PHYSICAL_NUMBERS: &[&str] = &[ + "replacement_count", + "expected", + "x_start", + "x_length", + "y_start", + "y_length", + "z_start", + "z_length", + "mass_g", + "max_mass_g", + "demand_mw", + "capacity_mw", + "cooling_mw", + "cooling_capacity_mw", +]; const MAX_CHILDREN: usize = 2048; mod deltas; mod inventory; @@ -374,6 +391,12 @@ impl Store { } let mut schema = LabelSchema::new_node_id("OmRow"); schema.add_property(PropertyDef::new("key", PropertyType::String).not_null()); + for name in PHYSICAL_TEXT { + schema.add_property(PropertyDef::new(*name, PropertyType::String)); + } + for name in PHYSICAL_NUMBERS { + schema.add_property(PropertyDef::new(*name, PropertyType::Int)); + } for name in ["candidate", "cursor", "snapshot"] { schema.add_property(PropertyDef::new(name, PropertyType::String)); } @@ -546,6 +569,11 @@ impl Store { .keys() .filter(|key| !current.properties.contains_key(*key)) .any(|key| { + if PHYSICAL_TEXT.contains(&key.as_str()) + || PHYSICAL_NUMBERS.contains(&key.as_str()) + { + return false; + } if [ "issuer", "subject", diff --git a/crates/omloom-store/src/disk/inventory.rs b/crates/omloom-store/src/disk/inventory.rs index a93aea5..fe554e2 100644 --- a/crates/omloom-store/src/disk/inventory.rs +++ b/crates/omloom-store/src/disk/inventory.rs @@ -932,6 +932,7 @@ pub(super) fn compatible( (K::Device, K::Rack | K::Site | K::Location) | (K::Rack, K::Site | K::Location) | (K::Location, K::Site | K::Location) + | (K::PhysicalComponent, K::PhysicalPosition) ), R::Contains => matches!( (from, to), @@ -949,12 +950,13 @@ pub(super) fn compatible( | (K::Service, K::SoftwareInstallation) | (K::Service, K::ProviderAccount) | (K::Workload, K::Container | K::Service) + | (K::Device | K::Rack | K::Port | K::PhysicalComponent, K::PhysicalPosition) | ( K::Container, K::Service | K::Interface | K::SoftwareInstallation ) ), - R::HasPort => matches!((from, to), (K::Device, K::Port)), + R::HasPort => matches!((from, to), (K::Device | K::PhysicalComponent, K::Port)), R::Carries => matches!((from, to), (K::Port, K::Interface)), R::ConnectedTo => matches!((from, to), (K::Port, K::Cable) | (K::Cable, K::Port)), R::AssignedTo => matches!((from, to), (K::IpAddress, K::Interface)), diff --git a/crates/omloom-store/src/disk/workspace.rs b/crates/omloom-store/src/disk/workspace.rs index 23d4039..00e616a 100644 --- a/crates/omloom-store/src/disk/workspace.rs +++ b/crates/omloom-store/src/disk/workspace.rs @@ -17,6 +17,7 @@ mod host_import; mod interface_facts; mod network; pub(super) mod network_facts; +mod physical; mod projection; mod requirements; mod responsibility; @@ -259,6 +260,12 @@ impl Store { use api::request::Command as C; let working = request.working; match request.command.ok_or(Error::Invalid)? { + C::Physical(value) => self.workspace_physical( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + now, + ), C::ObserveProxmox(_) => Err(Error::Invalid), C::Identity(_) => Ok(reply(api::reply::Result::Identity(api::Identity { name: context.actor.clone(), diff --git a/crates/omloom-store/src/disk/workspace/assets.rs b/crates/omloom-store/src/disk/workspace/assets.rs index a3cf799..4072fe5 100644 --- a/crates/omloom-store/src/disk/workspace/assets.rs +++ b/crates/omloom-store/src/disk/workspace/assets.rs @@ -35,6 +35,8 @@ fn ownership_groups( K::SoftwareRelease, K::Workload, K::Container, + K::PhysicalComponent, + K::PhysicalPosition, ]; let mut groups = BTreeMap::<(i32, bool, i32), Vec>::new(); for parent in kinds { @@ -310,6 +312,8 @@ pub(super) fn kind_for_view(view: api::View) -> &'static [wire::AssetKind] { api::View::Overview => &[], api::View::Hardware => &[ K::Device, + K::PhysicalComponent, + K::PhysicalPosition, K::Port, K::Cable, K::Rack, @@ -1476,6 +1480,9 @@ impl Tx<'_> { working: &api::WorkingContext, now: u64, ) -> Result { + if write.target.is_none() && write.kind==wire::AssetKind::PhysicalPosition as i32 { + return Err(Error::Invalid); + } if write.kind == wire::AssetKind::AddressReference as i32 { if write.changes.is_empty() || write.changes.len() > MAX_FIELDS { return Err(Error::Invalid); diff --git a/crates/omloom-store/src/disk/workspace/definitions.rs b/crates/omloom-store/src/disk/workspace/definitions.rs index 65a5de7..5f9b03e 100644 --- a/crates/omloom-store/src/disk/workspace/definitions.rs +++ b/crates/omloom-store/src/disk/workspace/definitions.rs @@ -502,11 +502,11 @@ impl Tx<'_> { return Err(Error::Invalid); } if omloom_contracts::equipment::FIELDS.contains(&value.name.as_str()) - && (kind == wire::AssetKind::Device as i32 + && (matches!(wire::AssetKind::try_from(kind),Ok(wire::AssetKind::Device | wire::AssetKind::PhysicalComponent)) || !software::fields(wire::AssetKind::try_from(kind).map_err(|_| Error::Invalid)?) .contains(&value.name.as_str())) { - if kind != wire::AssetKind::Device as i32 { + if !matches!(wire::AssetKind::try_from(kind),Ok(wire::AssetKind::Device | wire::AssetKind::PhysicalComponent)) { return Err(Error::Invalid); } return match value.value.as_ref() { @@ -578,6 +578,7 @@ impl Tx<'_> { | wire::AssetKind::Rack | wire::AssetKind::Port | wire::AssetKind::Cable) + | Ok(wire::AssetKind::PhysicalComponent) ) => { Err(Error::Invalid) diff --git a/crates/omloom-store/src/disk/workspace/physical.rs b/crates/omloom-store/src/disk/workspace/physical.rs new file mode 100644 index 0000000..35ee4f9 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical.rs @@ -0,0 +1,391 @@ +//! Current physical definitions use scalar indexed inventory rows, not wire blobs. +use super::*; +use crate::physical::{Span, Volume}; +mod lifecycle; +mod positions; +mod replacements; + +fn volume(value: &api::PhysicalVolume) -> Result { + fn axis(value: Option<&api::PhysicalExtent>) -> Result { + let value = value.ok_or(Error::Invalid)?; + Span::new(value.start_um, value.length_um).map_err(|_| Error::Invalid) + } + Ok(Volume { + x: axis(value.x.as_ref())?, + y: axis(value.y.as_ref())?, + z: axis(value.z.as_ref())?, + }) +} +fn write_volume(data: &mut Properties, value: &api::PhysicalVolume) -> Result<()> { + volume(value)?; + for (name, axis) in [("x", &value.x), ("y", &value.y), ("z", &value.z)] { + let axis = axis.as_ref().ok_or(Error::Invalid)?; + data.insert(format!("{name}_start"), Value::Int(axis.start_um)); + data.insert(format!("{name}_length"), int(axis.length_um)?); + } + Ok(()) +} +fn read_volume(data: &Properties) -> Result> { + if !data.contains_key("x_start") { + return Ok(None); + } + fn axis(data: &Properties, name: &str) -> Result { + Ok(api::PhysicalExtent { + start_um: data + .get(&format!("{name}_start")) + .and_then(Value::as_int) + .ok_or(Error::Corrupt)?, + length_um: number(data, &format!("{name}_length"))?, + }) + } + let value = api::PhysicalVolume { + x: Some(axis(data, "x")?), + y: Some(axis(data, "y")?), + z: Some(axis(data, "z")?), + }; + volume(&value).map_err(|_| Error::Corrupt)?; + Ok(Some(value)) +} +fn definition_row(value: &api::PhysicalDefinition) -> Result { + if value.basis.is_empty() + || value.basis.len() > 512 + || value.basis.chars().any(char::is_control) + || value + .mounting + .as_ref() + .is_some_and(|s| s.is_empty() || s.len() > 128 || s.chars().any(char::is_control)) + { + return Err(Error::Invalid); + } + let target = parse_target(value.target.as_ref())?; + let mut data = props([ + ("asset", text(target.asset.to_string())), + ("incarnation", text(target.incarnation.to_string())), + ("basis", text(&value.basis)), + ]); + if let Some(v) = &value.volume { + write_volume(&mut data, v)?; + } + for (key, n) in [ + ("mass_g", value.mass_g), + ("max_mass_g", value.max_mass_g), + ("demand_mw", value.demand_mw), + ("capacity_mw", value.capacity_mw), + ("cooling_mw", value.cooling_mw), + ("cooling_capacity_mw", value.cooling_capacity_mw), + ] { + if let Some(n) = n { + data.insert(key.into(), int(n)?); + } + } + if let Some(mounting) = &value.mounting { + data.insert("mounting".into(), text(mounting)); + } + Ok(data) +} +fn definition(data: &Properties) -> Result { + let optional = |key: &str| -> Result> { + if data.contains_key(key) { + number(data, key).map(Some) + } else { + Ok(None) + } + }; + Ok(api::PhysicalDefinition { + target: Some(target_wire(target(data)?)), + volume: read_volume(data)?, + basis: string(data, "basis")?, + mass_g: optional("mass_g")?, + max_mass_g: optional("max_mass_g")?, + demand_mw: optional("demand_mw")?, + capacity_mw: optional("capacity_mw")?, + cooling_mw: optional("cooling_mw")?, + cooling_capacity_mw: optional("cooling_capacity_mw")?, + mounting: data + .get("mounting") + .map(|_| string(data, "mounting")) + .transpose()?, + }) +} +impl Tx<'_> { + fn physical_access( + &self, + context: &Context, + target: AssetRef, + working: &api::WorkingContext, + write: bool, + ) -> Result<()> { + self.selection(context, working, write)? + .contains(target, self)?; + let row = self.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + if !context.field(target, kind, "physical", false) + || (write && !context.field(target, kind, "physical", true)) + { + return Err(Error::Denied); + } + Ok(()) + } + fn physical_definition(&self, target: AssetRef) -> Result> { + self.get(&format!("physical/{}", network::identity(target)))? + .as_ref() + .map(definition) + .transpose() + } +} +impl Store { + pub(super) fn workspace_physical( + &self, + context: &Context, + request: api::Physical, + working: &api::WorkingContext, + now: u64, + ) -> Result { + use api::physical::Action; + let mut tx = self.begin()?; + let mut view = api::PhysicalView { + revision: number(&tx.root, "revision")?, + state: api::RequirementState::Unknown as i32, + reason: api::RequirementReason::Missing as i32, + ..Default::default() + }; + match request.action.ok_or(Error::Invalid)? { + Action::Position(value) => { + view.positions.push(tx.save_physical_position(context,value,working,now)?); + } + Action::Positions(value) => { + let (positions,next)=tx.physical_positions(context,value,working)?; + view.positions=positions;view.next=next; + } + Action::List(request) => { + let limit = if request.limit == 0 { 25 } else { request.limit }; + if limit > 25 { return Err(Error::Limit); } + if request.after.as_ref().is_some_and(|s| s.len()>64) { return Err(Error::Invalid); } + let after = request.after.as_deref().unwrap_or(""); + let rows = tx.query("MATCH (n:OmRow {parent:'plans'}) WHERE n.actor=$actor AND n.action='physical' AND n.key>$after RETURN properties(n) AS p ORDER BY n.key LIMIT $limit",params([("actor",text(&context.actor)),("after",text(after)),("limit",int(u64::from(limit)+1)?)]))?; + for row in rows.iter().take(limit as usize) { + let key = string(row,"key")?; + let id = key.strip_prefix("plan/").ok_or(Error::Corrupt)?; + PlanId::parse(id).map_err(|_| Error::Corrupt)?; + view.plans.push(api::PhysicalPlanSummary { id: id.into(),name: string(row,"name")?, revision: number(row,"revision")?,state: i32::try_from(number(row,"state")?).map_err(|_|Error::Corrupt)? }); + } + if rows.len()>limit as usize { view.next = Some(string(&rows[limit as usize-1],"key")?); } + } + Action::Read(read) => { + let target = parse_target(read.enclosure.as_ref())?; + tx.physical_access(context, target, working, false)?; + if let Some(value) = tx.physical_definition(target)? { + view.definitions.push(value); + } + let rows = tx.query( + "MATCH (n:OmRow {parent:$parent}) RETURN properties(n) AS p LIMIT 129", + params([( + "parent", + text(format!("allocation/{}", network::identity(target))), + )]), + )?; + if rows.len() > 128 { + return Err(Error::Limit); + } + for row in rows { + if number(&row, "state")? != api::PhysicalPlanState::Completed as u64 { + continue; + } + let item = lifecycle::reference(&string(&row, "item")?)?; + match tx.physical_access(context, item, working, false) { + Ok(()) => {} + Err(Error::Denied | Error::Missing) => continue, + Err(error) => return Err(error), + } + view.occupancy.push(api::PhysicalOccupancy { + placement: Some(api::PhysicalPlacement { + item: Some(target_wire(item)), + enclosure: Some(target_wire(target)), + volume: read_volume(&row)?, + }), + reported_by: string(&row, "actor")?, + revision: number(&row, "revision")?, + }); + } + } + Action::Define(edit) => { + let value = edit.definition.ok_or(Error::Invalid)?; + let target = parse_target(value.target.as_ref())?; + tx.physical_access(context, target, working, true)?; + let data = definition_row(&value)?; + if tx.put( + &format!("physical/{}", network::identity(target)), + "physical", + Class::Inventory, + data, + )? { + tx.audit( + &network::identity(target), + &context.actor, + "physical-definition", + view.revision.checked_add(1).ok_or(Error::Limit)?, + now, + )?; + } + view.definitions.push(value); + } + Action::Preview(preview) => { + let plan = preview.plan.ok_or(Error::Invalid)?; + tx.validate_physical_draft(context, &plan, working)?; + view.plan = Some(plan); + } + Action::Save(save) => { + let mut plan = save.plan.ok_or(Error::Invalid)?; + tx.validate_physical_draft(context, &plan, working)?; + for p in &plan.placements { + tx.physical_access(context, parse_target(p.item.as_ref())?, working, true)?; + tx.physical_access( + context, + parse_target(p.enclosure.as_ref())?, + working, + true, + )?; + } + tx.check_replacements(context,&plan,working,true)?; + let id = match &plan.id { + Some(id) => PlanId::parse(id).map_err(|_| Error::Invalid)?, + None => PlanId::generate(), + }; + let key = format!("plan/{id}"); + let mut previous_count = 0; + let mut previous_replacements = 0; + if let Some(old) = tx.get(&key)? { + if number(&old, "revision")? != plan.revision { + return Err(Error::Conflict); + } + if string(&old, "actor")? != context.actor { + return Err(Error::Denied); + } + let existing = tx.load_physical_plan( + context, + &api::PhysicalPlanKey { + id: id.to_string(), + revision: plan.revision, + }, + working, + true, + )?; + if existing.state != api::PhysicalPlanState::Draft as i32 { + return Err(Error::Conflict); + } + previous_count = existing.placements.len(); + previous_replacements = existing.replacements.len(); + plan.state = api::PhysicalPlanState::Draft as i32; + if existing == plan { + view.plan = Some(existing); + tx.physical_labels(context,&mut view)?; + view.revision = tx.commit()?; + return Ok(reply(api::reply::Result::Physical(view))); + } + } else if plan.id.is_some() { + return Err(Error::Missing); + } + plan.id = Some(id.to_string()); + plan.state = api::PhysicalPlanState::Draft as i32; + plan.reserved_until_ms = None; + plan.revision = view.revision.checked_add(1).ok_or(Error::Limit)?; + tx.put( + &key, + "plans", + Class::Inventory, + props([ + ("name", text(&plan.name)), + ("actor", text(&context.actor)), + ("action", text("physical")), + ("state", int(api::PhysicalPlanState::Draft as u64)?), + ("revision", int(plan.revision)?), + ("count", int(plan.placements.len() as u64)?), + ("replacement_count",int(plan.replacements.len() as u64)?), + ]), + )?; + for (i, p) in plan.placements.iter().enumerate() { + let item = parse_target(p.item.as_ref())?; + let enclosure = parse_target(p.enclosure.as_ref())?; + let mut data = props([ + ("item", text(network::identity(item))), + ("enclosure", text(network::identity(enclosure))), + ]); + write_volume(&mut data, p.volume.as_ref().ok_or(Error::Invalid)?)?; + tx.put(&format!("{key}/{i}"), &key, Class::Inventory, data)?; + } + for i in plan.placements.len()..previous_count { + tx.delete(&format!("{key}/{i}"))?; + } + for (i,replacement) in plan.replacements.iter().enumerate() { + tx.put(&format!("{key}/replacement/{i}"),&key,Class::Inventory,replacements::replacement_row(replacement)?)?; + } + for i in plan.replacements.len()..previous_replacements { + tx.delete(&format!("{key}/replacement/{i}"))?; + } + tx.audit( + &id.to_string(), + &context.actor, + "save-plan", + plan.revision, + now, + )?; + view.plan = Some(plan); + } + Action::Load(key) => { + view.plan = Some(tx.load_physical_plan(context, &key, working, false)?); + } + Action::Reserve(value) => { + let key = value.plan.ok_or(Error::Invalid)?; + view.plan = + Some(tx.reserve_physical(context, &key, working, now, value.until_ms)?); + } + Action::Complete(value) => { + view.plan = Some(tx.complete_physical(context, value, working, now)?); + } + Action::Cancel(key) => { + view.plan = Some(tx.cancel_physical(context, &key, working, now)?); + } + } + tx.physical_labels(context,&mut view)?; + view.revision = tx.commit()?; + Ok(reply(api::reply::Result::Physical(view))) + } +} +impl Tx<'_> { + fn validate_physical_draft( + &self, + context: &Context, + plan: &api::PhysicalPlan, + working: &api::WorkingContext, + ) -> Result<()> { + if plan.name.is_empty() + || plan.name.len() > 160 + || plan.name.chars().any(char::is_control) + || (plan.placements.is_empty() && plan.replacements.is_empty()) + || plan.placements.len() + plan.replacements.len() > 64 + { + return Err(Error::Invalid); + } + if plan.reserved_until_ms.is_some() + || !matches!( + api::PhysicalPlanState::try_from(plan.state), + Ok(api::PhysicalPlanState::Unspecified | api::PhysicalPlanState::Draft) + ) + { + return Err(Error::Invalid); + } + let mut items = BTreeSet::new(); + for p in &plan.placements { + let item = parse_target(p.item.as_ref())?; + let enclosure = parse_target(p.enclosure.as_ref())?; + if item == enclosure || !items.insert(item) { + return Err(Error::Invalid); + } + self.physical_access(context, item, working, false)?; + self.physical_access(context, enclosure, working, false)?; + volume(p.volume.as_ref().ok_or(Error::Invalid)?)?; + } + self.check_replacements(context,plan,working,false)?; + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/lifecycle.rs b/crates/omloom-store/src/disk/workspace/physical/lifecycle.rs new file mode 100644 index 0000000..04b8519 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/lifecycle.rs @@ -0,0 +1,381 @@ +use super::*; + +pub(super) fn reference(value: &str) -> Result { + let (asset, incarnation) = value.split_once('/').ok_or(Error::Corrupt)?; + Ok(AssetRef { + asset: asset.parse().map_err(|_| Error::Corrupt)?, + incarnation: incarnation.parse().map_err(|_| Error::Corrupt)?, + }) +} +fn key(value: &api::PhysicalPlanKey) -> Result { + let id = PlanId::parse(&value.id).map_err(|_| Error::Invalid)?; + Ok(format!("plan/{id}")) +} +impl Tx<'_> { + pub(super) fn load_physical_plan( + &self, + context: &Context, + request: &api::PhysicalPlanKey, + working: &api::WorkingContext, + write: bool, + ) -> Result { + let key = key(request)?; + let row = self.get(&key)?.ok_or(Error::Missing)?; + if string(&row,"action")? != "physical" { return Err(Error::Invalid); } + if string(&row, "actor")? != context.actor { + return Err(Error::Denied); + } + if request.revision != 0 && number(&row, "revision")? != request.revision { + return Err(Error::Conflict); + } + let state = i32::try_from(number(&row,"state")?).map_err(|_| Error::Corrupt)?; + let terminal = matches!(api::PhysicalPlanState::try_from(state),Ok(api::PhysicalPlanState::Completed|api::PhysicalPlanState::Cancelled)); + let count = number(&row, "count")?; + let replacement_count=number(&row,"replacement_count")?; + if count > 64 || replacement_count>64 || count+replacement_count>64 { + return Err(Error::Corrupt); + } + let mut placements = Vec::with_capacity(count as usize); + for i in 0..count { + let p = self.get(&format!("{key}/{i}"))?.ok_or(if terminal { Error::Expired } else { Error::Corrupt })?; + let item = reference(&string(&p, "item")?)?; + let enclosure = reference(&string(&p, "enclosure")?)?; + self.physical_access(context, item, working, write)?; + self.physical_access(context, enclosure, working, write)?; + placements.push(api::PhysicalPlacement { + item: Some(target_wire(item)), + enclosure: Some(target_wire(enclosure)), + volume: read_volume(&p)?, + }); + } + let mut replacements=Vec::with_capacity(replacement_count as usize); + for i in 0..replacement_count { + let row=self.get(&format!("{key}/replacement/{i}"))?.ok_or(if terminal {Error::Expired}else{Error::Corrupt})?; + let replacement=super::replacements::read_replacement(&row)?; + self.replacement_access(context,&replacement,working,write)?; + replacements.push(replacement); + } + Ok(api::PhysicalPlan { + id: Some(request.id.clone()), + name: string(&row, "name")?, + placements, + replacements, + state, + revision: number(&row, "revision")?, + reserved_until_ms: if row.contains_key("until") { + Some(number(&row, "until")?) + } else { + None + }, + }) + } + fn physical_state( + &mut self, + context: &Context, + plan: &mut api::PhysicalPlan, + state: api::PhysicalPlanState, + now: u64, + ) -> Result<()> { + let id = plan.id.as_ref().ok_or(Error::Corrupt)?; + let key = format!("plan/{id}"); + let mut row = self.get(&key)?.ok_or(Error::Corrupt)?; + let previous_state = plan.state; + operations::strip_envelope(&mut row); + plan.state = state as i32; + plan.revision = number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?; + row.insert("state".into(), int(state as u64)?); + row.insert("revision".into(), int(plan.revision)?); + if let Some(until) = plan.reserved_until_ms { + row.insert("until".into(), int(until)?); + } else { + row.remove("until"); + } + let terminal_detail = state == api::PhysicalPlanState::Completed + || (state == api::PhysicalPlanState::Cancelled && previous_state == api::PhysicalPlanState::Draft as i32); + let class = if terminal_detail { Class::Detail } else { Class::Inventory }; + if terminal_detail { + row.insert("at".into(),int(now)?); + for index in 0..plan.placements.len() { + let child_key = format!("{key}/{index}"); + let mut child = self.get(&child_key)?.ok_or(Error::Corrupt)?; + operations::strip_envelope(&mut child); + child.insert("at".into(),int(now)?); + self.put(&child_key,&key,Class::Detail,child)?; + } + for index in 0..plan.replacements.len() { + let child_key=format!("{key}/replacement/{index}"); + let mut child=self.get(&child_key)?.ok_or(Error::Corrupt)?; + operations::strip_envelope(&mut child); + child.insert("at".into(),int(now)?); + self.put(&child_key,&key,Class::Detail,child)?; + } + } + self.put(&key, "plans", class, row)?; + self.audit(id, &context.actor, "physical-plan", plan.revision, now)?; + Ok(()) + } + pub(super) fn reserve_physical( + &mut self, + context: &Context, + key: &api::PhysicalPlanKey, + working: &api::WorkingContext, + now: u64, + until: u64, + ) -> Result { + if key.revision == 0 + || until <= now + || until.checked_sub(now).ok_or(Error::Invalid)? > 86_400_000 + { + return Err(Error::Invalid); + } + let mut plan = self.load_physical_plan(context, key, working, true)?; + if plan.state != api::PhysicalPlanState::Draft as i32 { + return Err(Error::Conflict); + } + let mut enclosures = BTreeMap::>::new(); + let mut rows_read = 0; + for p in &plan.placements { + let item = parse_target(p.item.as_ref())?; + let enclosure = parse_target(p.enclosure.as_ref())?; + let row = self.visible_asset(context, enclosure)?; + if !context.field( + enclosure, + number(&row, "kind")? as i32, + "physical_availability", + false, + ) { + return Err(Error::Denied); + } + self.require_no_pending_operations(item)?; + if self + .get(&format!("allocation/{}", network::identity(item)))? + .is_some() + { + return Err(Error::Conflict); + } + let enclosure_key = network::identity(enclosure); + if !enclosures.contains_key(&enclosure_key) { + let parent = format!("allocation/{enclosure_key}"); + let rows = self.query( + "MATCH (n:OmRow {parent:$parent}) RETURN properties(n) AS p LIMIT 2049", + params([("parent", text(parent))]), + )?; + rows_read += rows.len(); + if rows_read > 2048 { + return Err(Error::Limit); + } + let mut occupied = Vec::with_capacity(rows.len()); + for row in rows { + // Installed and unresolved allocations are never freed by a clock alone. + if let Some(v) = read_volume(&row)? { + occupied.push(volume(&v)?); + } else { + return Err(Error::Corrupt); + } + } + enclosures.insert(enclosure_key.clone(), occupied); + } + let definition = self + .physical_definition(enclosure)? + .ok_or(Error::Incomplete)?; + let item_definition = self.physical_definition(item)?.ok_or(Error::Incomplete)?; + let item_volume = item_definition.volume.as_ref().ok_or(Error::Incomplete)?; + let requested = p.volume.as_ref().ok_or(Error::Invalid)?; + for (actual, proposed) in [ + (&item_volume.x, &requested.x), + (&item_volume.y, &requested.y), + (&item_volume.z, &requested.z), + ] { + if actual.as_ref().ok_or(Error::Incomplete)?.length_um + != proposed.as_ref().ok_or(Error::Invalid)?.length_um + { + return Err(Error::Conflict); + } + } + let envelope = definition.volume.as_ref().map(volume).transpose()?; + let proposed = volume(p.volume.as_ref().ok_or(Error::Invalid)?)?; + let occupied = enclosures.get_mut(&enclosure_key).ok_or(Error::Corrupt)?; + if crate::physical::placement(envelope, Some(proposed), occupied) + != crate::requirements::Decision::PASS + { + return Err(Error::Conflict); + } + occupied.push(proposed); + } + // Availability permits only the result, never hidden occupants or their demands. + for enclosure_key in enclosures.keys() { + let enclosure = reference(enclosure_key)?; + let definition = self.physical_definition(enclosure)?.ok_or(Error::Incomplete)?; + let limits=[definition.max_mass_g,definition.capacity_mw,definition.cooling_capacity_mw]; + if limits.iter().all(Option::is_none) { continue; } + let parent=format!("allocation/{enclosure_key}"); + let rows=self.query("MATCH (n:OmRow {parent:$parent}) RETURN properties(n) AS p LIMIT 2049",params([("parent",text(parent))]))?; + if rows.len()>2048 { return Err(Error::Limit); } + let mut demands=[Some(0_u64);3]; + let mut add=|item:AssetRef|->Result<()> { + let item=self.physical_definition(item)?.ok_or(Error::Incomplete)?; + for (total,value) in demands.iter_mut().zip([item.mass_g,item.demand_mw,item.cooling_mw]) { + *total=match (*total,value) { + (Some(total),Some(value))=>Some(total.checked_add(value).ok_or(Error::Limit)?), + _=>None, + }; + } + Ok(()) + }; + for row in &rows { add(reference(&string(row,"item")?)?)?; } + for placement in &plan.placements { + if parse_target(placement.enclosure.as_ref())?==enclosure { add(parse_target(placement.item.as_ref())?)?; } + } + for (limit,demand) in limits.into_iter().zip(demands) { + if let Some(limit)=limit { + let demand=demand.ok_or(Error::Incomplete)?; + if demand>limit { return Err(Error::Conflict); } + } + } + } + for p in &plan.placements { + let item = parse_target(p.item.as_ref())?; + let enclosure = parse_target(p.enclosure.as_ref())?; + let mut row = props([ + ("item", text(network::identity(item))), + ("enclosure", text(network::identity(enclosure))), + ("owner", text(&key.id)), + ("state", int(api::PhysicalPlanState::Reserved as u64)?), + ("until", int(until)?), + ]); + write_volume(&mut row, p.volume.as_ref().ok_or(Error::Invalid)?)?; + self.put( + &format!("allocation/{}", network::identity(item)), + &format!("allocation/{}", network::identity(enclosure)), + Class::Pending, + row, + )?; + } + plan.reserved_until_ms = Some(until); + self.physical_state(context, &mut plan, api::PhysicalPlanState::Reserved, now)?; + Ok(plan) + } + pub(super) fn complete_physical( + &mut self, + context: &Context, + value: api::PhysicalCompletion, + working: &api::WorkingContext, + now: u64, + ) -> Result { + let key = value.plan.ok_or(Error::Invalid)?; + let mut plan = self.load_physical_plan(context, &key, working, true)?; + if key.revision == 0 + || !matches!( + api::PhysicalPlanState::try_from(plan.state), + Ok(api::PhysicalPlanState::Reserved | api::PhysicalPlanState::Partial) + ) + || value.installed.len() > 64 + || value.discrepancy.as_ref().is_some_and(|s| s.len() > 2048) + { + return Err(Error::Invalid); + } + let mut seen = BTreeSet::new(); + for value in &value.installed { + let item = parse_target(Some(value))?; + if !seen.insert(item) { + return Err(Error::Invalid); + } + let p = plan + .placements + .iter() + .find(|p| p.item.as_ref() == Some(value)) + .ok_or(Error::Invalid)?; + let enclosure = parse_target(p.enclosure.as_ref())?; + let allocation_key = format!("allocation/{}", network::identity(item)); + let mut row = self.get(&allocation_key)?.ok_or(Error::Conflict)?; + if string(&row, "owner")? != key.id { + return Err(Error::Conflict); + } + if number(&row, "state")? != api::PhysicalPlanState::Completed as u64 { + let relation = wire::Relation { + relation_id: RelationId::generate().to_string(), + from: Some(target_wire(item)), + to: Some(target_wire(enclosure)), + kind: wire::RelationKind::InstalledIn as i32, + revision: 0, + authority_id: self.workspace_authority()?, + }; + let selection = self.selection(context, working, true)?; + self.check_relationship(context, &selection, &relation)?; + self.save_typed_relation(&relation, None, &context.actor, now)?; + row.insert("claim".into(), text(relation.relation_id)); + } + operations::strip_envelope(&mut row); + row.remove("until"); + row.insert("actor".into(), text(&context.actor)); + row.insert( + "state".into(), + int(api::PhysicalPlanState::Completed as u64)?, + ); + row.insert( + "revision".into(), + int(number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?)?, + ); + self.put( + &allocation_key, + &format!("allocation/{}", network::identity(enclosure)), + Class::Inventory, + row, + )?; + } + let mut done = true; + for p in &plan.placements { + let item = parse_target(p.item.as_ref())?; + let row = self + .get(&format!("allocation/{}", network::identity(item)))? + .ok_or(Error::Corrupt)?; + if number(&row, "state")? != api::PhysicalPlanState::Completed as u64 { + done = false; + } + } + if let Some(discrepancy) = value.discrepancy { + let mut row = self + .get(&format!("plan/{}", key.id))? + .ok_or(Error::Corrupt)?; + operations::strip_envelope(&mut row); + row.insert("description".into(), text(discrepancy)); + self.put(&format!("plan/{}", key.id), "plans", Class::Inventory, row)?; + } + self.physical_state( + context, + &mut plan, + if done { + api::PhysicalPlanState::Completed + } else { + api::PhysicalPlanState::Partial + }, + now, + )?; + Ok(plan) + } + pub(super) fn cancel_physical( + &mut self, + context: &Context, + key: &api::PhysicalPlanKey, + working: &api::WorkingContext, + now: u64, + ) -> Result { + if key.revision == 0 { + return Err(Error::Invalid); + } + let mut plan = self.load_physical_plan(context, key, working, true)?; + if plan.state == api::PhysicalPlanState::Cancelled as i32 { + return Ok(plan); + } + // A cancellation cannot assert that reserved real-world work did not happen. + if plan.state != api::PhysicalPlanState::Draft as i32 { + return Err(Error::UnsafeTransition); + } + self.physical_state(context, &mut plan, api::PhysicalPlanState::Cancelled, now)?; + Ok(plan) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/positions.rs b/crates/omloom-store/src/disk/workspace/physical/positions.rs new file mode 100644 index 0000000..399cfff --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/positions.rs @@ -0,0 +1,98 @@ +use super::*; + +fn position_key(owner:AssetRef,structural:&str)->Result { + if structural.is_empty() || structural.len()>128 || !structural.bytes().all(|b|b.is_ascii_alphanumeric()||b"_./:-".contains(&b)) { + return Err(Error::Invalid); + } + Ok(format!("position-key/{}/{structural}",network::identity(owner))) +} +fn position(row:&Properties,owner:AssetRef)->Result { + let kind=i32::try_from(number(row,"vint")?).map_err(|_|Error::Corrupt)?; + api::PhysicalComponentKind::try_from(kind).map_err(|_|Error::Corrupt)?; + Ok(api::PhysicalPosition {target:Some(target_wire(target(row)?)),owner:Some(target_wire(owner)),structural_key:string(row,"basis")?,component_kind:kind,revision:number(row,"revision")?}) +} +impl Tx<'_> { + pub(super) fn save_physical_position(&mut self,context:&Context,value:api::PhysicalPositionEdit,working:&api::WorkingContext,now:u64)->Result { + let owner=parse_target(value.owner.as_ref())?; + self.physical_access(context,owner,working,true)?; + api::PhysicalComponentKind::try_from(value.component_kind).map_err(|_|Error::Invalid)?; + let lookup=position_key(owner,&value.structural_key)?; + let existing=self.get(&lookup)?.map(|row|target(&row)).transpose()?; + let prior=existing.map(|target|self.get(&format!("position/{}",network::identity(target)))).transpose()?.flatten(); + if prior.as_ref().map(|row|number(row,"revision")).transpose()? != value.expected { return Err(Error::Conflict); } + if existing.is_some() && prior.is_none() { return Err(Error::Corrupt); } + if let Some(target)=existing { + self.physical_access(context,target,working,true)?; + if let Some(prior)=&prior { + let root=self.asset(target)?; + if root.get("name").and_then(Value::as_str)==Some(value.name.as_str()) && number(prior,"vint")?==value.component_kind as u64 { + return position(prior,owner); + } + } + } + let parent=self.creation_parent(context,working,Some(&target_wire(owner)),wire::AssetKind::PhysicalPosition)?; + let target=self.workspace_write(context,api::Write { + target:existing.map(target_wire), + expected:existing.map(|target|self.asset(target).and_then(|row|number(&row,"revision"))).transpose()?, + kind:wire::AssetKind::PhysicalPosition as i32, + changes:vec![wire::Field {name:"name".into(),value:Some(field::Value::Text(value.name))}], + ..Default::default() + },now)?; + let revision=number(&self.root,"revision")?.checked_add(1).ok_or(Error::Limit)?; + let mut row=target_props(target); + row.extend(props([("enclosure",text(network::identity(owner))),("basis",text(value.structural_key)),("vint",int(value.component_kind as u64)?),("revision",int(revision)?)])); + self.put(&format!("position/{}",network::identity(target)),&format!("positions/{}",network::identity(owner)),Class::Inventory,row.clone())?; + if existing.is_none() { + self.put(&lookup,"position-keys",Class::Inventory,target_props(target))?; + self.attach_created(context,target,wire::AssetKind::PhysicalPosition,parent,now)?; + } + self.audit(&network::identity(target),&context.actor,"physical-position",revision,now)?; + position(&row,owner) + } + pub(super) fn physical_positions(&self,context:&Context,value:api::PhysicalPositionQuery,working:&api::WorkingContext)->Result<(Vec,Option)> { + let owner=parse_target(value.owner.as_ref())?; + self.physical_access(context,owner,working,false)?; + let limit=if value.limit==0 {25}else{value.limit}; + if limit>25 { return Err(Error::Limit); } + let after=value.after.as_deref().unwrap_or(""); + if after.len()>256 { return Err(Error::Invalid); } + let mut values=HashMap::from([("parent".into(),text(format!("positions/{}",network::identity(owner)))),("after".into(),text(after)),("limit".into(),int(u64::from(limit)+1)?)]); + let visible=assets::visible_clause_for(context,Some("physical"),&mut values,"a"); + let rows=self.query(&format!("MATCH (p:OmRow {{parent:$parent}}) WITH p, 'asset/'+p.asset AS key MATCH (a:OmRow {{key:key}}) WHERE a.incarnation=p.incarnation AND p.key>$after AND {visible} RETURN properties(p) AS p ORDER BY p.key LIMIT $limit"),Some(values))?; + let mut output=Vec::with_capacity(rows.len().min(limit as usize)); + for row in rows.iter().take(limit as usize) { output.push(position(row,owner)?); } + let next=if rows.len()>limit as usize { Some(string(&rows[limit as usize-1],"key")?) }else{None}; + Ok((output,next)) + } + pub(super) fn physical_labels(&self,context:&Context,view:&mut api::PhysicalView)->Result<()> { + let mut targets=BTreeSet::new(); + for position in &view.positions { + targets.insert(parse_target(position.target.as_ref())?); + targets.insert(parse_target(position.owner.as_ref())?); + } + for placement in view.plan.iter().flat_map(|p|p.placements.iter()).chain(view.occupancy.iter().filter_map(|o|o.placement.as_ref())) { + targets.insert(parse_target(placement.item.as_ref())?); + targets.insert(parse_target(placement.enclosure.as_ref())?); + } + for replacement in view.plan.iter().flat_map(|p|p.replacements.iter()) { + for item in [replacement.position.as_ref(),replacement.source_position.as_ref(),replacement.previous.as_ref(),replacement.replacement.as_ref()].into_iter().flatten() { + targets.insert(parse_target(Some(item))?); + } + } + if targets.is_empty() { return Ok(()); } + if targets.len()>256 { return Err(Error::Limit); } + let mut values=HashMap::from([("keys".into(),Value::Array(targets.iter().map(|t|text(asset_key(*t))).collect()))]); + let visible=assets::visible_clause_for(context,None,&mut values,"a"); + let rows=self.query(&format!("UNWIND $keys AS key MATCH (a:OmRow {{key:key}}) WHERE {visible} RETURN properties(a) AS p LIMIT 256"),Some(values))?; + for row in rows { + let target=target(&row)?; + if !targets.contains(&target) { continue; } + let kind=i32::try_from(number(&row,"kind")?).map_err(|_|Error::Corrupt)?; + let fields=if context.field(target,kind,"name",false) { + row.get("name").and_then(Value::as_str).map(|value|wire::Field {name:"name".into(),value:Some(field::Value::Text(value.into()))}).into_iter().collect() + }else{Vec::new()}; + view.items.push(api::Item { target:Some(target_wire(target)),kind,revision:number(&row,"revision")?,fields }); + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/replacements.rs b/crates/omloom-store/src/disk/workspace/physical/replacements.rs new file mode 100644 index 0000000..11b1e2b --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/replacements.rs @@ -0,0 +1,66 @@ +use super::*; + +pub(super) fn replacement_row(value:&api::PhysicalReplacement)->Result { + let mut row=props([("enclosure",text(network::identity(parse_target(value.position.as_ref())?))),("state",int(if value.completed {api::PhysicalPlanState::Completed as u64}else{api::PhysicalPlanState::Draft as u64})?)]); + if let Some(item)=&value.previous {row.insert("old_item".into(),text(network::identity(parse_target(Some(item))?)));} + if let Some(item)=&value.replacement {row.insert("item".into(),text(network::identity(parse_target(Some(item))?)));} + if let Some(position)=&value.source_position {row.insert("source".into(),text(network::identity(parse_target(Some(position))?)));} + if let Some(binding)=&value.previous_binding {row.insert("claim".into(),text(binding));} + if let Some(revision)=value.previous_revision {row.insert("expected".into(),int(revision)?);} + Ok(row) +} +pub(super) fn read_replacement(row:&Properties)->Result { + let reference=|name:&str|->Result>{row.get(name).map(|_|string(row,name).and_then(|s|lifecycle::reference(&s)).map(target_wire)).transpose()}; + Ok(api::PhysicalReplacement { + position:Some(target_wire(lifecycle::reference(&string(row,"enclosure")?)?)), + previous:reference("old_item")?,replacement:reference("item")?,source_position:reference("source")?, + previous_binding:row.get("claim").map(|_|string(row,"claim")).transpose()?, + previous_revision:row.get("expected").map(|_|number(row,"expected")).transpose()?, + completed:number(row,"state")?==api::PhysicalPlanState::Completed as u64, + }) +} +impl Tx<'_> { + pub(super) fn replacement_access(&self,context:&Context,value:&api::PhysicalReplacement,working:&api::WorkingContext,write:bool)->Result<()> { + for reference in [value.position.as_ref(),value.source_position.as_ref(),value.previous.as_ref(),value.replacement.as_ref()].into_iter().flatten() { + let target=parse_target(Some(reference))?; + self.physical_access(context,target,working,write)?; + let kind=i32::try_from(number(&self.asset(target)?,"kind")?).map_err(|_|Error::Corrupt)?; + if !context.field(target,kind,"relations",false) || (write && !context.field(target,kind,"relations",true)) {return Err(Error::Denied);} + } + Ok(()) + } + pub(super) fn check_replacements(&self,context:&Context,plan:&api::PhysicalPlan,working:&api::WorkingContext,write:bool)->Result<()> { + let mut positions=BTreeSet::new();let mut parts=BTreeSet::new(); + for replacement in &plan.replacements { + self.replacement_access(context,replacement,working,write)?; + let position=parse_target(replacement.position.as_ref())?; + if !positions.insert(position) || replacement.completed || (replacement.previous.is_none() && replacement.replacement.is_none()) {return Err(Error::Invalid);} + for item in [replacement.previous.as_ref(),replacement.replacement.as_ref()].into_iter().flatten() { + let item=parse_target(Some(item))?; + let kind=wire::AssetKind::try_from(number(&self.asset(item)?,"kind")? as i32).map_err(|_|Error::Corrupt)?; + if item==position || !matches!(kind,wire::AssetKind::PhysicalComponent|wire::AssetKind::Device) {return Err(Error::Invalid);} + } + if let Some(item)=&replacement.replacement { + if !parts.insert(parse_target(Some(item))?) {return Err(Error::Invalid);} + } + let position_kind=wire::AssetKind::try_from(number(&self.asset(position)?,"kind")? as i32).map_err(|_|Error::Corrupt)?; + if !matches!(position_kind,wire::AssetKind::PhysicalPosition|wire::AssetKind::Rack|wire::AssetKind::Location|wire::AssetKind::Site) {return Err(Error::Invalid);} + match (&replacement.previous_binding,replacement.previous_revision) { + (Some(id),Some(revision)) if replacement.previous.is_some() && revision!=0 => {RelationId::parse(id).map_err(|_|Error::Invalid)?;}, + (None,None)=>{}, + _=>return Err(Error::Invalid), + } + if replacement.source_position.is_some() && replacement.previous.is_none() {return Err(Error::Invalid);} + } + Ok(()) + } + pub(super) fn replacement_basis(&self,replacement:&api::PhysicalReplacement)->Result> { + let Some(previous)=replacement.previous.as_ref() else {return Ok(None);}; + let id=RelationId::parse(replacement.previous_binding.as_ref().ok_or(Error::Incomplete)?).map_err(|_|Error::Invalid)?; + let row=self.get(&format!("relation/{id}"))?.ok_or(Error::Conflict)?; + let relation=inventory::relation_projection(&row)?; + let source=replacement.source_position.as_ref().or(replacement.position.as_ref()); + if relation.from.as_ref()!=Some(previous) || relation.to.as_ref()!=source || Some(relation.revision)!=replacement.previous_revision || relation.kind!=wire::RelationKind::InstalledIn as i32 {return Err(Error::Conflict);} + Ok(Some(relation)) + } +} diff --git a/crates/omloom-store/src/disk/workspace/working.rs b/crates/omloom-store/src/disk/workspace/working.rs index b6bd85d..5c428b5 100644 --- a/crates/omloom-store/src/disk/workspace/working.rs +++ b/crates/omloom-store/src/disk/workspace/working.rs @@ -36,7 +36,7 @@ fn writable_fields( &["name", "description", "dns_name"] } else if kind == K::SoftwareInstallation { &["name", "description", "virtualization_role"] - } else if matches!(kind, K::Device | K::Rack | K::Port | K::Cable) { + } else if matches!(kind, K::Device | K::Rack | K::Port | K::Cable | K::PhysicalComponent) { &["name", "description", "serial"] } else { &["name", "description"] @@ -46,7 +46,7 @@ fn writable_fields( .filter(|name| context.field(target, kind as i32, name, true)) .map(|s| s.to_string()) .collect(); - if kind == K::Device { + if matches!(kind,K::Device|K::PhysicalComponent) { fields.extend( omloom_contracts::equipment::FIELDS .iter() @@ -177,7 +177,9 @@ pub(super) fn attachment( (K::Service, K::SoftwareInstallation) => Some((R::Contains, false)), (K::Service, K::ProviderAccount) => Some((R::Contains, false)), (K::Device, K::Port) => Some((R::HasPort, false)), - (K::Port, K::Interface) => Some((R::Carries, false)), + (K::PhysicalComponent, K::Port) => Some((R::HasPort, false)), + (K::Device | K::Rack | K::Port | K::PhysicalComponent, K::PhysicalPosition) => Some((R::Contains, false)), + (K::PhysicalPosition, K::PhysicalComponent) => Some((R::InstalledIn, true)), (K::Device, K::VirtualMachine | K::Service) | (K::VirtualMachine, K::Service) => { Some((R::RunsOn, true)) } @@ -661,6 +663,7 @@ impl Store { K::SoftwareRelease, K::Workload, K::Container, + K::PhysicalComponent, ] { let raw = kind as i32; if let Some(parent) = &parent_item { diff --git a/crates/omloom-store/src/lib.rs b/crates/omloom-store/src/lib.rs index 17e9c1f..55ffd52 100644 --- a/crates/omloom-store/src/lib.rs +++ b/crates/omloom-store/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; +pub mod physical; pub mod requirements; /// Independently bounded data classes. Recovery checkpoints never expire with detail. diff --git a/crates/omloom-store/src/physical.rs b/crates/omloom-store/src/physical.rs new file mode 100644 index 0000000..40c3a57 --- /dev/null +++ b/crates/omloom-store/src/physical.rs @@ -0,0 +1,131 @@ +//! Integer physical constraints shared by planning and final admission. +//! Geometry proves only the supplied constraints, never authority or live health. +use crate::requirements::{Decision, Reason}; + +/// Micrometres. Signed coordinates permit an explicit clearance outside a chassis. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Span { + start: i64, + end: i64, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Invalid { + Empty, + Overflow, + Reversed, + Limit, +} +impl Span { + pub fn new(start: i64, length: u64) -> Result { + if length == 0 { + return Err(Invalid::Empty); + } + let length = i64::try_from(length).map_err(|_| Invalid::Overflow)?; + let end = start.checked_add(length).ok_or(Invalid::Overflow)?; + Ok(Self { start, end }) + } + pub fn start(self) -> i64 { + self.start + } + pub fn end(self) -> i64 { + self.end + } + pub fn overlaps(self, other: Self) -> bool { + self.start < other.end && other.start < self.end + } + pub fn contains(self, other: Self) -> bool { + self.start <= other.start && other.end <= self.end + } + pub fn expand(self, before: u64, after: u64) -> Result { + let before = i64::try_from(before).map_err(|_| Invalid::Overflow)?; + let after = i64::try_from(after).map_err(|_| Invalid::Overflow)?; + Ok(Self { + start: self.start.checked_sub(before).ok_or(Invalid::Overflow)?, + end: self.end.checked_add(after).ok_or(Invalid::Overflow)?, + }) + } +} +/// A half-open physical volume in its owning enclosure's coordinate system. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Volume { + pub x: Span, + pub y: Span, + pub z: Span, +} +impl Volume { + pub fn overlaps(self, other: Self) -> bool { + self.x.overlaps(other.x) && self.y.overlaps(other.y) && self.z.overlaps(other.z) + } + pub fn contains(self, other: Self) -> bool { + self.x.contains(other.x) && self.y.contains(other.y) && self.z.contains(other.z) + } +} +/// A sourced interval, retaining uncertainty instead of averaging it away. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Quantity { + lower: u64, + upper: u64, +} +impl Quantity { + pub fn new(lower: u64, upper: u64) -> Result { + if lower > upper { + return Err(Invalid::Reversed); + } + Ok(Self { lower, upper }) + } + pub fn lower(self) -> u64 { + self.lower + } + pub fn upper(self) -> u64 { + self.upper + } + pub fn checked_sum(self, other: Self) -> Result { + Self::new( + self.lower + .checked_add(other.lower) + .ok_or(Invalid::Overflow)?, + self.upper + .checked_add(other.upper) + .ok_or(Invalid::Overflow)?, + ) + } +} +/// Capacity and demand must already use the same explicit unit and measurement kind. +pub fn capacity(capacity: Option, demand: Option) -> Decision { + let (Some(capacity), Some(demand)) = (capacity, demand) else { + return Decision::unknown(Reason::Missing); + }; + if demand.upper <= capacity.lower { + return Decision::PASS; + } + if demand.lower > capacity.upper { + return Decision::FAIL; + } + Decision::unknown(Reason::Incomplete) +} +/// Compare one candidate with an already bounded set of occupied/reserved volumes. +/// The storage owner supplies authoritative occupants, including hidden ones. +pub fn placement( + enclosure: Option, + candidate: Option, + occupied: &[Volume], +) -> Decision { + if occupied.len() > 2048 { + return Decision::unknown(Reason::Unsupported); + } + let (Some(enclosure), Some(candidate)) = (enclosure, candidate) else { + return Decision::unknown(Reason::Missing); + }; + if !enclosure.contains(candidate) { + return Decision::FAIL; + } + for volume in occupied { + if candidate.overlaps(*volume) { + return Decision::FAIL; + } + } + Decision::PASS +} + +#[cfg(test)] +mod tests; diff --git a/crates/omloom-store/src/physical/tests.rs b/crates/omloom-store/src/physical/tests.rs new file mode 100644 index 0000000..fc2ece0 --- /dev/null +++ b/crates/omloom-store/src/physical/tests.rs @@ -0,0 +1,91 @@ +use super::*; + +fn volume(x: i64, y: i64, z: i64, width: u64, height: u64, depth: u64) -> Volume { + Volume { + x: Span::new(x, width).unwrap(), + y: Span::new(y, height).unwrap(), + z: Span::new(z, depth).unwrap(), + } +} + +// A rear device may share U space only when its real depth does not intersect. +#[test] +fn opposing_faces_and_half_width_use_actual_volumes() { + let rack = volume(0, 0, 0, 500_000, 2_000_000, 1_000_000); + let front = volume(0, 0, 0, 250_000, 44_450, 400_000); + let rear = volume(0, 0, 600_000, 500_000, 44_450, 400_000); + let sibling = volume(250_000, 0, 0, 250_000, 44_450, 400_000); + assert_eq!(placement(Some(rack), Some(rear), &[front]), Decision::PASS); + assert_eq!( + placement(Some(rack), Some(sibling), &[front]), + Decision::PASS + ); + let full = volume(0, 0, 0, 500_000, 44_450, 1_000_000); + assert_eq!(placement(Some(rack), Some(rear), &[full]), Decision::FAIL); +} + +// Service clearance and zero-U obstruction are volumes, not special exemptions. +#[test] +fn clearance_and_zero_u_obstructions_conflict() { + let rack = volume(-50_000, 0, 0, 600_000, 2_000_000, 1_000_000); + let pdu = volume(0, 0, 950_000, 50_000, 1_800_000, 50_000); + let device = volume(0, 0, 0, 500_000, 44_450, 900_000); + assert_eq!(placement(Some(rack), Some(device), &[pdu]), Decision::PASS); + let service = Volume { + z: device.z.expand(0, 100_000).unwrap(), + ..device + }; + assert_eq!(placement(Some(rack), Some(service), &[pdu]), Decision::FAIL); +} + +// Unknown dimensions cannot become a positive fit or an assumed zero load. +#[test] +fn missing_geometry_and_uncertain_capacity_remain_unknown() { + let rack = volume(0, 0, 0, 500_000, 2_000_000, 1_000_000); + assert_eq!( + placement(Some(rack), None, &[]), + Decision::unknown(Reason::Missing) + ); + assert_eq!( + capacity(None, Some(Quantity::new(0, 10).unwrap())), + Decision::unknown(Reason::Missing) + ); + assert_eq!( + capacity( + Some(Quantity::new(100, 120).unwrap()), + Some(Quantity::new(90, 110).unwrap()) + ), + Decision::unknown(Reason::Incomplete) + ); + assert_eq!( + capacity( + Some(Quantity::new(100, 120).unwrap()), + Some(Quantity::new(121, 130).unwrap()) + ), + Decision::FAIL + ); + assert_eq!( + capacity( + Some(Quantity::new(100, 120).unwrap()), + Some(Quantity::new(90, 100).unwrap()) + ), + Decision::PASS + ); +} + +// Invalid units/ranges and arithmetic overflow are rejected before geometry work. +#[test] +fn coordinate_and_capacity_boundaries_are_checked() { + assert_eq!(Span::new(0, 0), Err(Invalid::Empty)); + assert_eq!(Span::new(i64::MAX, 1), Err(Invalid::Overflow)); + assert_eq!(Quantity::new(2, 1), Err(Invalid::Reversed)); + assert_eq!( + Quantity::new(u64::MAX, u64::MAX) + .unwrap() + .checked_sum(Quantity::new(1, 1).unwrap()), + Err(Invalid::Overflow) + ); + let first = Span::new(-100, 100).unwrap(); + assert!(!first.overlaps(Span::new(0, 100).unwrap())); + assert!(first.overlaps(Span::new(-1, 100).unwrap())); +} diff --git a/crates/omloom-store/tests/workspace.rs b/crates/omloom-store/tests/workspace.rs index a844a82..7c1547b 100644 --- a/crates/omloom-store/tests/workspace.rs +++ b/crates/omloom-store/tests/workspace.rs @@ -24,6 +24,8 @@ mod groups; mod host_import; #[path = "workspace/host_network.rs"] mod host_network; +#[path = "workspace/physical.rs"] +mod physical; #[path = "workspace/projections.rs"] mod projections; #[path = "workspace/requirements.rs"] diff --git a/crates/omloom-store/tests/workspace/physical.rs b/crates/omloom-store/tests/workspace/physical.rs new file mode 100644 index 0000000..9c86f7b --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical.rs @@ -0,0 +1,427 @@ +use super::*; +use api::{reply::Result as Reply, request::Command}; + +fn volume() -> api::PhysicalVolume { + let extent = |length| { + Some(api::PhysicalExtent { + start_um: 0, + length_um: length, + }) + }; + api::PhysicalVolume { + x: extent(500_000), + y: extent(2_000_000), + z: extent(1_000_000), + } +} +fn define(target: wire::AssetRef) -> api::PhysicalDefinition { + api::PhysicalDefinition { + target: Some(target), + volume: Some(volume()), + basis: "Measured mounting envelope".into(), + ..Default::default() + } +} +fn physical( + store: &Store, + actor: &Context, + action: api::physical::Action, +) -> Result { + match call( + store, + actor, + Command::Physical(api::Physical { + action: Some(action), + }), + )? { + Reply::Physical(value) => Ok(value), + _ => Err(Error::Corrupt), + } +} + +// Current facts survive reopen; repeated input and read-only inspection do not grow state. +#[test] +fn physical_definition_is_current_bounded_and_durable() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Measured rack")?; + let definition = define(rack.clone()); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition.clone()), + }), + )?; + let before = store.usage()?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition.clone()), + }), + )?; + let state = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + }), + )?; + assert_eq!(state.definitions, vec![definition.clone()]); + assert_eq!(store.usage()?, before); + drop(store); + let store = Store::open(dir.path(), policy())?; + let state = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + }), + )?; + assert_eq!(state.definitions, vec![definition]); + Ok(()) +} + +// A physical-field denial protects both predicate inputs and mutation, independently of names. +#[test] +fn physical_field_denial_precedes_decoding_and_writes() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Protected rack")?; + let mut reader = actor.clone(); + reader.grants[0].read = Fields::Selected(vec!["name".into()]); + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &reader, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()) + }) + ), + Err(Error::Denied) + )); + assert!(matches!( + physical( + &store, + &reader, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack)) + }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Draft authoring requires the actual write grant; a preview alone grants no saved plan. +#[test] +fn read_only_actor_cannot_save_physical_draft() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + let mut reader = actor.clone(); + reader.grants[0].writable = false; + let plan = api::PhysicalPlan { + name: "Proposed placement".into(), + placements: vec![api::PhysicalPlacement { + item: Some(item), + enclosure: Some(rack), + volume: Some(volume()), + }], + ..Default::default() + }; + let before = store.usage()?; + physical( + &store, + &reader, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert!(matches!( + physical( + &store, + &reader, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +fn draft( + store: &Store, + actor: &Context, + item: wire::AssetRef, + rack: wire::AssetRef, +) -> Result { + physical( + store, + actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(api::PhysicalPlan { + name: "Install server".into(), + placements: vec![api::PhysicalPlacement { + item: Some(item), + enclosure: Some(rack), + volume: Some(volume()), + }], + ..Default::default() + }), + }), + )? + .plan + .ok_or(Error::Corrupt) +} +fn reserve( + store: &Store, + actor: &Context, + plan: api::PhysicalPlan, +) -> Result { + physical( + store, + actor, + api::physical::Action::Reserve(api::PhysicalReserve { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + until_ms: 1000, + }), + ) +} + +// A serialized spare cannot be reserved in two distinct enclosures, even with free geometry. +#[test] +fn serialized_item_reservation_is_exclusive_across_enclosures() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::Rack, "Rack A")?; + let b = create(&store, &actor, K::Rack, "Rack B")?; + for target in [&a, &b] { + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(target.clone())), + }), + )?; + } + let item = create(&store, &actor, K::Device, "One serialized spare")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(item.clone())), + }), + )?; + let first = draft(&store, &actor, item.clone(), a)?; + let second = draft(&store, &actor, item, b)?; + reserve(&store, &actor, first)?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, second), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Manual completion couples occupancy and the existing containment relation in one transaction. +#[test] +fn completion_preserves_actual_occupancy_and_reuses_inventory_containment() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(item.clone())), + }), + )?; + let plan = draft(&store, &actor, item.clone(), rack.clone())?; + let reserved = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let done = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: reserved.id.ok_or(Error::Corrupt)?, + revision: reserved.revision, + }), + installed: vec![item.clone()], + discrepancy: None, + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(done.state, api::PhysicalPlanState::Completed as i32); + let before = store.usage()?; + let view = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + }), + )?; + assert_eq!(view.occupancy.len(), 1); + assert_eq!( + view.occupancy[0] + .placement + .as_ref() + .and_then(|p| p.item.as_ref()), + Some(&item) + ); + assert_eq!(store.usage()?, before); + let relationships = store.relations(AssetRef::try_from(&item)?, 16)?; + assert!( + relationships + .iter() + .any(|r| r.kind == wire::RelationKind::InstalledIn as i32 + && r.to.as_ref() == Some(&rack)) + ); + drop(store); + let store = Store::open(dir.path(), policy())?; + let view = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + }), + )?; + assert_eq!(view.occupancy.len(), 1); + // Expiring terminal plan details cannot free the authoritative installed footprint. + assert!(store.cleanup(5000, None)?.removed > 0); + let view = physical(&store, &actor, api::physical::Action::Read(api::PhysicalRead { enclosure: view.occupancy[0].placement.as_ref().and_then(|p|p.enclosure.clone()) }))?; + assert_eq!(view.occupancy.len(), 1); + Ok(()) +} + +// Saved-plan discovery is owner-scoped and does not hydrate resources or write metadata. +#[test] +fn saved_plan_list_is_personal_bounded_and_read_only() -> TestResult { + let dir=tempfile::tempdir()?; + let store=Store::open(dir.path(),policy())?; + let actor=operator(); + let rack=create(&store,&actor,K::Rack,"Rack")?; + let item=create(&store,&actor,K::Device,"Equipment")?; + let plan=draft(&store,&actor,item,rack)?; + let before=store.usage()?; + let list=physical(&store,&actor,api::physical::Action::List(api::PhysicalPlanList {limit:1,after:None}))?; + assert_eq!(list.plans.len(),1); + assert_eq!(Some(&list.plans[0].id),plan.id.as_ref()); + assert!(list.plan.is_none()); + assert!(list.occupancy.is_empty()); + let mut other=actor.clone();other.actor="another-operator".into(); + assert!(physical(&store,&other,api::physical::Action::List(api::PhysicalPlanList::default()))?.plans.is_empty()); + assert!(matches!(physical(&store,&actor,api::physical::Action::List(api::PhysicalPlanList {limit:26,after:None})),Err(Error::Limit))); + assert_eq!(store.usage()?,before); + Ok(()) +} + +// Geometry cannot admit a placement whose sourced aggregate mass exceeds its enclosure limit. +#[test] +fn reservation_enforces_declared_mass_power_and_cooling_limits() -> TestResult { + for axis in 0..3 { + let dir=tempfile::tempdir()?; + let store=Store::open(dir.path(),policy())?; + let actor=operator(); + let rack=create(&store,&actor,K::Rack,"Capacity-limited rack")?; + let item=create(&store,&actor,K::Device,"Demanding equipment")?; + let mut enclosure=define(rack.clone()); + let mut equipment=define(item.clone()); + match axis { + 0=>{enclosure.max_mass_g=Some(1000);equipment.mass_g=Some(2000);}, + 1=>{enclosure.capacity_mw=Some(1000);equipment.demand_mw=Some(2000);}, + _=>{enclosure.cooling_capacity_mw=Some(1000);equipment.cooling_mw=Some(2000);}, + } + for definition in [enclosure,equipment] { + physical(&store,&actor,api::physical::Action::Define(api::PhysicalDefinitionEdit { definition:Some(definition) }))?; + } + let plan=draft(&store,&actor,item,rack)?; + let before=store.usage()?; + assert!(matches!(reserve(&store,&actor,plan),Err(Error::Conflict)),"Capacity axis {axis} must reject admission"); + assert_eq!(store.usage()?,before); + } + Ok(()) +} + +// Structural keys identify positions only inside their actual owner's incarnation, not occupants. +#[test] +fn structural_positions_are_scoped_current_and_not_populated_implicitly() -> TestResult { + let dir=tempfile::tempdir()?;let store=Store::open(dir.path(),policy())?;let actor=operator(); + let first=create(&store,&actor,K::Device,"First chassis")?; + let second=create(&store,&actor,K::Device,"Second chassis")?; + let edit=api::PhysicalPositionEdit {owner:Some(first.clone()),structural_key:"psu/1".into(),name:"PSU bay 1".into(),component_kind:api::PhysicalComponentKind::PowerSupply as i32,expected:None}; + let created=physical(&store,&actor,api::physical::Action::Position(edit.clone()))?; + let position=&created.positions[0]; + let before=store.usage()?; + let mut again=edit.clone();again.expected=Some(position.revision); + assert_eq!(physical(&store,&actor,api::physical::Action::Position(again))?.positions,created.positions); + assert_eq!(store.usage()?,before); + let other=physical(&store,&actor,api::physical::Action::Position(api::PhysicalPositionEdit {owner:Some(second),..edit}))?; + assert_ne!(other.positions[0].target,position.target); + let list=physical(&store,&actor,api::physical::Action::Positions(api::PhysicalPositionQuery {owner:Some(first),after:None,limit:25}))?; + assert_eq!(list.positions,created.positions); + assert_eq!(list.items.iter().filter(|i|i.target==position.target).count(),1); + let read=physical(&store,&actor,api::physical::Action::Read(api::PhysicalRead {enclosure:position.target.clone()}))?; + assert!(read.occupancy.is_empty()); + assert!(matches!(create(&store,&actor,K::PhysicalPosition,"Unscoped invented bay"),Err(Error::Invalid))); + Ok(()) +} + +// A hardware realization is a peer binding, not the owner of OS interface/configuration intent. +#[test] +fn logical_interface_does_not_become_a_child_of_its_hardware_port() -> TestResult { + let dir=tempfile::tempdir()?;let store=Store::open(dir.path(),policy())?;let actor=operator(); + let port=create(&store,&actor,K::Port,"Current physical connector")?; + let interface=create(&store,&actor,K::Interface,"Surviving logical interface")?; + call(&store,&actor,Command::Relation(api::Relationship {relation:Some(wire::Relation {from:Some(port.clone()),to:Some(interface),kind:wire::RelationKind::Carries as i32,..Default::default()}),remove:false}))?; + let Reply::Page(page)=call(&store,&actor,Command::Query(api::Query {view:api::View::Network as i32,focus:Some(port),level_only:true,columns:vec!["name".into()],limit:25,..Default::default()}))? else { return Err("expected page".into()); }; + assert_eq!(page.total,0,"Removing a hardware realization must not remove or scope-own logical configuration"); + Ok(()) +} + +// Re-saving an unchanged accepted draft must not create a new revision or audit record. +#[test] +fn unchanged_saved_draft_is_a_noop() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + let plan = draft(&store, &actor, item, rack)?; + let before = store.usage()?; + let unchanged = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(plan.clone()), + }), + )?; + assert!(!unchanged.items.is_empty(),"A no-op save retains the same permitted subject labels as a changed save"); + let same=unchanged.plan.ok_or(Error::Corrupt)?; + assert_eq!(same, plan); + assert_eq!(store.usage()?, before); + Ok(()) +} diff --git a/package.json b/package.json index 44e680f..06fb434 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "generate": "protoc --proto_path=proto --plugin=protoc-gen-ts=node_modules/.bin/protoc-gen-ts --ts_out=web/src/generated --ts_opt=long_type_bigint,force_optimize_code_size,force_client_none proto/omloom/v1/contracts.proto proto/omloom/v1/workspace.proto", "typecheck": "tsc --noEmit", "build": "tsc && esbuild web/src/browser-corpus.ts --bundle --format=iife --outfile=dist/browser-corpus.js", - "test:contracts": "node --test web/tests/contracts.test.mjs web/tests/presentation.test.mjs web/tests/field-host.test.mjs web/tests/host-report.test.mjs web/tests/docker-template.test.mjs", + "test:contracts": "node --test web/tests/contracts.test.mjs web/tests/presentation.test.mjs web/tests/field-host.test.mjs web/tests/host-report.test.mjs web/tests/docker-template.test.mjs web/tests/physical-units.test.mjs", "test:browser": "playwright test", "clean": "node scripts/clean.mjs", "build:app": "node scripts/check-vue.cjs --noEmit -p web/app/tsconfig.json && vite build --config web/app/vite.config.ts" diff --git a/proto/omloom/v1/contracts.proto b/proto/omloom/v1/contracts.proto index 2b1d8ce..abfc5e9 100644 --- a/proto/omloom/v1/contracts.proto +++ b/proto/omloom/v1/contracts.proto @@ -49,6 +49,10 @@ enum AssetKind { ASSET_KIND_WORKLOAD = 27; // Concrete container lifetime; a name, image or published port is not its identity. ASSET_KIND_CONTAINER = 28; + // Adopted movable physical part, independent of its present bay, label or serial evidence. + ASSET_KIND_PHYSICAL_COMPONENT = 29; + // Instantiated structural position scoped to its actual owning component incarnation. + ASSET_KIND_PHYSICAL_POSITION = 30; } // Scoped provider identity is matching evidence, never authority. diff --git a/proto/omloom/v1/workspace.proto b/proto/omloom/v1/workspace.proto index f283502..21afc66 100644 --- a/proto/omloom/v1/workspace.proto +++ b/proto/omloom/v1/workspace.proto @@ -1505,6 +1505,259 @@ message RequirementEvaluation { repeated RequirementResult results = 5; } +// Half-open physical extent in integer micrometres in its owner's coordinates. +message PhysicalExtent { + // User-supplied signed origin, allowing documented external service clearance. + sint64 start_um = 1; + // Positive physical length; zero is invalid, not an unknown measurement. + uint64 length_um = 2; +} +message PhysicalVolume { + // Horizontal extent in the owning enclosure's coordinate system. + PhysicalExtent x = 1; + // Vertical extent; one rack unit is exactly 44450 micrometres. + PhysicalExtent y = 2; + // Front-to-rear extent; opposite faces share the same coordinates. + PhysicalExtent z = 3; +} +// Current explicit private/unmatched physical facts, not a second model catalogue. +message PhysicalDefinition { + // Existing resource incarnation whose current physical facts are being refined. + omloom.v1.AssetRef target = 1; + // User-selected external envelope or usable enclosure volume, absent if unknown. + optional PhysicalVolume volume = 2; + // Source/instruction reference supplied by the operator, max 512 bytes; no secrets. + string basis = 3; + // Optional sourced mass in grams. Missing is unknown and must not be summed as zero. + optional uint64 mass_g = 4; + // Optional permitted supported mass in grams, independently of actual equipment mass. + optional uint64 max_mass_g = 5; + // Optional design power requirement in milliwatts, not a measured load. + optional uint64 demand_mw = 6; + // Optional design power budget in milliwatts, not proof of independent feeds. + optional uint64 capacity_mw = 7; + // Optional design cooling demand in milliwatts, independent of power measurements. + optional uint64 cooling_mw = 8; + // Optional qualified cooling capacity in milliwatts; missing is unknown. + optional uint64 cooling_capacity_mw = 9; + // Explicit rail/attachment standard, max 128 bytes; missing means unqualified fit. + optional string mounting = 10; +} +message PhysicalDefinitionEdit { + // User-supplied current definition, validated under resource/field permissions. + PhysicalDefinition definition = 1; +} +enum PhysicalComponentKind { + PHYSICAL_COMPONENT_KIND_UNSPECIFIED = 0; + PHYSICAL_COMPONENT_KIND_CHASSIS = 1; + PHYSICAL_COMPONENT_KIND_TRANSCEIVER = 2; + PHYSICAL_COMPONENT_KIND_DISK = 3; + PHYSICAL_COMPONENT_KIND_POWER_SUPPLY = 4; + PHYSICAL_COMPONENT_KIND_NETWORK_ADAPTER = 5; + PHYSICAL_COMPONENT_KIND_LINE_CARD = 6; + PHYSICAL_COMPONENT_KIND_DOCK = 7; + PHYSICAL_COMPONENT_KIND_ASSEMBLY = 8; +} +message PhysicalPositionEdit { + // Selected actual owner; replacing that owner creates new structural positions. + omloom.v1.AssetRef owner = 1; + // User/source-qualified immutable structural token, 1-128 ASCII token bytes. + // Labels, serials and enumeration indices alone do not establish this key. + string structural_key = 2; + // User-visible bounded label, independent of structural identity. + string name = 3; + // Kind of removable part admitted by this position, unspecified means unknown. + PhysicalComponentKind component_kind = 4; + // Exact current position revision for an edit; absent means explicit creation. + optional uint64 expected = 5; +} +message PhysicalPosition { + // Server-generated stable position identity, scoped structurally by its owner. + omloom.v1.AssetRef target = 1; + // Current actual owning incarnation, not the presently removable occupant. + omloom.v1.AssetRef owner = 2; + // Immutable structural identity within the actual owning incarnation. + string structural_key = 3; + // Current declared part class; it does not prove electrical/media compatibility. + PhysicalComponentKind component_kind = 4; + // Accepted position revision for a reviewed mutation. + uint64 revision = 5; +} +message PhysicalPositionQuery { + // Explicit actual owner whose permitted direct positions are requested. + omloom.v1.AssetRef owner = 1; + // Previous keyset continuation, absent on the first page; never an authority grant. + optional string after = 2; + // Requested page size, zero selects 25 and values above 25 are rejected. + uint32 limit = 3; +} +message PhysicalPlacement { + // Existing adopted part/device incarnation, not a model identifier or future serial. + omloom.v1.AssetRef item = 1; + // Existing receiving rack/enclosure incarnation; parent authority is checked separately. + omloom.v1.AssetRef enclosure = 2; + // Proposed position/envelope in receiving enclosure coordinates. Admission + // checks dimensions against the item's current sourced definition; this input + // cannot shrink actual equipment to make it fit. Unknown dimensions stay unvalidated. + PhysicalVolume volume = 3; +} +message PhysicalReplacement { + // Selected surviving position or actual enclosure for a chassis replacement. + omloom.v1.AssetRef position = 1; + // Previously established part; absence leaves the predecessor unresolved, + // never implicitly asserts that the position is empty. + optional omloom.v1.AssetRef previous = 2; + // Explicit new adopted part; absence records removal. Equal previous/replacement + // records a same-part reseat, preserving the part's identity. + optional omloom.v1.AssetRef replacement = 3; + // Exact ordinary InstalledIn relation reviewed for previous, required with previous. + optional string previous_binding = 4; + // Revision of that binding, never a serial/name/time-based identity guess. + optional uint64 previous_revision = 5; + // Explicit old position when moving an established part or staging a chassis + // elsewhere; omission selects the same position as the destination. + optional omloom.v1.AssetRef source_position = 6; + // Server-derived attributed completion, never accepted as input to a saved draft. + bool completed = 7; +} +enum PhysicalPlanState { + PHYSICAL_PLAN_STATE_UNSPECIFIED = 0; + PHYSICAL_PLAN_STATE_DRAFT = 1; + PHYSICAL_PLAN_STATE_RESERVED = 2; + PHYSICAL_PLAN_STATE_PARTIAL = 3; + PHYSICAL_PLAN_STATE_COMPLETED = 4; + PHYSICAL_PLAN_STATE_CANCELLED = 5; +} +message PhysicalPlan { + // Server-assigned identity on explicit save; omitted for a new transient preview. + optional string id = 1; + // User label, max 160 UTF-8 bytes. It does not name an executable command. + string name = 2; + // Explicit proposed placements, at most 64; unsaved editing reserves nothing. + repeated PhysicalPlacement placements = 3; + // Server-derived current lifecycle; callers cannot assert accepted completion. + PhysicalPlanState state = 4; + // Server-derived saved revision, used for conditional plan changes. + uint64 revision = 5; + // Server-admitted reservation deadline in Unix milliseconds, absent for drafts. + optional uint64 reserved_until_ms = 6; + // Reviewed transitions in this same plan/lifecycle. Placements plus replacements + // are bounded to 64; no implicit cable attachment, credential or grant transfer. + repeated PhysicalReplacement replacements = 7; +} +message PhysicalPreview { + // User-supplied proposed plan; state, revision and deadline are not authority. + PhysicalPlan plan = 1; +} +message PhysicalSave { + // Proposed current draft; existing plans must include their last reviewed revision. + PhysicalPlan plan = 1; +} +message PhysicalRead { + // Existing enclosure whose permitted physical projection is requested. + omloom.v1.AssetRef enclosure = 1; +} +message PhysicalPlanKey { + // Exact saved plan identity returned by its save command, not a capability. + string id = 1; + // Last reviewed plan revision; changes require a new review. + uint64 revision = 2; +} +message PhysicalPlanList { + // Opaque last plan key from the previous page, not an access capability. + optional string after = 1; + // Requested summary count, at most 25. Omission uses the fixed first-page size. + uint32 limit = 2; +} +message PhysicalPlanSummary { + // Server-assigned saved plan UUID; names never identify a plan. + string id = 1; + // Owner-visible current plan label; resource access is independently rechecked. + string name = 2; + // Current conditional edit basis, not execution or installation evidence. + uint64 revision = 3; + // Derived current plan lifecycle, independent of actual installed occupancy. + PhysicalPlanState state = 4; +} +message PhysicalReserve { + // Existing saved draft and revision to admit against current capacity. + PhysicalPlanKey plan = 1; + // Requested finite deadline, Unix milliseconds, bounded by the host policy. + uint64 until_ms = 2; +} +message PhysicalCompletion { + // Admitted plan and revision under which the operator performed manual work. + PhysicalPlanKey plan = 1; + // Explicit selected installed items, at most 64; omitted items remain unresolved. + repeated omloom.v1.AssetRef installed = 2; + // Operator's concise discrepancy report, max 2048 bytes; never machine verification. + optional string discrepancy = 3; + // Explicit zero-based replacement indexes whose physical work was completed. + // Unselected transitions remain unresolved in this same plan. + repeated uint32 completed_replacements = 4; + // User explicitly records externally performed actual work, even if a planned + // safe installation could not be admitted. This grants no executor authority + // and never marks compatibility, cable connectivity or service health verified. + bool actual_state = 5; +} +message PhysicalOccupancy { + // Current installed placement, independent of the reservation's expiry. + PhysicalPlacement placement = 1; + // Attribution of the manual installation, not proof of live service or wiring. + string reported_by = 2; + // Exact accepted physical binding revision used to invalidate old evidence. + uint64 revision = 3; +} +message Physical { + // Exactly one physical query or command; no caller-supplied effective grants. + oneof action { + // Inspect one selected enclosure without writes or reservation admission. + PhysicalRead read = 1; + // Conditionally refine current physical facts, not installed component state. + PhysicalDefinitionEdit define = 2; + // Check a transient proposal against current authoritative constraints. + PhysicalPreview preview = 3; + // Explicitly save a bounded current plan without reserving or installing it. + PhysicalSave save = 4; + // Load the saved plan under current rights, without creating a snapshot. + PhysicalPlanKey load = 5; + // Atomically reserve current physical capacity, without claiming installation. + PhysicalReserve reserve = 6; + // Record attributed actual installation and preserve unresolved allocations. + PhysicalCompletion complete = 7; + // Cancel future work while preserving installed occupancy and unresolved effects. + PhysicalPlanKey cancel = 8; + // Retrieve only the caller's saved plan summaries with bounded stable pagination. + PhysicalPlanList list = 9; + // Create/refine a structural position with its ordinary inventory ownership atomically. + PhysicalPositionEdit position = 10; + // Retrieve bounded direct positions without loading occupants or descendants. + PhysicalPositionQuery positions = 11; + } +} +message PhysicalView { + // Current application revision under which the projection was produced. + uint64 revision = 1; + // Permitted current definitions for this bounded selection only. + repeated PhysicalDefinition definitions = 2; + // Explicitly selected current plan or transient proposal, absent for enclosure reads. + optional PhysicalPlan plan = 3; + // Shared constraint result, never a grant, execution receipt or live health. + RequirementState state = 4; + // Machine-readable missing/conflicting basis without hidden occupant identities. + RequirementReason reason = 5; + // Only permitted current occupants of the selected enclosure, maximum 128. + repeated PhysicalOccupancy occupancy = 6; + // At most 25 owner-visible plan summaries, without child or occupancy hydration. + repeated PhysicalPlanSummary plans = 7; + // Continuation for the same owner; it supplies no resource or plan authority. + optional string next = 8; + // Currently permitted direct structural positions, at most 25 per query. + repeated PhysicalPosition positions = 9; + // Minimal permitted ordinary inventory labels for this selected response only. + repeated Item items = 10; +} + message Request { // Explicit working selector for resource details, prepared actions and every // inventory mutation/import. Query/export use Query roots/focus instead. @@ -1576,6 +1829,8 @@ message Request { PrepareVmPlacement prepare_vm_placement = 32; // Evaluate desired conditions against permitted current facts without writes or probes. EvaluateRequirements evaluate_requirements = 33; + // Query or explicitly refine physical planning through the common store boundary. + Physical physical = 34; } } message Reply { @@ -1625,5 +1880,7 @@ message Reply { VmPlacementReview vm_placement_review = 21; // Shared read-only prerequisite explanations, not operation admission. RequirementEvaluation requirement_evaluation = 22; + // Bounded physical facts and plan validation over the same inventory revision. + PhysicalView physical = 23; } } diff --git a/web/app/App.vue b/web/app/App.vue index 1678793..520138f 100644 --- a/web/app/App.vue +++ b/web/app/App.vue @@ -9,6 +9,9 @@ import VmPlacement from './VmPlacement.vue'; import ResponsibilityPanel from './ResponsibilityPanel.vue'; import AccountPanel from './AccountPanel.vue'; import RequirementsPanel from './RequirementsPanel.vue'; +import PhysicalFacts from './PhysicalFacts.vue'; +import PhysicalPlanner from './PhysicalPlanner.vue'; +import PhysicalPositions from './PhysicalPositions.vue'; import { softwareFields } from './software-fields'; import { equipmentFields } from './equipment-fields'; import { ref, computed, watch, onMounted, nextTick } from 'vue'; @@ -378,6 +381,9 @@ onMounted(async () => { try { await metadata(); await load(); } catch (e) { erro + + +

Showing one bounded evidence page. Observation counts and filters apply to this page.

diff --git a/web/app/PhysicalFacts.vue b/web/app/PhysicalFacts.vue new file mode 100644 index 0000000..c72c07d --- /dev/null +++ b/web/app/PhysicalFacts.vue @@ -0,0 +1,97 @@ + + + diff --git a/web/app/PhysicalPlanner.vue b/web/app/PhysicalPlanner.vue new file mode 100644 index 0000000..288c0da --- /dev/null +++ b/web/app/PhysicalPlanner.vue @@ -0,0 +1,174 @@ + +