1use crate::resource::{Resource, ResourceRef};
7use crate::resources::{Build, Container, Daemon, Worker};
8
9pub trait ResourceLinks {
11 fn links(&self) -> &[ResourceRef];
13
14 fn links_mut(&mut self) -> &mut Vec<ResourceRef>;
16}
17
18macro_rules! impl_resource_links {
19 ($($ty:ty),+ $(,)?) => {$(
20 impl ResourceLinks for $ty {
21 fn links(&self) -> &[ResourceRef] {
22 &self.links
23 }
24
25 fn links_mut(&mut self) -> &mut Vec<ResourceRef> {
26 &mut self.links
27 }
28 }
29 )+};
30}
31
32impl_resource_links!(Worker, Container, Daemon, Build);
33
34pub fn resource_links(resource: &Resource) -> Option<&dyn ResourceLinks> {
39 if let Some(worker) = resource.downcast_ref::<Worker>() {
40 return Some(worker);
41 }
42 if let Some(container) = resource.downcast_ref::<Container>() {
43 return Some(container);
44 }
45 if let Some(daemon) = resource.downcast_ref::<Daemon>() {
46 return Some(daemon);
47 }
48 if let Some(build) = resource.downcast_ref::<Build>() {
52 return Some(build);
53 }
54 None
55}
56
57pub fn resource_links_mut(resource: &mut Resource) -> Option<&mut dyn ResourceLinks> {
59 if resource.downcast_ref::<Worker>().is_some() {
62 return resource
63 .downcast_mut::<Worker>()
64 .map(|worker| worker as &mut dyn ResourceLinks);
65 }
66 if resource.downcast_ref::<Container>().is_some() {
67 return resource
68 .downcast_mut::<Container>()
69 .map(|container| container as &mut dyn ResourceLinks);
70 }
71 if resource.downcast_ref::<Daemon>().is_some() {
72 return resource
73 .downcast_mut::<Daemon>()
74 .map(|daemon| daemon as &mut dyn ResourceLinks);
75 }
76 if resource.downcast_ref::<Build>().is_some() {
77 return resource
78 .downcast_mut::<Build>()
79 .map(|build| build as &mut dyn ResourceLinks);
80 }
81 None
82}
83
84pub fn links_of(resource: &Resource) -> &[ResourceRef] {
86 resource_links(resource).map_or(&[], |owner| owner.links())
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::resources::{ContainerCode, DaemonCode, Kv, ResourceSpec, WorkerCode};
93 use serde::Deserialize;
94
95 fn kv(id: &str) -> Kv {
96 Kv::new(id.to_string()).build()
97 }
98
99 fn worker() -> Resource {
100 Resource::new(
101 Worker::new("api".to_string())
102 .permissions("execution".to_string())
103 .code(WorkerCode::Image {
104 image: "example.com/api:latest".to_string(),
105 })
106 .link(&kv("cache"))
107 .link(&kv("store"))
108 .build(),
109 )
110 }
111
112 fn container() -> Resource {
113 Resource::new(
114 Container::new("api".to_string())
115 .code(ContainerCode::Image {
116 image: "example.com/api:latest".to_string(),
117 })
118 .cpu(ResourceSpec {
119 min: "0.25".to_string(),
120 desired: "0.5".to_string(),
121 })
122 .memory(ResourceSpec {
123 min: "256Mi".to_string(),
124 desired: "512Mi".to_string(),
125 })
126 .replicas(1)
127 .permissions("execution".to_string())
128 .port(8080)
129 .link(&kv("cache"))
130 .link(&kv("store"))
131 .build(),
132 )
133 }
134
135 fn daemon() -> Resource {
136 Resource::new(
137 Daemon::new("agent".to_string())
138 .code(DaemonCode::Image {
139 image: "example.com/agent:latest".to_string(),
140 })
141 .cluster("runtime".to_string())
142 .permissions("execution".to_string())
143 .link(&kv("cache"))
144 .link(&kv("store"))
145 .build(),
146 )
147 }
148
149 fn build() -> Resource {
150 Resource::new(
151 Build::new("builder".to_string())
152 .permissions("build".to_string())
153 .link(&kv("cache"))
154 .link(&kv("store"))
155 .build(),
156 )
157 }
158
159 const FIXTURES: &[(&str, fn() -> Resource)] = &[
162 ("worker", worker),
163 ("container", container),
164 ("daemon", daemon),
165 ("build", build),
166 ];
167
168 #[test]
172 fn every_link_owner_resolves_and_scrubs() {
173 for (name, make) in FIXTURES {
174 let mut resource = make();
175 assert_eq!(
176 links_of(&resource).len(),
177 2,
178 "{name} should start with both links"
179 );
180
181 let owner = resource_links_mut(&mut resource)
182 .unwrap_or_else(|| panic!("{name} must resolve as a link owner"));
183 owner.links_mut().retain(|l| l.id != "cache");
184
185 let remaining: Vec<&str> = links_of(&resource).iter().map(|l| l.id.as_str()).collect();
186 assert_eq!(remaining, vec!["store"], "{name} kept the wrong link");
187 }
188 }
189
190 #[test]
193 fn no_declines_leaves_every_link_owner_untouched() {
194 for (name, make) in FIXTURES {
195 let mut resource = make();
196 let before: Vec<ResourceRef> = links_of(&resource).to_vec();
197 assert_eq!(before.len(), 2, "{name} should start with both links");
198 let owner = resource_links_mut(&mut resource)
201 .unwrap_or_else(|| panic!("{name} must resolve as a link owner"));
202 let declined: Vec<String> = Vec::new();
203 owner.links_mut().retain(|l| !declined.contains(&l.id));
204 assert_eq!(links_of(&resource), before.as_slice(), "{name} changed");
205 }
206 }
207
208 #[test]
211 fn a_non_link_owner_does_not_resolve() {
212 let mut store = Resource::new(Kv::new("store".to_string()).build());
213
214 assert!(resource_links(&store).is_none());
215 assert!(resource_links_mut(&mut store).is_none());
216 assert!(links_of(&store).is_empty());
217 }
218
219 const LINK_OWNERSHIP: &[(&str, bool)] = &[
223 ("worker", true),
224 ("container", true),
225 ("daemon", true),
226 ("build", true),
227 ("vault", false),
228 ("compute-cluster", false),
229 ("kubernetes-cluster", false),
230 ("storage", false),
231 ("queue", false),
232 ("email", false),
233 ("kv", false),
234 ("postgres", false),
235 ("ai", false),
236 ("network", false),
237 ("service-account", false),
238 ("artifact-registry", false),
239 ("service_activation", false),
240 ("remote-stack-management", false),
241 ("azure_resource_group", false),
242 ("azure_storage_account", false),
243 ("azure_container_apps_environment", false),
244 ("azure_service_bus_namespace", false),
245 ("experimental/aws-opensearch", false),
246 ];
247
248 #[test]
252 fn every_registered_resource_type_declares_link_ownership() {
253 let refusal = Resource::deserialize(serde_json::json!({ "type": "not-a-resource" }))
254 .expect_err("an unregistered type must be refused");
255 let message = refusal.to_string();
256
257 let registered: Vec<&str> = message
258 .split('`')
259 .skip(1)
260 .step_by(2)
261 .filter(|tag| *tag != "not-a-resource")
262 .collect();
263
264 assert!(
265 registered.len() > 10,
266 "could not read the registered types out of: {message}"
267 );
268
269 for tag in ®istered {
270 assert!(
271 LINK_OWNERSHIP.iter().any(|(known, _)| known == tag),
272 "resource type '{tag}' is registered but does not declare whether it owns \
273 links. Add it to LINK_OWNERSHIP in resource_links.rs"
274 );
275 }
276
277 for (tag, _) in LINK_OWNERSHIP {
278 assert!(
279 registered.contains(tag),
280 "'{tag}' is classified but no longer registered; drop it from LINK_OWNERSHIP"
281 );
282 }
283
284 let declared: Vec<&str> = LINK_OWNERSHIP
287 .iter()
288 .filter(|(_, owns)| *owns)
289 .map(|(tag, _)| *tag)
290 .collect();
291 for (tag, make) in FIXTURES {
292 assert!(
293 declared.contains(tag),
294 "fixture '{tag}' is not declared a link owner"
295 );
296 assert!(
297 resource_links(&make()).is_some(),
298 "'{tag}' is declared a link owner but does not resolve as one"
299 );
300 }
301 assert_eq!(
302 declared.len(),
303 FIXTURES.len(),
304 "declared link owners {declared:?} have no fixture proving they resolve"
305 );
306 }
307}