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
35
36/// The link-owning view of a resource, or `None` when it owns no links.
37///
38/// `None` is ordinary and callers walking every resource skip it; a caller that has already
39/// established it holds a link owner should fail loudly instead.
40pub 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    // Build is not a compute kind, but it owns author-declared links producing the same
51    // bindings, so a declined gate must reach them too. Strip timing follows the target's
52    // lifecycle, not Build's, so a Build linking a live-gated target takes the late strip.
53    if let Some(build) = resource.downcast_ref::<Build>() {
54        return Some(build);
55    }
56    None
57}
58
59/// Mutable counterpart of [`resource_links`].
60pub fn resource_links_mut(resource: &mut Resource) -> Option<&mut dyn ResourceLinks> {
61    // Probed immutably first: returning a `&mut` borrow out of a conditional keeps the
62    // borrow alive across the whole function, which rejects a downcast chain.
63    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
86/// The links a resource owns, empty when it owns none.
87pub 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    /// One entry per wired link owner. The resolve, scrub and drift tests all read this, so
162    /// adding a type is a single edit rather than several independently-trusted ones.
163    const FIXTURES: &[(&str, fn() -> Resource)] = &[
164        ("worker", worker),
165        ("container", container),
166        ("daemon", daemon),
167        ("build", build),
168    ];
169
170    /// Every link owner must resolve, expose its links, and drop exactly the named one.
171    /// Parametrised because a type that silently stops participating would otherwise only
172    /// surface as a dangling reference in a deployed account.
173    #[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    /// Accepting must leave every link in place, or the scrub would be removing links it
193    /// was never asked to remove.
194    #[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            // Resolved strictly: behind an `if let` a broken resolver would skip the
201            // mutation entirely and the equality below would still hold.
202            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    /// A resource that owns no links resolves to `None` rather than an empty owner, so a
211    /// caller that requires one can fail loudly instead of silently seeing zero links.
212    #[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    /// Every resource type the deserializer accepts, and whether it owns links.
222    ///
223    /// The `Deserialize for Resource` match is the registry; this only records classification.
224    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    /// Drift guard. The registered types are read out of the deserializer's own
251    /// `unknown_variant` refusal rather than restated, so a new type that never declares
252    /// whether it owns links fails here instead of silently opting out of scrubbing.
253    #[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 &registered {
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        // Presence alone would pass a type declared `true` that nobody wired into
287        // `impl_resource_links!`, which then never resolves and is never scrubbed.
288        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}