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/// Build a per-launch *unique* DNS-1123-safe Pod name. The deterministic
271/// [`pod_name_for`] name collides whenever the warm pool needs more than one
272/// concurrent instance of a function (the RIE serves one invocation at a time),
273/// and a still-terminating Pod blocks its replacement (`AlreadyExists` /
274/// "object is being deleted" / `NotFound` races). Salting the hashed id with a
275/// process-monotonic counter yields a fresh, length-bounded name per launch, so
276/// instances never collide and a dying Pod never wedges a new one.
277pub 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
285/// Map the function's `memory_size` (MiB) onto both `requests` and
286/// `limits`. Mirrors real Lambda: CPU is implicitly proportional to
287/// memory, but k8s requires explicit values, so we set memory only and
288/// leave CPU unlimited (clusters with LimitRange policies can layer
289/// their own defaults).
290fn 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        // Bearer header references env, not the literal token, so we
391        // don't leak the secret into describe output / logs.
392        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        // Same function + deploy_id must still yield distinct names so the
576        // warm pool can run more than one concurrent instance and a
577        // still-terminating Pod never blocks its replacement.
578        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        // Hostile inputs and a large counter must stay DNS-1123-safe and
589        // within the 63-char Pod-name limit.
590        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 per_function_tags_override_base_pod_config() {
601        use fakecloud_k8s::K8sPodConfig;
602        use std::collections::BTreeMap;
603
604        // Backend base (global + service env): one node-selector key.
605        let base = K8sPodConfig {
606            node_selector: BTreeMap::from([
607                ("disktype".to_string(), "ssd".to_string()),
608                ("zone".to_string(), "a".to_string()),
609            ]),
610            ..Default::default()
611        };
612
613        // Function carries a reserved tag overriding disktype and adding
614        // an annotation.
615        let mut f = zip_function("my-fn");
616        f.tags
617            .insert("fakecloud-k8s/node-selector".into(), "disktype=nvme".into());
618        f.tags
619            .insert("fakecloud-k8s/annotations".into(), "team=core".into());
620
621        let mut pod = build_pod_spec(&f, "d", &ctx()).unwrap();
622        // Mirror the launch() contract: base merged with per-function tags.
623        base.merge(K8sPodConfig::from_tags(&f.tags)).apply(&mut pod);
624
625        let selector = pod.spec.unwrap().node_selector.unwrap();
626        // Tag wins on disktype; the base-only zone key survives.
627        assert_eq!(selector.get("disktype").map(String::as_str), Some("nvme"));
628        assert_eq!(selector.get("zone").map(String::as_str), Some("a"));
629        assert_eq!(
630            pod.metadata
631                .annotations
632                .unwrap()
633                .get("team")
634                .map(String::as_str),
635            Some("core")
636        );
637    }
638
639    #[test]
640    fn restart_policy_never() {
641        let f = zip_function("my-fn");
642        let pod = build_pod_spec(&f, "d", &ctx()).unwrap();
643        assert_eq!(pod.spec.unwrap().restart_policy.as_deref(), Some("Never"));
644    }
645
646    #[test]
647    fn unsupported_runtime_errors() {
648        let mut f = zip_function("my-fn");
649        f.runtime = "cobol1.0".into();
650        let err = build_pod_spec(&f, "d", &ctx()).unwrap_err();
651        assert!(err.contains("unsupported runtime"));
652    }
653
654    #[test]
655    fn image_function_without_uri_errors() {
656        let mut f = zip_function("my-fn");
657        f.package_type = "Image".into();
658        f.image_uri = None;
659        let err = build_pod_spec(&f, "d", &ctx()).unwrap_err();
660        assert!(err.contains("ImageUri"));
661    }
662}