Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/jera/src/vm.rs

//! **No-shell KVM/VM boot** seam — map a [`BootSpec`] onto **draupnir**'s KVM
//! backend and drive its lifecycle as a job.
//!
//! A [`BootSpec`] is a self-contained boot recipe (kernel/rootfs/disk, machine
//! size, kernel cmdline). The [`VmBootRunner`] seam turns it into a live
//! [`BootProc`]:
//!
//! - [`StubVmBootRunner`] — the default when draupnir's KVM backend is not compiled
//!   in: it maps the spec but reports a clear, honest failure rather than
//!   pretending to boot.
//! - [`DraupnirVmBootRunner`] (feature `vm-tunnr`) — the REAL boot: hands the spec
//!   to `draupnir::kvm::KvmBoot` (which drives tunnr's
//!   `tunnr_vm::boot_test(BootSpec) -> BootHandle` KVM primitive) and adapts the
//!   returned [`draupnir::Machine`] + [`draupnir::Lifecycle`] into a [`BootProc`],
//!   so the job lifecycle drives the guest (job-kill → `Lifecycle::power_off`;
//!   poll → `Lifecycle::status`).
//!
//! **One KVM boot path.** jera does **not** call tunnr directly — instance-firing
//! lives in exactly one place, draupnir. jera keeps only the job policy: turn a VM
//! spec into a tracked boot job. This is the "repoint jera's VM seam at draupnir"
//! step of the boot-library extraction (see draupnir's `design.md`).
//!
//! **Zero-shell**: draupnir drives the guest over tunnr's Rust API — never a
//! `qemu-system-*` subprocess spawned from jera.

use crate::boot::BootProc;
#[cfg(feature = "vm-tunnr")]
use crate::boot::BootStatus;

/// **The UEFI firmware choice**, re-exported from draupnir so a consumer picks a
/// firmware without depending on the boot library directly — the same way it
/// already reaches draupnir's KVM backend only through [`VmBootRunner`].
///
/// It is draupnir's own type, not a jera twin: a second enum here would be a
/// second mapping, and the one that mattered would be the one nobody tested.
/// draupnir resolves the CODE/VARS pair against THIS host and refuses an absent
/// firmware by name; jera only carries the choice.
pub use draupnir::Firmware;

/// nornir/jera's mapping of a VM build onto the boot recipe — the shape handed to
/// draupnir's KVM backend.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootSpec {
    /// The VM handle / guest name.
    pub name: String,
    /// Kernel image path (`-kernel`).
    pub kernel: String,
    /// Rootfs/initramfs path (`-initrd`), or empty for a disk boot.
    pub rootfs: String,
    /// Disk image path (`-drive`), or empty for a kernel+rootfs boot.
    pub disk: String,
    /// Guest memory (MiB).
    pub mem_mb: u32,
    /// Guest vCPU count.
    pub cores: u32,
    /// Kernel command line (`-append`).
    pub cmdline: String,
    /// Boot flavor (`qemu` / `microvm` / `appliance`).
    pub format: String,
    /// The materialized QEMU argv (the fallback boot recipe when draupnir/tunnr is absent).
    pub qemu_args: Vec<String>,
    /// A bootable **medium** (ISO). With an empty `kernel`: alone it is a LIVE boot
    /// off the medium (`-cdrom … -boot d`, no target disk); together with `disk` it
    /// is the INSTALL shape (the ISO installs onto the blank `disk`, whose writes
    /// persist). Empty (the default) keeps the kernel/rootfs/disk shapes exactly as
    /// they were.
    pub iso: String,
    /// Host-specific QEMU devices the spec does not model natively (a scratch
    /// virtio disk, `-device virtio-gpu-pci`, a `-qmp` control socket, an ssh
    /// hostfwd netdev), appended verbatim after tunnr's own argv — draupnir's
    /// `BootSpec::extra_qemu_args`. Empty leaves the argv byte-identical.
    pub extra_qemu_args: Vec<String>,
    /// The console line that means "this guest booted OK" (draupnir's
    /// `success_marker`): the runner's [`BootProc::poll`] reports
    /// [`BootStatus::BootedOk`] once it streams past. Empty (the default) keeps
    /// tunnr's stock marker.
    pub success_marker: String,
    /// The wall-clock budget for the boot in seconds (draupnir's `boot_timeout`,
    /// tunnr's SIGKILL backstop). `0` (the default) keeps tunnr's default (60 s).
    pub timeout_secs: u64,
    /// Cut the guest off from every network (`-nic none`; draupnir's
    /// `NetMode::None`) — either the airgap shape, or because the caller authors
    /// its own netdev in `extra_qemu_args`. `false` leaves tunnr's default
    /// user-mode virtio-net.
    pub net_none: bool,
    /// **The UEFI firmware the guest boots on** — draupnir's [`Firmware`], carried
    /// as ONE choice because the OVMF CODE and VARS images are a pair (see the type:
    /// the enforcing CODE with the stock, empty VARS template enforces against
    /// nothing and looks exactly like Secure Boot).
    ///
    /// [`Firmware::HostDefault`] (the default) names nothing and leaves tunnr's own
    /// stock OVMF discovery in place — argv byte-identical to a spec written before
    /// this field existed. This is the field a GUI firmware picker sets: without it
    /// the two `tunnr_vm::BootSpec` firmware fields were unreachable from any layer
    /// above tunnr, so no caller could ask for Secure Boot at all.
    pub firmware: Firmware,
}

