1use crate::error::{ErrorData, Result};
7use crate::{EnvironmentVariable, EnvironmentVariableType, Stack};
8use alien_error::AlienError;
9
10fn 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 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 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 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 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
164fn 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 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 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 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 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 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 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 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 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 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 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 assert!(stack
570 .receiver_command_env_vars("https://cmd.example.test/v1", Some("tok"))
571 .expect("no command targets")
572 .is_empty());
573 }
574}