1use std::collections::BTreeMap;
7
8use k8s_openapi::api::core::v1::{
9 Container, EmptyDirVolumeSource, EnvVar, LocalObjectReference, Pod, PodSpec,
10 ResourceRequirements, Volume, VolumeMount,
11};
12use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
13use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
14
15use fakecloud_k8s::names::label_safe;
16
17use super::super::docker::runtime_to_image;
18use super::super::env_rewrite::rewrite_localhost_envs;
19use crate::state::LambdaFunction;
20
21pub struct PodSpecContext<'a> {
25 pub instance_id: &'a str,
26 pub namespace: &'a str,
27 pub self_url: &'a str,
31 pub self_host: &'a str,
34 pub ecr_host: &'a str,
37 pub ecr_port: u16,
38 pub internal_token: &'a str,
41 pub account_id: &'a str,
44 pub pull_secret: Option<&'a str>,
48}
49
50fn ephemeral_storage_mib(size: Option<i64>) -> i64 {
55 size.unwrap_or(512).max(64)
56}
57
58pub fn build_pod_spec(
63 func: &LambdaFunction,
64 deploy_id: &str,
65 ctx: &PodSpecContext<'_>,
66) -> Result<Pod, String> {
67 let is_image = func.package_type == "Image";
68
69 let main_image = if is_image {
70 let raw = func
71 .image_uri
72 .as_deref()
73 .ok_or_else(|| "PackageType=Image function has no ImageUri".to_string())?;
74 fakecloud_core::ecr_uri::translate_to_local_at(raw, ctx.ecr_host, ctx.ecr_port)
75 .unwrap_or_else(|| raw.to_string())
76 } else {
77 runtime_to_image(&func.runtime)
78 .ok_or_else(|| format!("unsupported runtime: {}", func.runtime))?
79 };
80
81 let pod_name = pod_name_for(&func.function_name, deploy_id);
82
83 let mut labels = BTreeMap::new();
84 labels.insert(
85 fakecloud_k8s::labels::MANAGED_BY.into(),
86 fakecloud_k8s::labels::MANAGED_BY_VALUE.into(),
87 );
88 labels.insert(
89 fakecloud_k8s::labels::INSTANCE.into(),
90 ctx.instance_id.into(),
91 );
92 labels.insert(fakecloud_k8s::labels::SERVICE.into(), super::SERVICE.into());
93 labels.insert("fakecloud-lambda".into(), label_safe(&func.function_name));
94 labels.insert(
95 "fakecloud-deploy-id".into(),
96 label_safe(&deploy_id[..deploy_id.len().min(40)]),
97 );
98
99 let mut env: Vec<EnvVar> = rewrite_localhost_envs(&func.environment, ctx.self_host)
102 .into_iter()
103 .map(|(k, v)| EnvVar {
104 name: k,
105 value: Some(v),
106 value_from: None,
107 })
108 .collect();
109 env.push(EnvVar {
110 name: "AWS_LAMBDA_FUNCTION_TIMEOUT".into(),
111 value: Some(func.timeout.to_string()),
112 value_from: None,
113 });
114
115 let tmp_mib = ephemeral_storage_mib(func.ephemeral_storage_size);
118 let volumes = vec![
119 Volume {
120 name: "fakecloud-task".into(),
121 empty_dir: Some(EmptyDirVolumeSource::default()),
122 ..Volume::default()
123 },
124 Volume {
125 name: "fakecloud-opt".into(),
126 empty_dir: Some(EmptyDirVolumeSource::default()),
127 ..Volume::default()
128 },
129 Volume {
130 name: "fakecloud-tmp".into(),
131 empty_dir: Some(EmptyDirVolumeSource {
132 medium: Some("Memory".into()),
133 size_limit: Some(Quantity(format!("{tmp_mib}Mi"))),
134 }),
135 ..Volume::default()
136 },
137 ];
138
139 let common_mounts = vec![
140 VolumeMount {
141 name: "fakecloud-task".into(),
142 mount_path: "/var/task".into(),
143 ..VolumeMount::default()
144 },
145 VolumeMount {
146 name: "fakecloud-opt".into(),
147 mount_path: "/opt".into(),
148 ..VolumeMount::default()
149 },
150 VolumeMount {
151 name: "fakecloud-tmp".into(),
152 mount_path: "/tmp".into(),
153 ..VolumeMount::default()
154 },
155 ];
156
157 let init_script = if is_image {
162 "set -eu; \
163 wget -q --header=\"authorization: Bearer $FAKECLOUD_INTERNAL_TOKEN\" \
164 -O /tmp/layers.tar \
165 \"$FAKECLOUD_SELF_URL/_fakecloud/lambda/_internal/layers/$ACCT/$FN/$DEPLOY_ID.tar\"; \
166 if [ -s /tmp/layers.tar ]; then tar -xf /tmp/layers.tar -C /opt; fi"
167 .to_string()
168 } else {
169 "set -eu; \
170 wget -q --header=\"authorization: Bearer $FAKECLOUD_INTERNAL_TOKEN\" \
171 -O /tmp/code.zip \
172 \"$FAKECLOUD_SELF_URL/_fakecloud/lambda/_internal/code/$ACCT/$FN/$DEPLOY_ID.zip\"; \
173 unzip -q /tmp/code.zip -d /var/task; \
174 wget -q --header=\"authorization: Bearer $FAKECLOUD_INTERNAL_TOKEN\" \
175 -O /tmp/layers.tar \
176 \"$FAKECLOUD_SELF_URL/_fakecloud/lambda/_internal/layers/$ACCT/$FN/$DEPLOY_ID.tar\"; \
177 if [ -s /tmp/layers.tar ]; then tar -xf /tmp/layers.tar -C /opt; fi"
178 .to_string()
179 };
180
181 let init_env = vec![
182 EnvVar {
183 name: "FAKECLOUD_SELF_URL".into(),
184 value: Some(ctx.self_url.into()),
185 value_from: None,
186 },
187 EnvVar {
188 name: "FAKECLOUD_INTERNAL_TOKEN".into(),
189 value: Some(ctx.internal_token.into()),
190 value_from: None,
191 },
192 EnvVar {
193 name: "ACCT".into(),
194 value: Some(ctx.account_id.into()),
195 value_from: None,
196 },
197 EnvVar {
198 name: "FN".into(),
199 value: Some(func.function_name.clone()),
200 value_from: None,
201 },
202 EnvVar {
203 name: "DEPLOY_ID".into(),
204 value: Some(deploy_id.into()),
205 value_from: None,
206 },
207 ];
208
209 let init_container = Container {
210 name: "fakecloud-init".into(),
211 image: Some("busybox:1.36".into()),
215 command: Some(vec!["sh".into(), "-c".into(), init_script]),
216 env: Some(init_env),
217 volume_mounts: Some(common_mounts.clone()),
218 ..Container::default()
219 };
220
221 let mut main_container = Container {
225 name: "fakecloud-lambda".into(),
226 image: Some(main_image.clone()),
227 env: Some(env),
228 volume_mounts: Some(common_mounts),
229 resources: Some(memory_resources(func.memory_size)),
230 ..Container::default()
231 };
232 if !is_image {
233 main_container.command = None;
234 main_container.args = Some(vec![func.handler.clone()]);
235 }
236
237 let pull_secrets = ctx.pull_secret.map(|name| {
238 vec![LocalObjectReference {
239 name: name.to_string(),
240 }]
241 });
242
243 Ok(Pod {
244 metadata: ObjectMeta {
245 name: Some(pod_name),
246 namespace: Some(ctx.namespace.to_string()),
247 labels: Some(labels),
248 ..ObjectMeta::default()
249 },
250 spec: Some(PodSpec {
251 restart_policy: Some("Never".into()),
252 init_containers: Some(vec![init_container]),
253 containers: vec![main_container],
254 volumes: Some(volumes),
255 image_pull_secrets: pull_secrets,
256 ..PodSpec::default()
257 }),
258 ..Pod::default()
259 })
260}
261
262pub fn pod_name_for(function_name: &str, deploy_id: &str) -> String {
267 fakecloud_k8s::names::pod_name("fakecloud-lambda", function_name, deploy_id)
268}
269
270pub fn unique_pod_name(function_name: &str, deploy_id: &str) -> String {
278 use std::sync::atomic::{AtomicU64, Ordering};
279 static SEQ: AtomicU64 = AtomicU64::new(0);
280 let n = SEQ.fetch_add(1, Ordering::Relaxed);
281 let salted = format!("{deploy_id}-{n}");
282 fakecloud_k8s::names::pod_name("fakecloud-lambda", function_name, &salted)
283}
284
285fn memory_resources(memory_size: i64) -> ResourceRequirements {
291 let mut req = BTreeMap::new();
292 req.insert(
293 "memory".to_string(),
294 Quantity(format!("{}Mi", memory_size.max(128))),
295 );
296 let mut lim = BTreeMap::new();
297 lim.insert(
298 "memory".to_string(),
299 Quantity(format!("{}Mi", memory_size.max(128))),
300 );
301 ResourceRequirements {
302 requests: Some(req),
303 limits: Some(lim),
304 claims: None,
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::state::LambdaFunction;
312 use chrono::Utc;
313
314 fn ctx<'a>() -> PodSpecContext<'a> {
315 PodSpecContext {
316 instance_id: "fakecloud-1234",
317 namespace: "fakecloud",
318 self_url: "http://fakecloud.fakecloud.svc.cluster.local:4566",
319 self_host: "fakecloud.fakecloud.svc.cluster.local",
320 ecr_host: "fakecloud.fakecloud.svc.cluster.local",
321 ecr_port: 4566,
322 internal_token: "secret-token-xyz",
323 account_id: "000000000000",
324 pull_secret: None,
325 }
326 }
327
328 fn zip_function(name: &str) -> LambdaFunction {
329 let mut f = LambdaFunction {
330 function_name: name.into(),
331 function_arn: format!("arn:aws:lambda:us-east-1:000000000000:function:{name}"),
332 runtime: "python3.12".into(),
333 role: "arn:aws:iam::000000000000:role/test".into(),
334 handler: "lambda_function.lambda_handler".into(),
335 description: String::new(),
336 timeout: 30,
337 memory_size: 256,
338 code_sha256: "abc".into(),
339 code_size: 0,
340 version: "$LATEST".into(),
341 last_modified: Utc::now(),
342 tags: BTreeMap::new(),
343 environment: BTreeMap::new(),
344 architectures: vec!["x86_64".into()],
345 package_type: "Zip".into(),
346 code_zip: None,
347 image_uri: None,
348 policy: None,
349 layers: Vec::new(),
350 revision_id: "rev-1".into(),
351 tracing_mode: None,
352 kms_key_arn: None,
353 ephemeral_storage_size: Some(1024),
354 vpc_config: None,
355 snap_start: None,
356 dead_letter_config_arn: None,
357 file_system_configs: Vec::new(),
358 logging_config: None,
359 image_config: None,
360 durable_config: None,
361 signing_profile_version_arn: None,
362 signing_job_arn: None,
363 runtime_version_config: None,
364 master_arn: None,
365 state_reason: None,
366 state_reason_code: None,
367 last_update_status_reason: None,
368 last_update_status_reason_code: None,
369 };
370 f.environment
371 .insert("FAKECLOUD_URL".into(), "http://localhost:4566".into());
372 f
373 }
374
375 #[test]
376 fn zip_pod_has_init_container_with_code_download() {
377 let f = zip_function("my-fn");
378 let pod = build_pod_spec(&f, "deploy-xyz", &ctx()).unwrap();
379 let spec = pod.spec.unwrap();
380 let init = &spec.init_containers.unwrap()[0];
381 let script = init.command.as_ref().unwrap().last().unwrap();
382 assert!(
383 script.contains("code/$ACCT/$FN/$DEPLOY_ID.zip"),
384 "init script must include code download for zip functions: {script}"
385 );
386 assert!(
387 script.contains("layers/$ACCT/$FN/$DEPLOY_ID.tar"),
388 "init script must include layers download: {script}"
389 );
390 assert!(script.contains("$FAKECLOUD_INTERNAL_TOKEN"));
393 assert!(!script.contains("secret-token-xyz"));
394 }
395
396 #[test]
397 fn image_pod_skips_code_download() {
398 let mut f = zip_function("img-fn");
399 f.package_type = "Image".into();
400 f.image_uri = Some("public.ecr.aws/test/img:1".into());
401 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
402 let init = &pod.spec.unwrap().init_containers.unwrap()[0];
403 let script = init.command.as_ref().unwrap().last().unwrap();
404 assert!(!script.contains("code/"));
405 assert!(script.contains("layers/"));
406 }
407
408 #[test]
409 fn image_pod_translates_aws_ecr_uri() {
410 let mut f = zip_function("img-fn");
411 f.package_type = "Image".into();
412 f.image_uri = Some("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo:tag".into());
413 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
414 let image = pod.spec.unwrap().containers[0].image.clone().unwrap();
415 assert_eq!(image, "fakecloud.fakecloud.svc.cluster.local:4566/repo:tag");
416 }
417
418 #[test]
419 fn image_pod_leaves_non_ecr_uri_alone() {
420 let mut f = zip_function("img-fn");
421 f.package_type = "Image".into();
422 f.image_uri = Some("public.ecr.aws/test/img:1".into());
423 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
424 let image = pod.spec.unwrap().containers[0].image.clone().unwrap();
425 assert_eq!(image, "public.ecr.aws/test/img:1");
426 }
427
428 #[test]
429 fn zip_pod_uses_runtime_to_image_for_main_container() {
430 let f = zip_function("my-fn");
431 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
432 let image = pod.spec.unwrap().containers[0].image.clone().unwrap();
433 assert_eq!(image, "public.ecr.aws/lambda/python:3.12");
434 }
435
436 #[test]
437 fn handler_passed_as_args_for_zip_pod() {
438 let f = zip_function("my-fn");
439 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
440 let args = pod.spec.unwrap().containers[0].args.clone().unwrap();
441 assert_eq!(args, vec!["lambda_function.lambda_handler"]);
442 }
443
444 #[test]
445 fn env_vars_rewrite_localhost_to_self_host() {
446 let f = zip_function("my-fn");
447 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
448 let env = pod.spec.unwrap().containers[0].env.clone().unwrap();
449 let url = env
450 .iter()
451 .find(|e| e.name == "FAKECLOUD_URL")
452 .unwrap()
453 .value
454 .clone()
455 .unwrap();
456 assert_eq!(url, "http://fakecloud.fakecloud.svc.cluster.local:4566");
457 }
458
459 #[test]
460 fn function_timeout_env_set() {
461 let f = zip_function("my-fn");
462 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
463 let env = pod.spec.unwrap().containers[0].env.clone().unwrap();
464 let t = env
465 .iter()
466 .find(|e| e.name == "AWS_LAMBDA_FUNCTION_TIMEOUT")
467 .unwrap()
468 .value
469 .clone()
470 .unwrap();
471 assert_eq!(t, "30");
472 }
473
474 #[test]
475 fn ephemeral_storage_maps_to_emptydir_memory_with_size_limit() {
476 let f = zip_function("my-fn");
477 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
478 let vol = pod
479 .spec
480 .unwrap()
481 .volumes
482 .unwrap()
483 .into_iter()
484 .find(|v| v.name == "fakecloud-tmp")
485 .unwrap();
486 let ed = vol.empty_dir.unwrap();
487 assert_eq!(ed.medium.as_deref(), Some("Memory"));
488 assert_eq!(ed.size_limit.unwrap().0, "1024Mi");
489 }
490
491 #[test]
492 fn ephemeral_storage_defaults_to_512mib() {
493 let mut f = zip_function("my-fn");
494 f.ephemeral_storage_size = None;
495 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
496 let vol = pod
497 .spec
498 .unwrap()
499 .volumes
500 .unwrap()
501 .into_iter()
502 .find(|v| v.name == "fakecloud-tmp")
503 .unwrap();
504 assert_eq!(vol.empty_dir.unwrap().size_limit.unwrap().0, "512Mi");
505 }
506
507 #[test]
508 fn memory_size_maps_to_resources_requests_and_limits() {
509 let f = zip_function("my-fn");
510 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
511 let res = pod.spec.unwrap().containers[0].resources.clone().unwrap();
512 assert_eq!(res.requests.unwrap().get("memory").unwrap().0, "256Mi");
513 assert_eq!(res.limits.unwrap().get("memory").unwrap().0, "256Mi");
514 }
515
516 #[test]
517 fn labels_identify_fakecloud_ownership() {
518 let f = zip_function("my-fn");
519 let pod = build_pod_spec(&f, "deploy-xyz", &ctx()).unwrap();
520 let labels = pod.metadata.labels.unwrap();
521 assert_eq!(
522 labels.get("fakecloud-managed-by"),
523 Some(&"fakecloud".into())
524 );
525 assert_eq!(
526 labels.get("fakecloud-instance"),
527 Some(&"fakecloud-1234".into())
528 );
529 assert_eq!(labels.get("fakecloud-lambda"), Some(&"my-fn".into()));
530 assert!(labels.contains_key("fakecloud-deploy-id"));
531 }
532
533 #[test]
534 fn pull_secret_attached_when_configured() {
535 let f = zip_function("my-fn");
536 let mut c = ctx();
537 c.pull_secret = Some("fakecloud-ecr-secret");
538 let pod = build_pod_spec(&f, "d", &c).unwrap();
539 let secrets = pod.spec.unwrap().image_pull_secrets.unwrap();
540 assert_eq!(secrets.len(), 1);
541 assert_eq!(secrets[0].name, "fakecloud-ecr-secret");
542 }
543
544 #[test]
545 fn no_pull_secret_when_unset() {
546 let f = zip_function("my-fn");
547 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
548 assert!(pod.spec.unwrap().image_pull_secrets.is_none());
549 }
550
551 #[test]
552 fn pod_name_is_dns1123_safe() {
553 let name = pod_name_for("My_Awesome_Function", "abc/123+def==");
554 assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
555 assert!(!name.ends_with('-'));
556 assert!(name.len() <= 63);
557 }
558
559 #[test]
560 fn pod_name_is_stable_for_same_inputs() {
561 let a = pod_name_for("fn", "deploy");
562 let b = pod_name_for("fn", "deploy");
563 assert_eq!(a, b);
564 }
565
566 #[test]
567 fn pod_name_differs_when_deploy_id_changes() {
568 let a = pod_name_for("fn", "deploy-1");
569 let b = pod_name_for("fn", "deploy-2");
570 assert_ne!(a, b);
571 }
572
573 #[test]
574 fn unique_pod_name_differs_across_calls() {
575 let a = unique_pod_name("fn", "deploy");
579 let b = unique_pod_name("fn", "deploy");
580 let c = unique_pod_name("fn", "deploy");
581 assert_ne!(a, b);
582 assert_ne!(b, c);
583 assert_ne!(a, c);
584 }
585
586 #[test]
587 fn unique_pod_name_is_dns1123_safe_and_bounded() {
588 for _ in 0..1000 {
591 let name = unique_pod_name("My_Awesome_Function", "abc/123+def==");
592 assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
593 assert!(!name.starts_with('-'));
594 assert!(!name.ends_with('-'));
595 assert!(name.len() <= 63);
596 }
597 }
598
599 #[test]
600 fn restart_policy_never() {
601 let f = zip_function("my-fn");
602 let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
603 assert_eq!(pod.spec.unwrap().restart_policy.as_deref(), Some("Never"));
604 }
605
606 #[test]
607 fn unsupported_runtime_errors() {
608 let mut f = zip_function("my-fn");
609 f.runtime = "cobol1.0".into();
610 let err = build_pod_spec(&f, "d", &ctx()).unwrap_err();
611 assert!(err.contains("unsupported runtime"));
612 }
613
614 #[test]
615 fn image_function_without_uri_errors() {
616 let mut f = zip_function("my-fn");
617 f.package_type = "Image".into();
618 f.image_uri = None;
619 let err = build_pod_spec(&f, "d", &ctx()).unwrap_err();
620 assert!(err.contains("ImageUri"));
621 }
622}