impl BootSpec {
    /// A kernel+rootfs boot recipe with QEMU defaults. `qemu_args` is left for the
    /// caller (nornir fills it from its `VmBuild::qemu_args()`); this keeps jera free
    /// of nornir's build-descriptor types.
    pub fn kernel_rootfs(name: impl Into<String>, kernel: impl Into<String>, rootfs: impl Into<String>) -> Self {
        BootSpec {
            name: name.into(),
            kernel: kernel.into(),
            rootfs: rootfs.into(),
            disk: String::new(),
            mem_mb: 512,
            cores: 1,
            cmdline: String::new(),
            format: "qemu".into(),
            qemu_args: Vec::new(),
            iso: String::new(),
            extra_qemu_args: Vec::new(),
            success_marker: String::new(),
            timeout_secs: 0,
            net_none: false,
            firmware: Firmware::HostDefault,
        }
    }

    /// Choose the guest's UEFI [`firmware`](BootSpec::firmware), builder-style —
    /// the call a firmware picker makes:
    /// `BootSpec::live_iso(name, iso).with_firmware(Firmware::SecureBoot)`.
    pub fn with_firmware(mut self, firmware: Firmware) -> Self {
        self.firmware = firmware;
        self
    }

    /// A **LIVE boot off a medium**: firmware-boot `iso` (`-cdrom … -boot d`),
    /// no target disk, nothing installed — the run-from-RAM / airgap shape.
    pub fn live_iso(name: impl Into<String>, iso: impl Into<String>) -> Self {
        let mut s = Self::kernel_rootfs(name, "", "");
        s.iso = iso.into();
        s
    }

    /// The **INSTALL shape**: firmware-boot `iso` with the blank `disk` attached as
    /// the virtio target (vda) — the installer writes onto it and the writes
    /// PERSIST (draupnir's `kvm_install_from_medium`, `persist_disk = true`).
    pub fn install_from_medium(
        name: impl Into<String>,
        iso: impl Into<String>,
        disk: impl Into<String>,
    ) -> Self {
        let mut s = Self::kernel_rootfs(name, "", "");
        s.iso = iso.into();
        s.disk = disk.into();
        s
    }

    /// **Boot OFF an installed disk** — no kernel, no medium: OVMF firmware-boots the
    /// ESP on `disk`, whose writes persist (draupnir's `kvm_boot_installed_disk`).
    /// The second half of install → reboot-into-the-product.
    pub fn installed_disk(name: impl Into<String>, disk: impl Into<String>) -> Self {
        let mut s = Self::kernel_rootfs(name, "", "");
        s.disk = disk.into();
        s
    }

