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