Skip to main content

alien_core/
resource_links.rs

1//! Resources that own links to other resources.
2//!
3//! A link produces an `ALIEN_<ID>_BINDING` and is the one reference kind a declined gate can
4//! remove; structural edges cannot. Resolution is by concrete type, not by tag string.
5
6use crate::resource::{Resource, ResourceRef};
7use crate::resources::{Build, Container, Daemon, Worker};
8
9/// A resource definition that owns resource links.
10pub trait ResourceLinks {
11    /// The links this definition owns, excluding triggers and ordering edges.
12    fn links(&self) -> &[ResourceRef];
13
14    /// Mutable access, for dropping links to resources leaving the stack.
15    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/// The link-owning view of a resource, or `None` when it owns no links.
35///
36/// `None` is ordinary and callers walking every resource skip it; a caller that has already
37/// established it holds a link owner should fail loudly instead.
38pub 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    // Build is not a compute kind, but it owns author-declared links producing the same
49    // bindings, so a declined gate must reach them too. Strip timing follows the target's
50    // lifecycle, not Build's, so a Build linking a live-gated target takes the late strip.
51    if let Some(build) = resource.downcast_ref::<Build>() {
52        return Some(build);
53    }
54    None
55}
56
57/// Mutable counterpart of [`resource_links`].
58pub fn resource_links_mut(resource: &mut Resource) -> Option<&mut dyn ResourceLinks> {
59    // Probed immutably first: returning a `&mut` borrow out of a conditional keeps the
60    // borrow alive across the whole function, which rejects a downcast chain.
61    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
84/// The links a resource owns, empty when it owns none.
85pub 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    /// One entry per wired link owner. The resolve, scrub and drift tests all read this, so
160    /// adding a type is a single edit rather than several independently-trusted ones.
161    const FIXTURES: &[(&str, fn() -> Resource)] = &[
162        ("worker", worker),
163        ("container", container),
164        ("daemon", daemon),
165        ("build", build),
166    ];
167
168    /// Every link owner must resolve, expose its links, and drop exactly the named one.
169    /// Parametrised because a type that silently stops participating would otherwise only
170    /// surface as a dangling reference in a deployed account.
171    #[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    /// Accepting must leave every link in place, or the scrub would be removing links it
191    /// was never asked to remove.
192    #[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            // Resolved strictly: behind an `if let` a broken resolver would skip the
199            // mutation entirely and the equality below would still hold.
200            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    /// A resource that owns no links resolves to `None` rather than an empty owner, so a
209    /// caller that requires one can fail loudly instead of silently seeing zero links.
210    #[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    /// Every resource type the deserializer accepts, and whether it owns links.
220    ///
221    /// The `Deserialize for Resource` match is the registry; this only records classification.
222    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        ("resource-access", false),
242        ("azure_resource_group", false),
243        ("azure_storage_account", false),
244        ("azure_container_apps_environment", false),
245        ("azure_service_bus_namespace", false),
246        ("experimental/aws-opensearch", false),
247    ];
248
249    /// Drift guard. The registered types are read out of the deserializer's own
250    /// `unknown_variant` refusal rather than restated, so a new type that never declares
251    /// whether it owns links fails here instead of silently opting out of scrubbing.
252    #[test]
253    fn every_registered_resource_type_declares_link_ownership() {
254        let refusal = Resource::deserialize(serde_json::json!({ "type": "not-a-resource" }))
255            .expect_err("an unregistered type must be refused");
256        let message = refusal.to_string();
257
258        let registered: Vec<&str> = message
259            .split('`')
260            .skip(1)
261            .step_by(2)
262            .filter(|tag| *tag != "not-a-resource")
263            .collect();
264
265        assert!(
266            registered.len() > 10,
267            "could not read the registered types out of: {message}"
268        );
269
270        for tag in &registered {
271            assert!(
272                LINK_OWNERSHIP.iter().any(|(known, _)| known == tag),
273                "resource type '{tag}' is registered but does not declare whether it owns \
274                 links. Add it to LINK_OWNERSHIP in resource_links.rs"
275            );
276        }
277
278        for (tag, _) in LINK_OWNERSHIP {
279            assert!(
280                registered.contains(tag),
281                "'{tag}' is classified but no longer registered; drop it from LINK_OWNERSHIP"
282            );
283        }
284
285        // Presence alone would pass a type declared `true` that nobody wired into
286        // `impl_resource_links!`, which then never resolves and is never scrubbed.
287        let declared: Vec<&str> = LINK_OWNERSHIP
288            .iter()
289            .filter(|(_, owns)| *owns)
290            .map(|(tag, _)| *tag)
291            .collect();
292        for (tag, make) in FIXTURES {
293            assert!(
294                declared.contains(tag),
295                "fixture '{tag}' is not declared a link owner"
296            );
297            assert!(
298                resource_links(&make()).is_some(),
299                "'{tag}' is declared a link owner but does not resolve as one"
300            );
301        }
302        assert_eq!(
303            declared.len(),
304            FIXTURES.len(),
305            "declared link owners {declared:?} have no fixture proving they resolve"
306        );
307    }
308}