    /// Map this jera VM spec onto a `draupnir::BootSpec` (the KVM appliance path).
    /// tunnr takes a single boot image (`rootfs_or_disk`): prefer the rootfs
    /// (initramfs), else the disk. Only compiled with `vm-tunnr` (the draupnir KVM
    /// backend types come with that feature).
    ///
    /// The SHAPE is decided by which of kernel / iso / disk are named — the same
    /// four shapes draupnir's constructors spell out, picked here once so a
    /// consumer never has to:
    /// * a kernel ⇒ direct-kernel boot (`kvm_kernel_rootfs`, rootfs else disk);
    /// * no kernel, an iso, no disk ⇒ LIVE boot off the medium (`iso_boot`);
    /// * no kernel, an iso AND a disk ⇒ INSTALL (`kvm_install_from_medium`);
    /// * no kernel, no iso, a disk ⇒ boot the INSTALLED disk (`kvm_boot_installed_disk`).
    ///
    /// Marker, budget, extra args, `net_none` and the [`Firmware`] choice ride
    /// through when set; unset leaves draupnir's (and so tunnr's) defaults — argv
    /// byte-identical to before.
    #[cfg(feature = "vm-tunnr")]
    pub fn to_draupnir(&self) -> draupnir::BootSpec {
        let mut d = if !self.kernel.is_empty() {
            let image = if !self.rootfs.is_empty() { &self.rootfs } else { &self.disk };
            draupnir::BootSpec::kvm_kernel_rootfs(&self.name, &self.kernel, image)
        } else if !self.iso.is_empty() && self.disk.is_empty() {
            draupnir::BootSpec::iso_boot(&self.name, &self.iso)
        } else if !self.iso.is_empty() {
            draupnir::BootSpec::kvm_install_from_medium(&self.name, &self.iso, &self.disk)
        } else if !self.disk.is_empty() {
            draupnir::BootSpec::kvm_boot_installed_disk(&self.name, &self.disk)
        } else {
            // Nothing bootable named: keep the historical mapping, which draupnir's
            // `validate` refuses by name ("needs a non-empty kernel path").
            draupnir::BootSpec::kvm_kernel_rootfs(&self.name, &self.kernel, &self.rootfs)
        };
        d.mem_mb = self.mem_mb;
        d.cores = self.cores;
        d.cmdline = self.cmdline.clone();
        if !self.extra_qemu_args.is_empty() {
            d.extra_qemu_args = self.extra_qemu_args.clone();
        }
        if !self.success_marker.is_empty() {
            d.success_marker = Some(self.success_marker.clone());
        }
        if self.timeout_secs > 0 {
            d.boot_timeout = Some(std::time::Duration::from_secs(self.timeout_secs));
        }
        if self.net_none {
            d.net = draupnir::NetMode::None;
        }
        // The firmware choice travels whole; draupnir resolves it into a CODE/VARS
        // pair on the host and hands it to tunnr's pflash seam. `HostDefault` is
        // draupnir's own default, so an untouched spec stays byte-identical.
        d.firmware = self.firmware.clone();
        d
    }
}

/// The VM boot seam: turn a [`BootSpec`] into a live [`BootProc`].
pub trait VmBootRunner: Send + Sync {
    /// Boot the VM described by `spec`, returning the live [`BootProc`].
    fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String>;
}

/// The default VM runner when draupnir's KVM backend is not compiled in (`vm-tunnr`
/// off): it maps the spec but reports a clear, honest failure rather than
/// pretending to boot.
pub struct StubVmBootRunner;

impl VmBootRunner for StubVmBootRunner {
    fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String> {
        Err(format!(
            "vm boot '{}' not wired in this build: rebuild with `--features vm-tunnr` to boot \
             THROUGH draupnir's KVM backend (draupnir::kvm::KvmBoot → tunnr_vm::boot_test)",
            spec.name,
        ))
    }
}

/// The REAL VM runner (feature `vm-tunnr`): maps jera's [`BootSpec`] onto a
/// `draupnir::BootSpec`, boots it via `draupnir::kvm::KvmBoot`, and adapts the
/// returned [`draupnir::Machine`] into a [`BootProc`] — so the job lifecycle drives
/// the KVM through draupnir: job-kill → `Lifecycle::power_off`; poll →
/// `Lifecycle::status` (which polls tunnr's QEMU pid, so an externally-dead VM flips
/// the job too).
#[cfg(feature = "vm-tunnr")]
#[derive(Default)]
pub struct DraupnirVmBootRunner {
    /// The KVM backend; its live-handle registry is shared (cheap `Arc` clone) with
    /// each spawned [`DraupnirVmProc`] so poll/kill address the exact VM booted.
    kvm: draupnir::kvm::KvmBoot,
}

