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