Skip to main content

nxd_core/domain/contract/
resources.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use std::collections::BTreeMap;
4
5/// Canonical resource. Only core lifecycle kinds remain first-party. All
6/// infrastructure/vendor kinds (PVE, PBS, Headscale, …) that declare a
7/// provider instance are [`Resource::ProviderOwned`] envelopes: core sees association
8/// and secret fields only; vendor schema is owned by the provider handshake.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum Resource {
11	Machine(Machine),
12	DeploymentTarget(Box<DeploymentTarget>),
13	SyntheticResource(SyntheticResource),
14	ProviderOwned(ProviderOwnedResource),
15}
16
17/// Closed first-party wire kinds (serde). Open provider kinds use envelopes.
18#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
19#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
20enum FirstPartyResource {
21	Machine(Machine),
22	DeploymentTarget(Box<DeploymentTarget>),
23	SyntheticResource(SyntheticResource),
24}
25
26impl From<FirstPartyResource> for Resource {
27	fn from(value: FirstPartyResource) -> Self {
28		match value {
29			FirstPartyResource::Machine(v) => Self::Machine(v),
30			FirstPartyResource::DeploymentTarget(v) => Self::DeploymentTarget(v),
31			FirstPartyResource::SyntheticResource(v) => Self::SyntheticResource(v),
32		}
33	}
34}
35
36impl TryFrom<&Resource> for FirstPartyResource {
37	type Error = ();
38	fn try_from(value: &Resource) -> Result<Self, Self::Error> {
39		Ok(match value {
40			Resource::Machine(v) => Self::Machine(v.clone()),
41			Resource::DeploymentTarget(v) => Self::DeploymentTarget(v.clone()),
42			Resource::SyntheticResource(v) => Self::SyntheticResource(v.clone()),
43			Resource::ProviderOwned(_) => return Err(()),
44		})
45	}
46}
47
48impl Serialize for Resource {
49	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
50	where
51		S: Serializer,
52	{
53		match self {
54			Resource::ProviderOwned(owned) => owned.desired.serialize(serializer),
55			other => {
56				FirstPartyResource::try_from(other).expect("first-party resource").serialize(serializer)
57			}
58		}
59	}
60}
61
62impl<'de> Deserialize<'de> for Resource {
63	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
64	where
65		D: Deserializer<'de>,
66	{
67		let value = Value::deserialize(deserializer)?;
68		match serde_json::from_value::<FirstPartyResource>(value.clone()) {
69			Ok(first_party) => return Ok(first_party.into()),
70			Err(error)
71				if matches!(
72					value.get("kind").and_then(Value::as_str),
73					Some("machine" | "deploymentTarget" | "syntheticResource")
74				) =>
75			{
76				return Err(serde::de::Error::custom(error));
77			}
78			Err(_) => {}
79		}
80		ProviderOwnedResource::from_wire_value(value)
81			.map(Resource::ProviderOwned)
82			.map_err(serde::de::Error::custom)
83	}
84}
85
86/// Provider-owned resource envelope: common fields for core; `desired` is the
87/// full wire document for the owning provider (opaque vendor schema).
88#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct ProviderOwnedResource {
91	/// Provider schema kind (wire `kind`).
92	#[serde(rename = "resourceKind")]
93	pub resource_kind: String,
94	pub id: String,
95	pub provider: String,
96	#[serde(default)]
97	pub depends_on: Vec<String>,
98	#[serde(default, skip_serializing_if = "Option::is_none")]
99	pub deployment_target: Option<String>,
100	#[serde(default, skip_serializing_if = "Option::is_none")]
101	pub secret_binding: Option<String>,
102	/// Full desired document returned to the provider (includes kind and fields).
103	pub desired: Value,
104}
105
106impl ProviderOwnedResource {
107	/// Build an envelope from a wire JSON object that was not a first-party kind.
108	pub fn from_wire_value(value: Value) -> Result<Self, String> {
109		let object = value
110			.as_object()
111			.ok_or_else(|| "provider-owned resource must be a JSON object".to_string())?;
112		let resource_kind = object
113			.get("kind")
114			.and_then(Value::as_str)
115			.filter(|value| !value.is_empty())
116			.ok_or_else(|| "provider-owned resource is missing kind".to_string())?
117			.to_string();
118		let id = object
119			.get("id")
120			.and_then(Value::as_str)
121			.filter(|value| !value.is_empty())
122			.ok_or_else(|| "provider-owned resource is missing id".to_string())?
123			.to_string();
124		// Every infrastructure kind, including pveCluster/qdevice topology, must
125		// name a configured provider instance (prompt 03.1 / C.3).
126		let provider = object
127			.get("provider")
128			.and_then(Value::as_str)
129			.filter(|value| !value.is_empty())
130			.ok_or_else(|| format!("provider-owned resource {id} is missing provider"))?
131			.to_string();
132		// Only explicit dependsOn edges are graph dependencies. Vendor fields
133		// are not resource ids and are not interpreted as associations.
134		let depends_on = object
135			.get("dependsOn")
136			.and_then(Value::as_array)
137			.map(|items| items.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>())
138			.unwrap_or_default();
139		// Association is the common field only — never hostname conventions.
140		let deployment_target = object
141			.get("deploymentTarget")
142			.and_then(Value::as_str)
143			.filter(|value| !value.is_empty())
144			.map(str::to_string);
145		let secret_binding = object
146			.get("secretBinding")
147			.and_then(Value::as_str)
148			.filter(|value| !value.is_empty())
149			.map(str::to_string);
150		Ok(Self {
151			resource_kind,
152			id,
153			provider,
154			depends_on,
155			deployment_target,
156			secret_binding,
157			desired: value,
158		})
159	}
160
161	pub fn associated_deployment_target(&self) -> Option<String> {
162		if let Some(target) = &self.deployment_target
163			&& !target.is_empty()
164		{
165			return Some(target.clone());
166		}
167		self
168			.desired
169			.get("deploymentTarget")
170			.and_then(Value::as_str)
171			.filter(|v| !v.is_empty())
172			.map(str::to_string)
173	}
174}
175
176#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
177#[serde(rename_all = "camelCase", deny_unknown_fields)]
178pub struct DeploymentTarget {
179	pub id: String,
180	#[serde(default)]
181	pub depends_on: Vec<String>,
182	#[serde(default)]
183	pub labels: BTreeMap<String, String>,
184	pub metadata: crate::domain::deployment_metadata::FlakeMetadata,
185}
186
187#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
188#[serde(rename_all = "camelCase", deny_unknown_fields)]
189pub struct Machine {
190	pub id: String,
191	#[serde(default)]
192	pub labels: BTreeMap<String, String>,
193}
194
195#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct SyntheticResource {
198	pub id: String,
199	pub provider: String,
200	pub desired: serde_json::Value,
201	#[serde(default)]
202	pub depends_on: Vec<String>,
203}
204
205/// Stable resource id for any resource variant.
206pub fn resource_id(resource: &Resource) -> &str {
207	match resource {
208		Resource::Machine(value) => &value.id,
209		Resource::DeploymentTarget(value) => &value.id,
210		Resource::SyntheticResource(value) => &value.id,
211		Resource::ProviderOwned(value) => &value.id,
212	}
213}
214
215/// Owning provider instance id when the resource is provider-routed.
216pub fn resource_provider(resource: &Resource) -> Option<&str> {
217	match resource {
218		Resource::SyntheticResource(value) => Some(&value.provider),
219		Resource::ProviderOwned(value) if !value.provider.is_empty() => Some(&value.provider),
220		_ => None,
221	}
222}
223
224/// Wire kind for a provider-owned envelope (`guest`, `backupJob`, …).
225pub fn resource_kind(resource: &Resource) -> Option<&str> {
226	match resource {
227		Resource::ProviderOwned(value) => Some(value.resource_kind.as_str()),
228		Resource::SyntheticResource(_) => Some("syntheticResource"),
229		Resource::DeploymentTarget(_) => Some("deploymentTarget"),
230		Resource::Machine(_) => Some("machine"),
231	}
232}
233
234/// Full desired JSON document for provider-owned resources.
235pub fn resource_desired(resource: &Resource) -> Option<&Value> {
236	match resource {
237		Resource::ProviderOwned(value) => Some(&value.desired),
238		Resource::SyntheticResource(value) => Some(&value.desired),
239		_ => None,
240	}
241}
242
243/// Declared dependency edges for graph validation / action rewrite.
244pub fn resource_depends_on(resource: &Resource) -> &[String] {
245	match resource {
246		Resource::DeploymentTarget(value) => &value.depends_on,
247		Resource::SyntheticResource(value) => &value.depends_on,
248		Resource::ProviderOwned(value) => &value.depends_on,
249		_ => &[],
250	}
251}
252
253/// Explicit lifecycle association to a deployment target, if any.
254pub fn resource_associated_deployment_target(resource: &Resource) -> Option<String> {
255	match resource {
256		Resource::ProviderOwned(value) => value.associated_deployment_target(),
257		Resource::SyntheticResource(value) => value
258			.desired
259			.get("deploymentTarget")
260			.and_then(Value::as_str)
261			.filter(|v| !v.is_empty())
262			.map(str::to_string),
263		_ => None,
264	}
265}
266
267/// Optional confidential binding declared on the resource (ensure/mint producers).
268pub fn resource_secret_binding(resource: &Resource) -> Option<&str> {
269	match resource {
270		Resource::ProviderOwned(value) => value.secret_binding.as_deref(),
271		Resource::SyntheticResource(value) => {
272			value.desired.get("secretBinding").and_then(Value::as_str)
273		}
274		_ => None,
275	}
276}