/// Back-compat alias — jera's VM boot now runs THROUGH draupnir (which drives
/// tunnr), so the runner is named for draupnir; the old name still resolves.
#[cfg(feature = "vm-tunnr")]
pub type TunnrVmBootRunner = DraupnirVmBootRunner;

#[cfg(feature = "vm-tunnr")]
impl VmBootRunner for DraupnirVmBootRunner {
    fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String> {
        use draupnir::Boot;
        let machine = self
            .kvm
            .boot(&spec.to_draupnir())
            .map_err(|e| format!("draupnir kvm boot: {e}"))?;
        Ok(Box::new(DraupnirVmProc {
            kvm: self.kvm.clone(),
            machine,
            killed: false,
            terminal: None,
            lines_emitted: 0,
            pending: Vec::new(),
        }))
    }
}

/// Adapts a draupnir KVM [`draupnir::Machine`] (backed by a live tunnr handle inside
/// the shared `KvmBoot` registry) to jera's [`BootProc`] (feature `vm-tunnr`).
#[cfg(feature = "vm-tunnr")]
struct DraupnirVmProc {
    kvm: draupnir::kvm::KvmBoot,
    machine: draupnir::Machine,
    killed: bool,
    terminal: Option<BootStatus>,
    /// How many newline-TERMINATED console lines [`drain_log`](BootProc::drain_log)
    /// has already handed out, so each poll returns only the fresh tail (never a
    /// still-growing partial line twice).
    lines_emitted: usize,
    /// Console lines captured at teardown, BEFORE the handle was released: `kill` /
    /// `shutdown` drop draupnir's live handle, after which `serial_log` is `None`,
    /// so the tail the guest wrote last would be lost to the next `drain_log`.
    pending: Vec<String>,
}

#[cfg(feature = "vm-tunnr")]
impl DraupnirVmProc {
    /// The newly-completed console lines since the last hand-out (empty when the
    /// handle is gone). `split('\n')`'s last element is the text after the final
    /// newline (a partial, still-filling line, or empty) — only the elements BEFORE
    /// it are complete.
    fn fresh_lines(&mut self) -> Vec<String> {
        let Some(full) = self.kvm.serial_log(&self.machine) else {
            return Vec::new();
        };
        let parts: Vec<&str> = full.split('\n').collect();
        let complete = &parts[..parts.len().saturating_sub(1)];
        if complete.len() <= self.lines_emitted {
            return Vec::new();
        }
        let fresh: Vec<String> =
            complete[self.lines_emitted..].iter().map(|s| s.to_string()).collect();
        self.lines_emitted = complete.len();
        fresh
    }

    /// Tear down through `stop` (power_off or shutdown), keeping the console tail
    /// and the verdict honest: a boot that already reached `BootedOk` stays
    /// `BootedOk` (a clean stop after success is not a failure); anything else
    /// becomes `Killed`.
    fn teardown(&mut self, stop: impl FnOnce(&draupnir::kvm::KvmBoot, &draupnir::Machine)) {
        if self.killed {
            return;
        }
        // Settle the verdict first: the handle answers `booted_ok` only while live.
        if self.terminal.is_none() && self.kvm.booted_ok(&self.machine) {
            self.terminal = Some(BootStatus::BootedOk);
        }
        let tail = self.fresh_lines();
        self.pending.extend(tail);
        stop(&self.kvm, &self.machine);
        self.killed = true;
        if self.terminal.is_none() {
            self.terminal = Some(BootStatus::Killed);
        }
    }
}

