Skip to main content

fakecloud_lambda/runtime/k8s/
spec.rs

1//! Pod spec construction for the Kubernetes [`super::K8sBackend`].
2//!
3//! Pure functions over `k8s_openapi::api::core::v1::Pod` — no cluster
4//! interaction, fully unit-testable.
5
6use 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
19/// Inputs that don't come from the function itself — instance identity,
20/// the in-cluster fakecloud URL, the bearer token the init container
21/// uses to fetch code/layers, etc.
22pub struct PodSpecContext<'a> {
23    pub instance_id: &'a str,
24    pub namespace: &'a str,
25    /// In-cluster URL of the fakecloud server (e.g.
26    /// `http://fakecloud.fakecloud.svc.cluster.local:4566`). Init
27    /// containers fetch code + layers from this host.
28    pub self_url: &'a str,
29    /// Host part of `self_url` — used to rewrite `localhost`/`127.0.0.1`
30    /// env values so user code can reach fakecloud from inside the Pod.
31    pub self_host: &'a str,
32    /// Host:port for the fakecloud ECR endpoint (for `PackageType=Image`
33    /// functions that reference AWS private-ECR URIs).
34    pub ecr_host: &'a str,
35    pub ecr_port: u16,
36    /// Bearer token the init container presents when fetching code +
37    /// layers from `self_url`. Never logged.
38    pub internal_token: &'a str,
39    /// Account ID owning the function. Embedded in init-container URLs
40    /// so the artifact endpoint can find the right LambdaState.
41    pub account_id: &'a str,
42    /// Optional name of a Kubernetes `Secret` of type
43    /// `kubernetes.io/dockerconfigjson` used as `imagePullSecrets` for
44    /// container-image functions.
45    pub pull_secret: Option<&'a str>,
46}
47
48/// Resource cap for the `/tmp` `emptyDir` (`medium: Memory`) — sized
49/// from the function's `ephemeral_storage_size`. AWS defaults to 512
50/// MiB; clamp at 64 MiB so wildly stale snapshots still produce a spec
51/// the kubelet accepts.
52fn ephemeral_storage_mib(size: Option<i64>) -> i64 {
53    size.unwrap_or(512).max(64)
54}
55
56/// Build the Pod spec for a single Lambda function invocation runtime.
57/// The Pod has one init container (busybox) that downloads code + layers
58/// from fakecloud over HTTP, and one main container running the AWS RIE
59/// image (zip functions) or the user-supplied image (image functions).
60pub 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    // Env vars — user-supplied (with localhost rewritten to in-cluster
91    // fakecloud host) + the AWS_LAMBDA_FUNCTION_TIMEOUT the RIE honors.
92    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    // Shared volumes — init container writes /var/task, /opt, layers;
107    // main container reads them. `/tmp` is sized from ephemeral_storage.
108    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    // Init container: busybox + wget. Downloads code zip + layers tar
149    // and unpacks them into the shared emptyDir volumes. For
150    // image-package functions the code+layers endpoint short-circuits
151    // (image already contains code) so we only fetch the layers tar.
152    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        // busybox ships `wget`, `unzip`, `tar` — covers everything the
203        // bootstrap script needs without depending on what's in the
204        // runtime image.
205        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    // Main container — runs the RIE image (zip) or the user's image.
213    // For zip functions the handler is passed as the cmd argument, same
214    // as Docker (`docker create <image> <handler>`).
215    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
253/// Build a deterministic, DNS-1123-safe Pod name for the given
254/// function + deploy_id. Truncated/lowercased so it fits the 63-char
255/// label limit.
256pub fn pod_name_for(function_name: &str, deploy_id: &str) -> String {
257    // function_name may contain underscores or uppercase; convert.
258    let fn_part = label_safe(function_name);
259    // deploy_id is base64 — pick the first 12 chars of a hex projection
260    // so the suffix is stable across restarts and DNS-safe.
261    let digest = simple_hex12(deploy_id);
262    // Keep total length under 63. fn_part already <=40, digest is 12,
263    // prefix "fakecloud-lambda-" is 17. Trim function part to 30.
264    let fn_short: String = fn_part.chars().take(30).collect();
265    format!("fakecloud-lambda-{fn_short}-{digest}")
266}
267
268/// Map the function's `memory_size` (MiB) onto both `requests` and
269/// `limits`. Mirrors real Lambda: CPU is implicitly proportional to
270/// memory, but k8s requires explicit values, so we set memory only and
271/// leave CPU unlimited (clusters with LimitRange policies can layer
272/// their own defaults).
273fn 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
291/// Lowercase + replace anything outside `[a-z0-9-]` with `-`. DNS-1123
292/// label rules: 63 chars max, ends with alphanumeric.
293fn 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    // Trim trailing dashes so the label ends in an alphanumeric.
305    while out.ends_with('-') {
306        out.pop();
307    }
308    out
309}
310
311/// 12-char hex projection of an arbitrary string, used to suffix Pod
312/// names so deploy_id changes produce a new Pod without colliding with
313/// the old one. Deterministic so the watcher and the launch step agree
314/// on the name. Not a cryptographic hash — collision space (2^48) is
315/// plenty for per-function uniqueness.
316fn 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        // Bearer header references env, not the literal token, so we
408        // don't leak the secret into describe output / logs.
409        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}