Skip to main content

alien_core/
stack_commands.rs

1//! Command-target discovery and the command env-var injection helpers for
2//! [`Stack`]. Workers receive authenticated platform pushes; Containers and
3//! Daemons run the pull receiver. Both are derived from the same ordered
4//! [`Stack::command_targets`] list.
5
6use crate::error::{ErrorData, Result};
7use crate::{EnvironmentVariable, EnvironmentVariableType, Stack};
8use alien_error::AlienError;
9
10/// Builds a single [`EnvironmentVariable`] scoped to exactly one resource.
11///
12/// Every command env var is scoped via `target_resources` so it reaches only
13/// its own resource; this collapses the otherwise-verbatim struct literals in
14/// the polling/receiver builders below into one place.
15fn scoped(
16    name: &str,
17    value: impl Into<String>,
18    var_type: EnvironmentVariableType,
19    target_id: &str,
20) -> EnvironmentVariable {
21    EnvironmentVariable {
22        name: name.to_string(),
23        value: value.into(),
24        var_type,
25        target_resources: Some(vec![target_id.to_string()]),
26    }
27}
28
29impl Stack {
30    /// Returns the ordered list of command-capable targets in this stack:
31    /// Worker, Container, and Daemon resources with `commands_enabled` set,
32    /// in stack declaration order.
33    ///
34    /// Declaration order is the order resources were added to the stack
35    /// (via `StackBuilder::add`/`add_with_dependencies`/`add_with_remote_access`).
36    /// `resources` is an `IndexMap`, which preserves insertion order, so
37    /// iterating it directly yields declaration order without any extra
38    /// bookkeeping.
39    pub fn command_targets(&self) -> Vec<crate::commands_types::CommandTarget> {
40        use crate::commands_types::{CommandTarget, CommandTargetType};
41
42        self.resources
43            .iter()
44            .filter_map(|(id, entry)| {
45                if let Some(worker) = entry.config.downcast_ref::<crate::Worker>() {
46                    worker
47                        .commands_enabled
48                        .then(|| CommandTarget::new(id.clone(), CommandTargetType::Worker))
49                } else if let Some(container) = entry.config.downcast_ref::<crate::Container>() {
50                    container
51                        .commands_enabled
52                        .then(|| CommandTarget::new(id.clone(), CommandTargetType::Container))
53                } else if let Some(daemon) = entry.config.downcast_ref::<crate::Daemon>() {
54                    daemon
55                        .commands_enabled
56                        .then(|| CommandTarget::new(id.clone(), CommandTargetType::Daemon))
57                } else {
58                    None
59                }
60            })
61            .collect()
62    }
63
64    /// Returns the authentication token used by the manager/operator to push
65    /// commands to each command-enabled Local/Kubernetes Worker runtime.
66    /// Cloud-native Worker transports authenticate through their platform and
67    /// do not use this helper.
68    pub fn worker_command_push_env_vars(
69        &self,
70        commands_token: Option<&str>,
71    ) -> Result<Vec<EnvironmentVariable>> {
72        use crate::commands_types::CommandTargetType;
73
74        let mut vars = Vec::new();
75        for target in self
76            .command_targets()
77            .into_iter()
78            .filter(|target| target.resource_type == CommandTargetType::Worker)
79        {
80            let id = target.resource_id;
81            let token = require_command_token(commands_token, &id)?;
82            vars.push(scoped(
83                crate::ENV_ALIEN_COMMANDS_TOKEN,
84                token,
85                EnvironmentVariableType::Secret,
86                &id,
87            ));
88        }
89        Ok(vars)
90    }
91
92    /// Returns the command *receiver* environment variables for each
93    /// command-enabled Container and Daemon in this stack, scoped via
94    /// `target_resources` so every var reaches only its own resource.
95    ///
96    /// Workers receive platform pushes; Containers and Daemons run the pull
97    /// receiver, which reads this fixed environment contract:
98    ///   - `ALIEN_COMMANDS_URL` (Plain) — base receiver URL
99    ///   - `ALIEN_COMMANDS_TOKEN` (Secret, only if a token is present)
100    ///   - `ALIEN_COMMANDS_TARGET_RESOURCE_ID` (Plain) — this resource's id
101    ///   - `ALIEN_COMMANDS_TARGET_RESOURCE_TYPE` (Plain) — `container`/`daemon`
102    ///
103    /// `ALIEN_DEPLOYMENT_ID` is intentionally NOT emitted here: the manager and
104    /// operator already inject it deployment-wide (`target_resources: None`), so
105    /// it reaches every Container/Daemon via that path — re-scoping it per
106    /// resource would be redundant. This mirrors the worker helper, which also
107    /// relies on the deployment-wide `ALIEN_DEPLOYMENT_ID`.
108    ///
109    /// Workers are excluded here, and a
110    /// commands-disabled Container/Daemon is never a `command_targets()` entry,
111    /// so it receives nothing — the receiver fail-fasts on a partial config, so
112    /// a deployment-wide flag would crash it at startup.
113    pub fn receiver_command_env_vars(
114        &self,
115        commands_url: &str,
116        commands_token: Option<&str>,
117    ) -> Result<Vec<EnvironmentVariable>> {
118        use crate::commands_types::CommandTargetType;
119
120        let mut vars = Vec::new();
121        for target in self.command_targets().into_iter().filter(|target| {
122            matches!(
123                target.resource_type,
124                CommandTargetType::Container | CommandTargetType::Daemon
125            )
126        }) {
127            let resource_type = target.resource_type.as_str();
128            let id = target.resource_id;
129            // Four of the five receiver vars without the token would make
130            // `Receiver::from_env` crash-loop the workload at startup; fail
131            // the deploy here instead, where the cause is nameable.
132            let token = require_command_token(commands_token, &id)?;
133            vars.extend([
134                scoped(
135                    crate::ENV_ALIEN_COMMANDS_URL,
136                    commands_url,
137                    EnvironmentVariableType::Plain,
138                    &id,
139                ),
140                scoped(
141                    crate::ENV_ALIEN_COMMANDS_TOKEN,
142                    token,
143                    EnvironmentVariableType::Secret,
144                    &id,
145                ),
146                scoped(
147                    crate::ENV_ALIEN_COMMANDS_TARGET_RESOURCE_TYPE,
148                    resource_type,
149                    EnvironmentVariableType::Plain,
150                    &id,
151                ),
152                scoped(
153                    crate::ENV_ALIEN_COMMANDS_TARGET_RESOURCE_ID,
154                    id.clone(),
155                    EnvironmentVariableType::Plain,
156                    &id,
157                ),
158            ]);
159        }
160        Ok(vars)
161    }
162}
163
164/// The deployment token, or a deploy-time error naming the command-enabled
165/// resource that needs it — a runtime crash-loop on a missing env var is the
166/// only alternative.
167fn require_command_token<'a>(token: Option<&'a str>, resource_id: &str) -> Result<&'a str> {
168    token
169        .filter(|value| !value.trim().is_empty())
170        .ok_or_else(|| {
171            AlienError::new(ErrorData::CommandTokenMissing {
172                resource_id: resource_id.to_string(),
173                reason: "the deployment record carries no non-empty deployment token".to_string(),
174            })
175        })
176}
177
178#[cfg(test)]
179mod tests {
180    use crate::resource::ResourceLifecycle;
181    use crate::{
182        Container, ContainerCode, Daemon, DaemonCode, ResourceSpec, Stack, Storage, Worker,
183        WorkerCode,
184    };
185
186    #[test]
187    fn command_targets_returns_only_commands_enabled_resources_in_declaration_order() {
188        let worker_enabled = Worker::new("worker-a".to_string())
189            .code(WorkerCode::Image {
190                image: "worker:latest".to_string(),
191            })
192            .permissions("execution".to_string())
193            .commands_enabled(true)
194            .build();
195
196        let container_disabled = Container::new("container-b".to_string())
197            .code(ContainerCode::Image {
198                image: "container:latest".to_string(),
199            })
200            .cpu(ResourceSpec {
201                min: "0.5".to_string(),
202                desired: "1".to_string(),
203            })
204            .memory(ResourceSpec {
205                min: "512Mi".to_string(),
206                desired: "1Gi".to_string(),
207            })
208            .port(8080)
209            .permissions("container-execution".to_string())
210            .build();
211
212        let daemon_enabled = Daemon::new("daemon-c".to_string())
213            .code(DaemonCode::Image {
214                image: "daemon:latest".to_string(),
215            })
216            .permissions("daemon-execution".to_string())
217            .commands_enabled(true)
218            .build();
219
220        let container_enabled = Container::new("container-d".to_string())
221            .code(ContainerCode::Image {
222                image: "container:latest".to_string(),
223            })
224            .cpu(ResourceSpec {
225                min: "0.5".to_string(),
226                desired: "1".to_string(),
227            })
228            .memory(ResourceSpec {
229                min: "512Mi".to_string(),
230                desired: "1Gi".to_string(),
231            })
232            .port(8080)
233            .permissions("container-execution".to_string())
234            .commands_enabled(true)
235            .build();
236
237        let worker_disabled = Worker::new("worker-e".to_string())
238            .code(WorkerCode::Image {
239                image: "worker:latest".to_string(),
240            })
241            .permissions("execution".to_string())
242            .build();
243
244        let storage = Storage::new("bucket-f".to_string()).build();
245
246        // Declaration order: worker-a (enabled), container-b (disabled),
247        // daemon-c (enabled), container-d (enabled), worker-e (disabled),
248        // bucket-f (not a command-capable resource type at all).
249        let stack = Stack::new("command-targets-stack".to_string())
250            .add(worker_enabled, ResourceLifecycle::Live)
251            .add(container_disabled, ResourceLifecycle::Live)
252            .add(daemon_enabled, ResourceLifecycle::Live)
253            .add(container_enabled, ResourceLifecycle::Live)
254            .add(worker_disabled, ResourceLifecycle::Live)
255            .add(storage, ResourceLifecycle::Frozen)
256            .build();
257
258        let targets = stack.command_targets();
259
260        assert_eq!(
261            targets,
262            vec![
263                crate::commands_types::CommandTarget::new(
264                    "worker-a",
265                    crate::commands_types::CommandTargetType::Worker
266                ),
267                crate::commands_types::CommandTarget::new(
268                    "daemon-c",
269                    crate::commands_types::CommandTargetType::Daemon
270                ),
271                crate::commands_types::CommandTarget::new(
272                    "container-d",
273                    crate::commands_types::CommandTargetType::Container
274                ),
275            ]
276        );
277    }
278
279    #[test]
280    fn command_targets_empty_when_no_commands_enabled_resources() {
281        let worker = Worker::new("worker-only".to_string())
282            .code(WorkerCode::Image {
283                image: "worker:latest".to_string(),
284            })
285            .permissions("execution".to_string())
286            .build();
287
288        let stack = Stack::new("no-targets-stack".to_string())
289            .add(worker, ResourceLifecycle::Live)
290            .build();
291
292        assert!(stack.command_targets().is_empty());
293    }
294
295    #[test]
296    fn worker_command_push_env_vars_scopes_secret_per_worker() {
297        let worker_a = Worker::new("worker-a".to_string())
298            .code(WorkerCode::Image {
299                image: "worker:latest".to_string(),
300            })
301            .permissions("execution".to_string())
302            .commands_enabled(true)
303            .build();
304
305        let worker_b = Worker::new("worker-b".to_string())
306            .code(WorkerCode::Image {
307                image: "worker:latest".to_string(),
308            })
309            .permissions("execution".to_string())
310            .commands_enabled(true)
311            .build();
312
313        // Commands-disabled Workers must not expose a command push endpoint.
314        let worker_disabled = Worker::new("worker-off".to_string())
315            .code(WorkerCode::Image {
316                image: "worker:latest".to_string(),
317            })
318            .permissions("execution".to_string())
319            .build();
320
321        let daemon_enabled = Daemon::new("daemon-c".to_string())
322            .code(DaemonCode::Image {
323                image: "daemon:latest".to_string(),
324            })
325            .permissions("daemon-execution".to_string())
326            .commands_enabled(true)
327            .build();
328
329        let stack = Stack::new("worker-push-env-stack".to_string())
330            .add(worker_a, ResourceLifecycle::Live)
331            .add(worker_b, ResourceLifecycle::Live)
332            .add(worker_disabled, ResourceLifecycle::Live)
333            .add(daemon_enabled, ResourceLifecycle::Live)
334            .build();
335
336        let vars = stack
337            .worker_command_push_env_vars(Some("tok"))
338            .expect("token present");
339
340        // Every var is scoped to exactly one command-enabled Worker — nothing
341        // is deployment-wide, and neither the disabled Worker nor the Daemon
342        // is ever a scope target.
343        assert!(vars.iter().all(|v| {
344            v.target_resources == Some(vec!["worker-a".to_string()])
345                || v.target_resources == Some(vec!["worker-b".to_string()])
346        }));
347
348        // Each command-enabled Worker gets only its scoped push token.
349        for worker_id in ["worker-a", "worker-b"] {
350            let scoped: Vec<_> = vars
351                .iter()
352                .filter(|v| v.target_resources == Some(vec![worker_id.to_string()]))
353                .collect();
354            assert_eq!(scoped.len(), 1, "expected one push token for {worker_id}");
355            assert!(scoped.iter().any(|v| {
356                v.name == crate::ENV_ALIEN_COMMANDS_TOKEN
357                    && v.value == "tok"
358                    && v.var_type == crate::EnvironmentVariableType::Secret
359            }));
360        }
361    }
362
363    #[test]
364    fn worker_command_push_env_vars_fails_without_token() {
365        let worker = Worker::new("worker-a".to_string())
366            .code(WorkerCode::Image {
367                image: "worker:latest".to_string(),
368            })
369            .permissions("execution".to_string())
370            .commands_enabled(true)
371            .build();
372
373        let stack = Stack::new("worker-no-token-stack".to_string())
374            .add(worker, ResourceLifecycle::Live)
375            .build();
376
377        // A command-enabled worker without a token would expose no usable push
378        // endpoint, so deployment must fail loudly.
379        for token in [None, Some(""), Some("   \t")] {
380            let error = stack
381                .worker_command_push_env_vars(token)
382                .expect_err("missing or blank token must fail the env build");
383            assert_eq!(error.code, "COMMAND_TOKEN_MISSING");
384            assert!(error.to_string().contains("worker-a"));
385        }
386
387        let original = "  nonempty-token  ";
388        let vars = stack
389            .worker_command_push_env_vars(Some(original))
390            .expect("non-empty token");
391        assert_eq!(vars[0].value, original, "token bytes must be preserved");
392    }
393
394    #[test]
395    fn receiver_command_env_vars_scopes_contract_per_container_and_daemon() {
396        let container_a = Container::new("container-a".to_string())
397            .code(ContainerCode::Image {
398                image: "container:latest".to_string(),
399            })
400            .cpu(ResourceSpec {
401                min: "0.5".to_string(),
402                desired: "1".to_string(),
403            })
404            .memory(ResourceSpec {
405                min: "512Mi".to_string(),
406                desired: "1Gi".to_string(),
407            })
408            .port(8080)
409            .permissions("container-execution".to_string())
410            .commands_enabled(true)
411            .build();
412
413        let daemon_b = Daemon::new("daemon-b".to_string())
414            .code(DaemonCode::Image {
415                image: "daemon:latest".to_string(),
416            })
417            .permissions("daemon-execution".to_string())
418            .commands_enabled(true)
419            .build();
420
421        // Commands-DISABLED container: must receive NONE of the receiver vars.
422        let container_off = Container::new("container-off".to_string())
423            .code(ContainerCode::Image {
424                image: "container:latest".to_string(),
425            })
426            .cpu(ResourceSpec {
427                min: "0.5".to_string(),
428                desired: "1".to_string(),
429            })
430            .memory(ResourceSpec {
431                min: "512Mi".to_string(),
432                desired: "1Gi".to_string(),
433            })
434            .port(8080)
435            .permissions("container-execution".to_string())
436            .build();
437
438        // Commands-enabled Worker gets push auth, not the receiver contract.
439        let worker_enabled = Worker::new("worker-c".to_string())
440            .code(WorkerCode::Image {
441                image: "worker:latest".to_string(),
442            })
443            .permissions("execution".to_string())
444            .commands_enabled(true)
445            .build();
446
447        let stack = Stack::new("receiver-env-stack".to_string())
448            .add(container_a, ResourceLifecycle::Live)
449            .add(daemon_b, ResourceLifecycle::Live)
450            .add(container_off, ResourceLifecycle::Live)
451            .add(worker_enabled, ResourceLifecycle::Live)
452            .build();
453
454        let vars = stack
455            .receiver_command_env_vars("https://cmd.example.test/v1", Some("tok"))
456            .expect("token present");
457
458        // Every var is scoped to exactly one command-enabled Container/Daemon —
459        // nothing is deployment-wide, and neither the disabled container nor the
460        // Worker is ever a scope target.
461        assert!(vars.iter().all(|v| {
462            v.target_resources == Some(vec!["container-a".to_string()])
463                || v.target_resources == Some(vec!["daemon-b".to_string()])
464        }));
465
466        // ALIEN_DEPLOYMENT_ID remains deployment-wide and is not duplicated.
467        assert!(!vars
468            .iter()
469            .any(|v| v.name == crate::ENV_ALIEN_DEPLOYMENT_ID));
470
471        for (resource_id, expected_type) in [("container-a", "container"), ("daemon-b", "daemon")] {
472            let scoped: Vec<_> = vars
473                .iter()
474                .filter(|v| v.target_resources == Some(vec![resource_id.to_string()]))
475                .collect();
476            assert_eq!(
477                scoped.len(),
478                4,
479                "expected 4 receiver vars for {resource_id}"
480            );
481            assert!(scoped.iter().any(|v| {
482                v.name == crate::ENV_ALIEN_COMMANDS_URL
483                    && v.value == "https://cmd.example.test/v1"
484                    && v.var_type == crate::EnvironmentVariableType::Plain
485            }));
486            assert!(scoped.iter().any(|v| {
487                v.name == crate::ENV_ALIEN_COMMANDS_TOKEN
488                    && v.value == "tok"
489                    && v.var_type == crate::EnvironmentVariableType::Secret
490            }));
491            assert!(scoped.iter().any(|v| {
492                v.name == crate::ENV_ALIEN_COMMANDS_TARGET_RESOURCE_ID && v.value == resource_id
493            }));
494            assert!(scoped.iter().any(|v| {
495                v.name == crate::ENV_ALIEN_COMMANDS_TARGET_RESOURCE_TYPE
496                    && v.value == expected_type
497                    && v.var_type == crate::EnvironmentVariableType::Plain
498            }));
499        }
500    }
501
502    #[test]
503    fn receiver_command_env_vars_fails_without_token() {
504        let container = Container::new("container-a".to_string())
505            .code(ContainerCode::Image {
506                image: "container:latest".to_string(),
507            })
508            .cpu(ResourceSpec {
509                min: "0.5".to_string(),
510                desired: "1".to_string(),
511            })
512            .memory(ResourceSpec {
513                min: "512Mi".to_string(),
514                desired: "1Gi".to_string(),
515            })
516            .port(8080)
517            .permissions("container-execution".to_string())
518            .commands_enabled(true)
519            .build();
520
521        let daemon = Daemon::new("daemon-b".to_string())
522            .code(DaemonCode::Image {
523                image: "daemon:latest".to_string(),
524            })
525            .permissions("daemon-execution".to_string())
526            .commands_enabled(true)
527            .build();
528
529        let stack = Stack::new("receiver-no-token-stack".to_string())
530            .add(container, ResourceLifecycle::Live)
531            .add(daemon, ResourceLifecycle::Live)
532            .build();
533
534        // Four of the five receiver vars without the token would make
535        // Receiver::from_env crash-loop at startup; the deploy must fail.
536        for token in [None, Some(""), Some("   \n")] {
537            let error = stack
538                .receiver_command_env_vars("https://cmd.example.test/v1", token)
539                .expect_err("missing or blank token must fail the env build");
540            assert_eq!(error.code, "COMMAND_TOKEN_MISSING");
541            assert!(error.to_string().contains("container-a"));
542        }
543
544        let original = "  nonempty-token  ";
545        let vars = stack
546            .receiver_command_env_vars("https://cmd.example.test/v1", Some(original))
547            .expect("non-empty token");
548        assert!(vars
549            .iter()
550            .filter(|var| var.name == crate::ENV_ALIEN_COMMANDS_TOKEN)
551            .all(|var| var.value == original));
552    }
553
554    #[test]
555    fn receiver_command_env_vars_empty_without_command_targets() {
556        let worker = Worker::new("worker-only".to_string())
557            .code(WorkerCode::Image {
558                image: "worker:latest".to_string(),
559            })
560            .permissions("execution".to_string())
561            .commands_enabled(true)
562            .build();
563
564        let stack = Stack::new("receiver-worker-only-stack".to_string())
565            .add(worker, ResourceLifecycle::Live)
566            .build();
567
568        // Only a Worker target exists → receiver helper yields nothing.
569        assert!(stack
570            .receiver_command_env_vars("https://cmd.example.test/v1", Some("tok"))
571            .expect("no command targets")
572            .is_empty());
573    }
574}