#[cfg(feature = "vm-tunnr")]
impl BootProc for DraupnirVmProc {
    fn poll(&mut self) -> BootStatus {
        use draupnir::{Lifecycle, PowerState};
        if let Some(t) = &self.terminal {
            return t.clone();
        }
        // The boot VERDICT comes first: tunnr flips to BootedOk the moment the
        // spec's success marker streams past, and draupnir's `status` folds that into
        // `On` (a booted guest is a powered guest) — so ask the verdict reader, not
        // the power state. Terminal: the job is over, the guest may keep serving.
        if self.kvm.booted_ok(&self.machine) {
            self.terminal = Some(BootStatus::BootedOk);
            return BootStatus::BootedOk;
        }
        match self.kvm.status(&self.machine) {
            Ok(PowerState::On) => BootStatus::Running,
            // Not yet observed by the backend — still coming up.
            Ok(PowerState::Unknown) => BootStatus::Booting,
            // The VM is powered off: a deliberate teardown is Killed; otherwise the
            // guest went down on its own (the boot job is over / it failed) — in
            // tunnr's own words when it has them (timed out / killed / failed).
            Ok(PowerState::Off) => {
                let t = if self.killed {
                    BootStatus::Killed
                } else {
                    BootStatus::Failed(
                        self.kvm
                            .exit_reason(&self.machine)
                            .unwrap_or_else(|| "vm powered off".into()),
                    )
                };
                self.terminal = Some(t.clone());
                t
            }
            Err(e) => {
                let t = BootStatus::Failed(format!("vm status: {e}"));
                self.terminal = Some(t.clone());
                t
            }
        }
    }

    fn kill(&mut self) {
        use draupnir::Lifecycle;
        self.teardown(|kvm, m| {
            let _ = kvm.power_off(m);
        });
    }

    fn shutdown(&mut self, grace: std::time::Duration) {
        self.teardown(|kvm, m| {
            let _ = kvm.shutdown(m, grace);
        });
    }

    fn drain_log(&mut self) -> Vec<String> {
        // The KVM console: read the serial capture off draupnir (which reads it off
        // tunnr's live BootHandle) and return only the newly-completed lines since the
        // last drain, so a VM boot job accumulates the guest console the same way an
        // Exe boot accumulates stdout — the surface the forge (dwarves) renders. Lines
        // captured at teardown (before the handle went away) come out first.
        let mut out = std::mem::take(&mut self.pending);
        out.extend(self.fresh_lines());
        out
    }
}

