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 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
21/// Inputs that don't come from the function itself — instance identity,
22/// the in-cluster fakecloud URL, the bearer token the init container
23/// uses to fetch code/layers, etc.
24pub struct PodSpecContext<'a> {
25    pub instance_id: &'a str,
26    pub namespace: &'a str,
27    /// In-cluster URL of the fakecloud server (e.g.
28    /// `http://fakecloud.fakecloud.svc.cluster.local:4566`). Init
29    /// containers fetch code + layers from this host.
30    pub self_url: &'a str,
31    /// Host part of `self_url` — used to rewrite `localhost`/`127.0.0.1`
32    /// env values so user code can reach fakecloud from inside the Pod.
33    pub self_host: &'a str,
34    /// Host:port for the fakecloud ECR endpoint (for `PackageType=Image`
35    /// functions that reference AWS private-ECR URIs).
36    pub ecr_host: &'a str,
37    pub ecr_port: u16,
38    /// Bearer token the init container presents when fetching code +
39    /// layers from `self_url`. Never logged.
40    pub internal_token: &'a str,
41    /// Account ID owning the function. Embedded in init-container URLs
42    /// so the artifact endpoint can find the right LambdaState.
43    pub account_id: &'a str,
44    /// Optional name of a Kubernetes `Secret` of type
45    /// `kubernetes.io/dockerconfigjson` used as `imagePullSecrets` for
46    /// container-image functions.
47    pub pull_secret: Option<&'a str>,
48}
49
50/// Resource cap for the `/tmp` `emptyDir` (`medium: Memory`) — sized
51/// from the function's `ephemeral_storage_size`. AWS defaults to 512
52/// MiB; clamp at 64 MiB so wildly stale snapshots still produce a spec
53/// the kubelet accepts.
54fn ephemeral_storage_mib(size: Option<i64>) -> i64 {
55    size.unwrap_or(512).max(64)
56}
57
58/// Build the Pod spec for a single Lambda function invocation runtime.
59/// The Pod has one init container (busybox) that downloads code + layers
60/// from fakecloud over HTTP, and one main container running the AWS RIE
61/// image (zip functions) or the user-supplied image (image functions).
62pub 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    // Env vars — user-supplied (with localhost rewritten to in-cluster
100    // fakecloud host) + the AWS_LAMBDA_FUNCTION_TIMEOUT the RIE honors.
101    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    // Shared volumes — init container writes /var/task, /opt, layers;
116    // main container reads them. `/tmp` is sized from ephemeral_storage.
117    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    // Init container: busybox + wget. Downloads code zip + layers tar
158    // and unpacks them into the shared emptyDir volumes. For
159    // image-package functions the code+layers endpoint short-circuits
160    // (image already contains code) so we only fetch the layers tar.
161    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        // busybox ships `wget`, `unzip`, `tar` — covers everything the
212        // bootstrap script needs without depending on what's in the
213        // runtime image.
214        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    // Main container — runs the RIE image (zip) or the user's image.
222    // For zip functions the handler is passed as the cmd argument, same
223    // as Docker (`docker create <image> <handler>`).
224    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
262/// Build a deterministic, DNS-1123-safe Pod name for the given
263/// function + deploy_id. Truncated/lowercased so it fits the 63-char
264/// label limit. The suffix is a stable hash of `deploy_id` so a new
265/// deploy gets a fresh, non-colliding Pod name.
266pub 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
270/// Map the function's `memory_size` (MiB) onto both `requests` and
271/// `limits`. Mirrors real Lambda: CPU is implicitly proportional to
272/// memory, but k8s requires explicit values, so we set memory only and
273/// leave CPU unlimited (clusters with LimitRange policies can layer
274/// their own defaults).
275fn memory_resources(memory_size: i64) -> ResourceRequirements {
276    let mut req = BTreeMap::new();
277    req.insert(
278        "memory".to_string(),
279        Quantity(format!("{}Mi", memory_size.max(128))),
280    );
281    let mut lim = BTreeMap::new();
282    lim.insert(
283        "memory".to_string(),
284        Quantity(format!("{}Mi", memory_size.max(128))),
285    );
286    ResourceRequirements {
287        requests: Some(req),
288        limits: Some(lim),
289        claims: None,
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::state::LambdaFunction;
297    use chrono::Utc;
298
299    fn ctx<'a>() -> PodSpecContext<'a> {
300        PodSpecContext {
301            instance_id: "fakecloud-1234",
302            namespace: "fakecloud",
303            self_url: "http://fakecloud.fakecloud.svc.cluster.local:4566",
304            self_host: "fakecloud.fakecloud.svc.cluster.local",
305            ecr_host: "fakecloud.fakecloud.svc.cluster.local",
306            ecr_port: 4566,
307            internal_token: "secret-token-xyz",
308            account_id: "000000000000",
309            pull_secret: None,
310        }
311    }
312
313    fn zip_function(name: &str) -> LambdaFunction {
314        let mut f = LambdaFunction {
315            function_name: name.into(),
316            function_arn: format!("arn:aws:lambda:us-east-1:000000000000:function:{name}"),
317            runtime: "python3.12".into(),
318            role: "arn:aws:iam::000000000000:role/test".into(),
319            handler: "lambda_function.lambda_handler".into(),
320            description: String::new(),
321            timeout: 30,
322            memory_size: 256,
323            code_sha256: "abc".into(),
324            code_size: 0,
325            version: "$LATEST".into(),
326            last_modified: Utc::now(),
327            tags: BTreeMap::new(),
328            environment: BTreeMap::new(),
329            architectures: vec!["x86_64".into()],
330            package_type: "Zip".into(),
331            code_zip: None,
332            image_uri: None,
333            policy: None,
334            layers: Vec::new(),
335            revision_id: "rev-1".into(),
336            tracing_mode: None,
337            kms_key_arn: None,
338            ephemeral_storage_size: Some(1024),
339            vpc_config: None,
340            snap_start: None,
341            dead_letter_config_arn: None,
342            file_system_configs: Vec::new(),
343            logging_config: None,
344            image_config: None,
345            durable_config: None,
346            signing_profile_version_arn: None,
347            signing_job_arn: None,
348            runtime_version_config: None,
349            master_arn: None,
350            state_reason: None,
351            state_reason_code: None,
352            last_update_status_reason: None,
353            last_update_status_reason_code: None,
354        };
355        f.environment
356            .insert("FAKECLOUD_URL".into(), "http://localhost:4566".into());
357        f
358    }
359
360    #[test]
361    fn zip_pod_has_init_container_with_code_download() {
362        let f = zip_function("my-fn");
363        let pod = build_pod_spec(&f, "deploy-xyz", &ctx()).unwrap();
364        let spec = pod.spec.unwrap();
365        let init = &spec.init_containers.unwrap()[0];
366        let script = init.command.as_ref().unwrap().last().unwrap();
367        assert!(
368            script.contains("code/$ACCT/$FN/$DEPLOY_ID.zip"),
369            "init script must include code download for zip functions: {script}"
370        );
371        assert!(
372            script.contains("layers/$ACCT/$FN/$DEPLOY_ID.tar"),
373            "init script must include layers download: {script}"
374        );
375        // Bearer header references env, not the literal token, so we
376        // don't leak the secret into describe output / logs.
377        assert!(script.contains("$FAKECLOUD_INTERNAL_TOKEN"));
378        assert!(!script.contains("secret-token-xyz"));
379    }
380
381    #[test]
382    fn image_pod_skips_code_download() {
383        let mut f = zip_function("img-fn");
384        f.package_type = "Image".into();
385        f.image_uri = Some("public.ecr.aws/test/img:1".into());
386        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
387        let init = &pod.spec.unwrap().init_containers.unwrap()[0];
388        let script = init.command.as_ref().unwrap().last().unwrap();
389        assert!(!script.contains("code/"));
390        assert!(script.contains("layers/"));
391    }
392
393    #[test]
394    fn image_pod_translates_aws_ecr_uri() {
395        let mut f = zip_function("img-fn");
396        f.package_type = "Image".into();
397        f.image_uri = Some("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo:tag".into());
398        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
399        let image = pod.spec.unwrap().containers[0].image.clone().unwrap();
400        assert_eq!(image, "fakecloud.fakecloud.svc.cluster.local:4566/repo:tag");
401    }
402
403    #[test]
404    fn image_pod_leaves_non_ecr_uri_alone() {
405        let mut f = zip_function("img-fn");
406        f.package_type = "Image".into();
407        f.image_uri = Some("public.ecr.aws/test/img:1".into());
408        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
409        let image = pod.spec.unwrap().containers[0].image.clone().unwrap();
410        assert_eq!(image, "public.ecr.aws/test/img:1");
411    }
412
413    #[test]
414    fn zip_pod_uses_runtime_to_image_for_main_container() {
415        let f = zip_function("my-fn");
416        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
417        let image = pod.spec.unwrap().containers[0].image.clone().unwrap();
418        assert_eq!(image, "public.ecr.aws/lambda/python:3.12");
419    }
420
421    #[test]
422    fn handler_passed_as_args_for_zip_pod() {
423        let f = zip_function("my-fn");
424        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
425        let args = pod.spec.unwrap().containers[0].args.clone().unwrap();
426        assert_eq!(args, vec!["lambda_function.lambda_handler"]);
427    }
428
429    #[test]
430    fn env_vars_rewrite_localhost_to_self_host() {
431        let f = zip_function("my-fn");
432        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
433        let env = pod.spec.unwrap().containers[0].env.clone().unwrap();
434        let url = env
435            .iter()
436            .find(|e| e.name == "FAKECLOUD_URL")
437            .unwrap()
438            .value
439            .clone()
440            .unwrap();
441        assert_eq!(url, "http://fakecloud.fakecloud.svc.cluster.local:4566");
442    }
443
444    #[test]
445    fn function_timeout_env_set() {
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 t = env
450            .iter()
451            .find(|e| e.name == "AWS_LAMBDA_FUNCTION_TIMEOUT")
452            .unwrap()
453            .value
454            .clone()
455            .unwrap();
456        assert_eq!(t, "30");
457    }
458
459    #[test]
460    fn ephemeral_storage_maps_to_emptydir_memory_with_size_limit() {
461        let f = zip_function("my-fn");
462        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
463        let vol = pod
464            .spec
465            .unwrap()
466            .volumes
467            .unwrap()
468            .into_iter()
469            .find(|v| v.name == "fakecloud-tmp")
470            .unwrap();
471        let ed = vol.empty_dir.unwrap();
472        assert_eq!(ed.medium.as_deref(), Some("Memory"));
473        assert_eq!(ed.size_limit.unwrap().0, "1024Mi");
474    }
475
476    #[test]
477    fn ephemeral_storage_defaults_to_512mib() {
478        let mut f = zip_function("my-fn");
479        f.ephemeral_storage_size = None;
480        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
481        let vol = pod
482            .spec
483            .unwrap()
484            .volumes
485            .unwrap()
486            .into_iter()
487            .find(|v| v.name == "fakecloud-tmp")
488            .unwrap();
489        assert_eq!(vol.empty_dir.unwrap().size_limit.unwrap().0, "512Mi");
490    }
491
492    #[test]
493    fn memory_size_maps_to_resources_requests_and_limits() {
494        let f = zip_function("my-fn");
495        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
496        let res = pod.spec.unwrap().containers[0].resources.clone().unwrap();
497        assert_eq!(res.requests.unwrap().get("memory").unwrap().0, "256Mi");
498        assert_eq!(res.limits.unwrap().get("memory").unwrap().0, "256Mi");
499    }
500
501    #[test]
502    fn labels_identify_fakecloud_ownership() {
503        let f = zip_function("my-fn");
504        let pod = build_pod_spec(&f, "deploy-xyz", &ctx()).unwrap();
505        let labels = pod.metadata.labels.unwrap();
506        assert_eq!(
507            labels.get("fakecloud-managed-by"),
508            Some(&"fakecloud".into())
509        );
510        assert_eq!(
511            labels.get("fakecloud-instance"),
512            Some(&"fakecloud-1234".into())
513        );
514        assert_eq!(labels.get("fakecloud-lambda"), Some(&"my-fn".into()));
515        assert!(labels.contains_key("fakecloud-deploy-id"));
516    }
517
518    #[test]
519    fn pull_secret_attached_when_configured() {
520        let f = zip_function("my-fn");
521        let mut c = ctx();
522        c.pull_secret = Some("fakecloud-ecr-secret");
523        let pod = build_pod_spec(&f, "d", &c).unwrap();
524        let secrets = pod.spec.unwrap().image_pull_secrets.unwrap();
525        assert_eq!(secrets.len(), 1);
526        assert_eq!(secrets[0].name, "fakecloud-ecr-secret");
527    }
528
529    #[test]
530    fn no_pull_secret_when_unset() {
531        let f = zip_function("my-fn");
532        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
533        assert!(pod.spec.unwrap().image_pull_secrets.is_none());
534    }
535
536    #[test]
537    fn pod_name_is_dns1123_safe() {
538        let name = pod_name_for("My_Awesome_Function", "abc/123+def==");
539        assert!(name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
540        assert!(!name.ends_with('-'));
541        assert!(name.len() <= 63);
542    }
543
544    #[test]
545    fn pod_name_is_stable_for_same_inputs() {
546        let a = pod_name_for("fn", "deploy");
547        let b = pod_name_for("fn", "deploy");
548        assert_eq!(a, b);
549    }
550
551    #[test]
552    fn pod_name_differs_when_deploy_id_changes() {
553        let a = pod_name_for("fn", "deploy-1");
554        let b = pod_name_for("fn", "deploy-2");
555        assert_ne!(a, b);
556    }
557
558    #[test]
559    fn restart_policy_never() {
560        let f = zip_function("my-fn");
561        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
562        assert_eq!(pod.spec.unwrap().restart_policy.as_deref(), Some("Never"));
563    }
564
565    #[test]
566    fn unsupported_runtime_errors() {
567        let mut f = zip_function("my-fn");
568        f.runtime = "cobol1.0".into();
569        let err = build_pod_spec(&f, "d", &ctx()).unwrap_err();
570        assert!(err.contains("unsupported runtime"));
571    }
572
573    #[test]
574    fn image_function_without_uri_errors() {
575        let mut f = zip_function("my-fn");
576        f.package_type = "Image".into();
577        f.image_uri = None;
578        let err = build_pod_spec(&f, "d", &ctx()).unwrap_err();
579        assert!(err.contains("ImageUri"));
580    }
581}