/// The default VM runner for this build: the real [`DraupnirVmBootRunner`] under
/// `vm-tunnr`, else the honest [`StubVmBootRunner`].
pub fn default_vm_runner() -> Box<dyn VmBootRunner> {
    #[cfg(feature = "vm-tunnr")]
    {
        Box::new(DraupnirVmBootRunner::default())
    }
    #[cfg(not(feature = "vm-tunnr"))]
    {
        Box::new(StubVmBootRunner)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stub_vm_runner_reports_honest_not_wired() {
        let spec = BootSpec::kernel_rootfs("vm", "/k", "r");
        let err = match StubVmBootRunner.boot(&spec) {
            Ok(_) => panic!("stub must not pretend to boot"),
            Err(e) => e,
        };
        assert!(err.contains("draupnir"), "names the boot library it routes through: {err}");
        assert!(err.contains("vm-tunnr"), "names the feature: {err}");
    }

    #[test]
    fn bootspec_kernel_rootfs_defaults() {
        let s = BootSpec::kernel_rootfs("g", "/boot/vmlinuz", "rootfs.cpio.gz");
        assert_eq!(s.mem_mb, 512);
        assert_eq!(s.cores, 1);
        assert_eq!(s.format, "qemu");
        assert!(s.disk.is_empty());
    }

    /// The three kernel-less shapes pick draupnir's own constructors, and marker /
    /// budget / extra args / net_none ride through — none of it invented here.
    #[cfg(feature = "vm-tunnr")]
    #[test]
    fn medium_and_disk_shapes_map_onto_draupnirs_own_constructors() {
        use draupnir::{BootOrder, ImageSource, NetMode};
        let live = BootSpec::live_iso("live", "/m.iso").to_draupnir();
        assert_eq!(live.image, ImageSource::Iso("/m.iso".into()));
        assert_eq!(live.boot_order, BootOrder::Medium);
        assert_eq!(live.medium, None, "a live ISO is the payload, not a second -cdrom");

        let install = BootSpec::install_from_medium("inst", "/m.iso", "/t.raw").to_draupnir();
        assert_eq!(install.image, ImageSource::BootloaderDisk { disk: "/t.raw".into() });
        assert_eq!(install.medium.as_deref(), Some("/m.iso"));
        assert!(install.persist_disk, "an install whose writes are discarded is a false green");

        let second = BootSpec::installed_disk("boot2", "/t.raw").to_draupnir();
        assert_eq!(second.image, ImageSource::BootloaderDisk { disk: "/t.raw".into() });
        assert_eq!(second.boot_order, BootOrder::Disk);
        assert!(second.persist_disk);

        let mut knobs = BootSpec::live_iso("k", "/m.iso");
        knobs.success_marker = "APP-ALIVE".into();
        knobs.timeout_secs = 120;
        knobs.extra_qemu_args = vec!["-device".into(), "virtio-gpu-pci".into()];
        knobs.net_none = true;
        let d = knobs.to_draupnir();
        assert_eq!(d.success_marker.as_deref(), Some("APP-ALIVE"));
        assert_eq!(d.boot_timeout, Some(std::time::Duration::from_secs(120)));
        assert_eq!(d.extra_qemu_args, vec!["-device", "virtio-gpu-pci"]);
        assert_eq!(d.net, NetMode::None);

        // Unset knobs leave draupnir's defaults — byte-identical argv to before.
        let plain = BootSpec::kernel_rootfs("p", "/k", "/r").to_draupnir();
        assert_eq!(plain.success_marker, None);
        assert_eq!(plain.boot_timeout, None);
        assert!(plain.extra_qemu_args.is_empty());
        assert_eq!(plain.net, NetMode::Default);
    }

    /// **The firmware choice rides the same seam as `-device virtio-gpu-pci`** —
    /// a GUI picker sets it on the jera spec and it arrives on draupnir's, which
    /// is where the CODE/VARS pair is resolved and handed to tunnr's pflash.
    ///
    /// Modelled on `medium_and_disk_shapes_map_onto_draupnirs_own_constructors`:
    /// prove the ARGUMENT survives the journey, not that a field was assigned.
    #[cfg(feature = "vm-tunnr")]
    #[test]
    fn the_firmware_choice_survives_the_jera_to_draupnir_seam() {
        // Unset → draupnir's default → tunnr's stock discovery: byte-identical argv.
        let plain = BootSpec::live_iso("p", "/m.iso").to_draupnir();
        assert_eq!(plain.firmware, Firmware::HostDefault);

        let sb = BootSpec::live_iso("sb", "/m.iso")
            .with_firmware(Firmware::SecureBoot)
            .to_draupnir();
        assert_eq!(sb.firmware, Firmware::SecureBoot);
        assert!(sb.firmware.enforces_secure_boot(), "the picker asked for enforcement");

        // The caller's own enrolled PK/KEK/db store rides through whole — the vars
        // path is data, and jera must not reinterpret it.
        let enrolled = BootSpec::installed_disk("e", "/t.raw")
            .with_firmware(Firmware::SecureBootEnrolled {
                vars_template: "/keys/OVMF_VARS.enrolled.fd".into(),
            })
            .to_draupnir();
        assert_eq!(
            enrolled.firmware,
            Firmware::SecureBootEnrolled { vars_template: "/keys/OVMF_VARS.enrolled.fd".into() }
        );
    }

    /// The jera→draupnir spec mapping prefers the rootfs image and carries the
    /// machine size + cmdline (feature `vm-tunnr`).
    #[cfg(feature = "vm-tunnr")]
    #[test]
    fn maps_onto_a_draupnir_kvm_spec() {
        let mut s = BootSpec::kernel_rootfs("holger", "/boot/vmlinuz", "rootfs.cpio.gz");
        s.mem_mb = 2048;
        s.cores = 4;
        s.cmdline = "korp.smoke=1".into();
        let d = s.to_draupnir();
        assert_eq!(d.name, "holger");
        assert_eq!(d.kind, draupnir::Kind::Vm);
        assert_eq!(d.mem_mb, 2048);
        assert_eq!(d.cores, 4);
        assert_eq!(d.cmdline, "korp.smoke=1");
        assert_eq!(d.image, draupnir::ImageSource::KernelRootfs {
            kernel: "/boot/vmlinuz".into(),
            rootfs: "rootfs.cpio.gz".into(),
        });
    }
}