1use std::net::{Ipv4Addr, Ipv6Addr};
12
13use indexmap::IndexMap;
14use isoloom_spec::images;
15use isoloom_spec::model::{Arch, Boot, Constraints, External, Machine, MachineFile, Network, Protocol, Seal, Spec, Target, Tc, Ulimit, VmImage, Winrm};
16use isoloom_spec::validate::docker_cidrs;
17use isoloom_spec::windows::{self, Tpm};
18
19mod image;
20
21pub use image::{Build, Image};
22
23use crate::addressing::{address, address_for, host_address, start_order};
24use crate::ansible;
25use crate::checks::{self, Resolved};
26use crate::configure::{DaemonTable, Recipe};
27use crate::hardware::{self, Hardware};
28use crate::images::Images;
29use crate::ipv6::{self, Ipv6Plan, Route6};
30use crate::lifecycle::{self, MachineLifecycle};
31use crate::outside::{self, OutsideAddress, OutsideNetwork};
32use crate::process::Process;
33use crate::requirements::{self, Requirement};
34use crate::routing;
35use crate::secrets::SecretFile;
36use crate::storage::{self, DataDisk, Lifetime};
37
38#[derive(Debug, Clone, PartialEq)]
40pub struct Environment {
41 pub name: String,
43 pub target: Target,
45 pub instance: Option<u8>,
47 pub networks: IndexMap<String, NetworkPlan>,
49 pub outside: IndexMap<String, OutsideNetwork>,
52 pub router: Option<IndexMap<String, Ipv4Addr>>,
54 pub router6: IndexMap<String, Ipv6Addr>,
56 pub machines: IndexMap<String, MachinePlan>,
58 pub start_order: Vec<String>,
60 pub checks: Vec<Resolved>,
62 pub controller: Option<Controller>,
64 pub constraints: Option<Constraints>,
66 pub requirements: Vec<Requirement>,
68 pub images: Images,
70 pub inventory_groups: IndexMap<String, Vec<String>>,
72 pub private_dns: Option<crate::private_dns::PrivateDns>,
74 spec: Spec,
75}
76
77#[derive(Debug, Clone, PartialEq)]
79pub struct NetworkPlan {
80 pub cidr: String,
82 pub declared_cidr: String,
84 pub host: Ipv4Addr,
86 pub gateway: Option<String>,
88 pub internet: bool,
90 pub tc: Option<Tc>,
92 pub vlan: Option<(String, u16)>,
94 pub ipv6: Option<Ipv6Plan>,
96 pub mtu: Option<u16>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum MachineShape {
103 Container,
105 Vm,
107 Supplied,
109 External,
111}
112
113#[derive(Debug, Clone, PartialEq)]
115pub struct MachinePlan {
116 pub shape: MachineShape,
118 pub addresses: IndexMap<String, Ipv4Addr>,
120 pub more_addresses: IndexMap<String, Vec<Ipv4Addr>>,
123 pub addresses6: IndexMap<String, Ipv6Addr>,
125 pub names: Vec<String>,
128 pub routes: Vec<Route>,
130 pub routes6: Vec<Route6>,
132 pub default_gateway: Option<Ipv4Addr>,
134 pub is_gateway: bool,
136 pub offline: bool,
138 pub seal: Seal,
141 pub ports: Vec<PublishedPort>,
143 pub volumes: Vec<Volume>,
145 pub files: Vec<MachineFile>,
147 pub disks: Vec<DataDisk>,
149 pub settings: Settings,
151 pub inputs: Vec<String>,
153 pub secrets: Vec<SecretFile>,
155 pub size: Size,
157 pub arch: Arch,
159 pub rate_limit: Option<String>,
161 pub image: Option<Image>,
163 pub vm_image: Option<VmImage>,
166 pub boot: Option<Boot>,
168 pub depends_on: Vec<String>,
170 pub lifecycle: MachineLifecycle,
172 pub os: Option<String>,
175 pub winrm: Option<Winrm>,
177 pub winssh: bool,
179 pub tpm: Option<Tpm>,
181 pub external: Option<External>,
183 pub host_vars: IndexMap<String, serde_json::Value>,
185 pub outside: IndexMap<String, OutsideAddress>,
187 pub process: Process,
189 pub hardware: Hardware,
191 pub recipe: Option<Recipe>,
194}
195
196impl MachinePlan {
197 pub fn is_windows(&self) -> bool {
199 self.os.as_deref().is_some_and(images::is_windows)
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct Route {
206 pub cidr: String,
208 pub via: Ipv4Addr,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct PublishedPort {
215 pub name: String,
217 pub port: u16,
219 pub publish: u16,
221 pub protocol: Protocol,
223 pub fixed: bool,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Volume {
230 pub name: String,
232 pub path: String,
234 pub subpath: Option<String>,
236 pub key: String,
238 pub lifetime: Lifetime,
240 pub kind: VolumeKind,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum VolumeKind {
247 Own,
249 Shared {
251 owner: String,
253 users: Vec<String>,
255 },
256}
257
258#[derive(Debug, Clone, Default, PartialEq, Eq)]
260pub struct Settings {
261 pub environment: IndexMap<String, String>,
263 pub sysctls: IndexMap<String, String>,
265 pub ulimits: IndexMap<String, Ulimit>,
267}
268
269impl Settings {
270 #[must_use]
272 pub fn is_empty(&self) -> bool {
273 self.environment.is_empty() && self.sysctls.is_empty() && self.ulimits.is_empty()
274 }
275}
276
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
279pub struct Size {
280 pub cpus: Option<u32>,
282 pub memory_mb: Option<u32>,
284 pub disk_gb: Option<u32>,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
290#[non_exhaustive]
291pub struct Controller {
292 pub cpus: u32,
294 pub memory_mb: u32,
296 pub disk_gb: Option<u32>,
298}
299
300impl Controller {
301 pub const CPUS: u32 = 1;
303 pub const MEMORY_MB: u32 = 512;
305
306 #[must_use]
309 pub fn of(spec: &Spec) -> Controller {
310 let r = spec.controller.as_ref().and_then(|c| c.resources.as_ref());
311 Controller {
312 cpus: r.and_then(|r| r.cpus).unwrap_or(Controller::CPUS),
313 memory_mb: r.and_then(|r| r.memory_mb).unwrap_or(Controller::MEMORY_MB),
314 disk_gb: r.and_then(|r| r.disk_gb),
315 }
316 }
317
318 #[must_use]
320 pub fn needed(spec: &Spec) -> bool {
321 !spec.provision.is_empty() || spec.checks.iter().any(isoloom_spec::Check::is_playbook)
322 }
323}
324
325impl Environment {
326 #[must_use]
328 pub fn build(spec: &Spec, target: Target, instance: Option<u8>) -> Environment {
329 let laid_out = outside::without(&lay_out(spec, target));
331 let machines = machines(spec, &laid_out, target);
332 let requirements = all_requirements(spec, &laid_out, &machines);
333 let networks = laid_out
334 .networks
335 .iter()
336 .map(|(name, n)| {
337 let declared = spec.networks.get(name).map_or(&n.cidr, |o| &o.cidr);
338 (name.clone(), network(&laid_out, name, n, declared))
339 })
340 .collect();
341 Environment {
342 name: laid_out.name.clone(),
343 target,
344 instance,
345 networks,
346 outside: outside::networks(spec),
347 router: routing::needed(&laid_out).then(|| routing::networks(&laid_out).map(|n| (n.clone(), routing::address(&laid_out, n))).collect()),
348 router6: ipv6::router(&laid_out),
349 machines,
350 start_order: start_order(&laid_out).into_iter().map(String::from).collect(),
351 checks: checks::plan(&laid_out),
352 controller: Controller::needed(&laid_out).then(|| Controller::of(&laid_out)),
353 constraints: laid_out.constraints.clone(),
354 requirements,
355 images: Images::of(&laid_out),
356 inventory_groups: ansible::groups(&laid_out),
357 private_dns: crate::private_dns::plan(&laid_out),
358 spec: laid_out,
359 }
360 }
361
362 #[must_use]
364 pub fn spec(&self) -> &Spec {
365 &self.spec
366 }
367
368 #[must_use]
370 pub fn address_for(&self, from: &str, to: &str) -> Ipv4Addr {
371 address_for(&self.spec, from, to)
372 }
373
374 #[must_use]
379 pub fn probe_address(&self, name: &str, network: &str) -> String {
380 let m = self.machines.get(name);
381 let address = if ipv6::only(&self.spec, network) {
382 m.and_then(|m| m.addresses6.get(network)).map(ToString::to_string)
383 } else {
384 m.and_then(|m| m.addresses.get(network)).map(ToString::to_string)
385 };
386 address.unwrap_or_else(|| name.to_owned())
387 }
388}
389
390#[must_use]
392pub fn on_docker(target: Target) -> bool {
393 matches!(target, Target::Docker | Target::Hosted)
394}
395
396#[must_use]
398pub fn lay_out(spec: &Spec, target: Target) -> Spec {
399 let mut s = spec.clone();
400 if on_docker(target) {
401 for (name, c) in docker_cidrs(spec) {
402 if let Some(n) = s.networks.get_mut(&name) {
403 n.cidr = c.to_string();
404 }
405 }
406 }
407 s
408}
409
410fn network(spec: &Spec, name: &str, n: &Network, declared: &str) -> NetworkPlan {
412 NetworkPlan {
413 cidr: n.cidr.clone(),
414 declared_cidr: declared.to_owned(),
415 host: host_address(spec, name),
416 gateway: n.gateway.clone(),
417 internet: n.internet,
418 tc: n.tc.clone(),
419 vlan: n.vlan.clone(),
420 ipv6: ipv6::network(spec, name),
421 mtu: n.mtu,
422 }
423}
424
425fn shape(target: Target, m: &Machine) -> MachineShape {
429 if target == Target::External {
430 return MachineShape::External;
431 }
432 let container = match target {
433 Target::Docker | Target::Hosted | Target::DockerVm | Target::CloudDocker | Target::Kubernetes => true,
434 Target::Hybrid => m.docker.is_some(),
435 Target::Proxmox => m.vm.is_none() && m.docker.as_ref().is_some_and(|d| d.lxc),
437 _ => false,
438 };
439 match (container, m.docker.is_some(), m.vm.is_some()) {
440 (true, true, _) => MachineShape::Container,
441 (false, _, true) => MachineShape::Vm,
442 _ => MachineShape::Supplied,
443 }
444}
445
446fn machines(spec: &Spec, laid_out: &Spec, target: Target) -> IndexMap<String, MachinePlan> {
448 laid_out
449 .machines
450 .iter()
451 .map(|(name, m)| {
452 let mut mp = machine(laid_out, target, name, m);
453 mp.outside = spec.machines.get(name).map(|om| outside::addresses(spec, om)).unwrap_or_default();
454 (name.clone(), mp)
455 })
456 .collect()
457}
458
459fn all_requirements(spec: &Spec, laid_out: &Spec, machines: &IndexMap<String, MachinePlan>) -> Vec<Requirement> {
462 let shape = |n: &str| machines.get(n).map(|m| m.shape);
463 let mut out = requirements::of(laid_out);
464 out.extend(outside::requirements(spec));
465 out.extend(requirements::mixed_shapes(laid_out, shape));
466 out.extend(crate::machine_files::requirements(laid_out, shape));
467 out.extend(requirements::tpm(machines));
468 out.extend(crate::boot::requirements(machines));
469 out.extend(hardware::requirements(laid_out, shape));
470 out.extend(crate::more_addresses::requirements(laid_out, shape));
471 out
472}
473
474fn machine(spec: &Spec, target: Target, name: &str, m: &Machine) -> MachinePlan {
476 let shape = shape(target, m);
477 let recipe = m.configuration.as_ref().and_then(|c| Recipe::of(c, &m.ports, &DaemonTable::common()).ok());
479 MachinePlan {
480 shape,
481 addresses: m.networks.iter().map(|(n, o)| (n.clone(), address(spec, n, *o))).collect(),
482 more_addresses: crate::more_addresses::of(spec, m),
483 addresses6: ipv6::addresses(spec, m),
484 names: crate::private_dns::with_domain(
485 spec,
486 std::iter::once(name).chain(m.aliases.iter().map(String::as_str)).map(String::from).collect(),
487 ),
488 routes: routing::routes(spec, name, m).into_iter().map(|(cidr, via)| Route { cidr, via }).collect(),
489 routes6: if target == Target::CloudVm { Vec::new() } else { ipv6::routes(spec, m) },
490 default_gateway: routing::default_gateway(spec, name, m),
491 is_gateway: routing::is_gateway(spec, name),
492 offline: routing::offline(spec, name, m),
493 seal: m.seal,
494 ports: published(m),
495 volumes: volumes(spec, name, m),
496 files: m.files.clone(),
497 disks: storage::disks(name, m),
498 settings: Settings {
499 environment: m.environment.clone(),
500 sysctls: m.sysctls.clone(),
501 ulimits: m.ulimits.clone(),
502 },
503 inputs: m.inputs.clone(),
504 secrets: m.secrets.iter().map(|s| SecretFile::of(spec, s)).collect(),
505 size: m
506 .resources
507 .map(|r| Size {
508 cpus: r.cpus,
509 memory_mb: r.memory_mb,
510 disk_gb: r.disk_gb,
511 })
512 .unwrap_or_default(),
513 arch: m.arch,
514 rate_limit: m.rate_limit.clone(),
515 image: image::of(spec, shape, name, m, recipe.as_ref()),
516 vm_image: m.vm.as_ref().and_then(|v| v.image.clone()),
517 boot: m.vm.as_ref().and_then(|v| v.boot.clone()),
518 depends_on: m.depends_on.clone(),
519 lifecycle: if shape == MachineShape::Vm {
520 vm_lifecycle(spec, target, name, m, recipe.as_ref())
521 } else {
522 Vec::new()
523 },
524 os: m.vm.as_ref().map(|v| v.os.clone()),
525 winrm: m.vm.as_ref().filter(|v| images::is_windows(&v.os) && !windows::winssh(v)).map(images::winrm),
526 winssh: m.vm.as_ref().is_some_and(windows::winssh),
527 tpm: m.vm.as_ref().and_then(windows::tpm),
528 external: m.external.clone(),
529 host_vars: ansible::host_vars(spec, name),
530 outside: IndexMap::new(),
531 process: Process::of(m),
532 hardware: Hardware::of(m),
533 recipe,
534 }
535}
536
537fn vm_lifecycle(spec: &Spec, target: Target, name: &str, m: &Machine, recipe: Option<&Recipe>) -> MachineLifecycle {
539 let mut steps = lifecycle::of(spec, name, m, recipe);
540 if target == Target::CloudVm {
541 ipv6::cloud_routes(&mut steps);
542 }
543 steps
544}
545
546fn published(m: &Machine) -> Vec<PublishedPort> {
547 m.ports
548 .iter()
549 .filter_map(|s| {
550 s.publish.map(|publish| PublishedPort {
551 name: m.port_label(s),
552 port: s.port,
553 publish,
554 protocol: s.protocol,
555 fixed: s.fixed,
556 })
557 })
558 .collect()
559}
560
561fn volumes(spec: &Spec, name: &str, m: &Machine) -> Vec<Volume> {
562 m.volumes
563 .iter()
564 .map(|(v, mount)| Volume {
565 name: v.clone(),
566 path: mount.path().to_owned(),
567 subpath: mount.subpath().map(String::from),
568 key: spec.volume_key(name, v),
569 lifetime: storage::lifetime(spec, v),
570 kind: match spec.volume_owner(v) {
572 Some(owner) => VolumeKind::Shared {
573 owner: owner.to_owned(),
574 users: spec.volume_users(v).into_iter().map(String::from).collect(),
575 },
576 None => VolumeKind::Own,
577 },
578 })
579 .collect()
580}
581
582#[cfg(test)]
583mod tests {
584 use isoloom_spec::model::Target;
585
586 use super::{Controller, Environment};
587 use crate::testing;
588
589 const NEEDS_ONE: &str = "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n web: { networks: { lan: 10 }, os: debian@12 }\nprovision: [{ ansible: site.yml }]\n";
590
591 fn controller(extra: &str) -> Option<Controller> {
592 testing::environment(&format!("{NEEDS_ONE}{extra}"), Target::Vagrant).controller
593 }
594
595 #[test]
596 fn the_controller_has_one_cpu_and_512_mb_unless_the_spec_sizes_it() {
597 assert_eq!(
598 controller(""),
599 Some(Controller {
600 cpus: 1,
601 memory_mb: 512,
602 disk_gb: None
603 })
604 );
605 assert_eq!(
606 controller("controller: { resources: { cpus: 2, memory_mb: 768, disk_gb: 10 } }\n"),
607 Some(Controller {
608 cpus: 2,
609 memory_mb: 768,
610 disk_gb: Some(10)
611 })
612 );
613 }
614
615 #[test]
616 fn settings_are_empty_until_one_is_set() {
617 assert!(super::Settings::default().is_empty());
618 let mut s = super::Settings::default();
619 s.sysctls.insert("net.ipv4.ip_forward".into(), "1".into());
620 assert!(!s.is_empty());
621 let mut u = super::Settings::default();
622 u.environment.insert("A".into(), "1".into());
623 assert!(!u.is_empty());
624 }
625
626 #[test]
627 fn a_probe_reaches_a_machine_at_its_address_else_by_name() {
628 let env = testing::environment(
629 "version: 1\nname: t\nnetworks:\n v4: { cidr: 10.9.0.0/24 }\n v6: { cidr: 10.8.0.0/24, ipv6: { cidr: \"fd00:8::/64\", only: true } }\n auto: { cidr: 10.7.0.0/24, ipv6: { cidr: \"fd00:7::/64\", only: true, addresses: auto } }\nmachines:\n web: { networks: { v4: 10, v6: 10, auto: 10 }, app: { image: nginx } }\n",
630 Target::Kubernetes,
631 );
632 assert_eq!(env.probe_address("web", "v4"), "10.9.0.10");
633 assert_eq!(env.probe_address("web", "v6"), "fd00:8::10");
634 assert_eq!(env.probe_address("web", "auto"), "web");
635 assert_eq!(env.probe_address("ghost", "v4"), "ghost");
636 }
637
638 const SHAPES: &str = "version: 1
641name: t
642networks: { a: { cidr: 10.1.0.0/24, ipv6: { cidr: \"fd00:1::/64\" } }, b: { cidr: 10.2.0.0/24, ipv6: { cidr: \"fd00:2::/64\" } } }
643reach: [{ from: a, to: b }]
644volumes: { data: { owner: vm } }
645machines:
646 both: { networks: { a: 10 }, os: debian@12, app: { image: nginx, steps: [{ run: a.sh }] }, volumes: { data: /d, own: /o } }
647 img: { networks: { a: 11 }, app: { image: nginx } }
648 vm: { networks: { b: 12 }, os: debian@12, app: { steps: [{ run: a.sh }, reboot] }, volumes: { data: /d } }
649 ct: { networks: { a: 13 }, app: { image: alpine } }
650 win: { networks: { a: 14 }, os: windows-server@2022 }
651 user: { access: true, networks: { a: 5 } }
652overrides:
653 proxmox: { machines: { ct: { lxc: true } } }
654 vagrant: { machines: { win: { winrm: ssl } } }
655";
656
657 fn shapes(target: Target) -> Vec<(String, super::MachineShape)> {
658 testing::environment(SHAPES, target)
659 .machines
660 .iter()
661 .map(|(n, m)| (n.clone(), m.shape))
662 .collect()
663 }
664
665 fn shape_of(target: Target, machine: &str) -> Option<super::MachineShape> {
666 shapes(target).into_iter().find(|(n, _)| n == machine).map(|(_, s)| s)
667 }
668
669 #[test]
670 fn each_target_produces_a_machine_in_the_form_it_runs() {
671 use super::MachineShape::{Container, External, Supplied, Vm};
672 for t in [Target::Docker, Target::Hosted, Target::DockerVm, Target::CloudDocker, Target::Kubernetes] {
673 assert_eq!(shape_of(t, "both"), Some(Container), "{t:?}");
674 assert_eq!(shape_of(t, "user"), Some(Supplied), "{t:?}");
675 }
676 assert_eq!(shape_of(Target::Hybrid, "both"), Some(Container), "a container where it has one");
677 assert_eq!(shape_of(Target::Hybrid, "vm"), Some(Vm));
678 assert_eq!(shape_of(Target::Proxmox, "ct"), Some(Container), "an LXC container");
679 assert_eq!(shape_of(Target::Proxmox, "both"), Some(Vm));
680 assert_eq!(
681 shape_of(Target::Proxmox, "img"),
682 Some(Supplied),
683 "no VM and no LXC: Proxmox refuses it, nothing is built"
684 );
685 assert_eq!(shape_of(Target::Vagrant, "both"), Some(Vm));
686 assert_eq!(shape_of(Target::External, "both"), Some(External));
687 }
688
689 #[test]
690 fn a_vm_has_set_up_steps_and_a_container_none() {
691 let env = testing::environment(SHAPES, Target::Hybrid);
692 let steps = |m: &str| env.machines.get(m).map_or(0, |p| p.lifecycle.len());
693 assert!(steps("vm") > 0);
694 assert_eq!(steps("both"), 0);
695 }
696
697 #[test]
698 fn a_cloud_routes_ipv6_itself_and_the_other_vm_targets_through_the_router() {
699 let routes6 = |t: Target| {
700 testing::environment(SHAPES, t)
701 .machines
702 .get("both")
703 .map(|m| m.routes6.len())
704 .unwrap_or_default()
705 };
706 assert_eq!(routes6(Target::Vagrant), 1);
707 assert_eq!(routes6(Target::CloudVm), 0);
708 let cloud = testing::environment(SHAPES, Target::CloudVm);
709 let ipv6_routes = cloud.machines.get("both").and_then(|m| {
710 m.lifecycle.iter().find_map(|s| match s {
711 crate::Step::Ipv6 { routes, .. } => Some(routes.len()),
712 _ => None,
713 })
714 });
715 assert_eq!(ipv6_routes, Some(0));
716 }
717
718 #[test]
719 fn a_shared_volume_names_its_owner_and_users_and_an_own_one_is_the_machines() {
720 let env = testing::environment(SHAPES, Target::Vagrant);
721 let both = env.machines.get("both").map(|m| m.volumes.clone()).unwrap_or_default();
722 let kinds: Vec<(&str, &super::VolumeKind)> = both.iter().map(|v| (v.name.as_str(), &v.kind)).collect();
723 assert_eq!(
724 kinds,
725 [
726 (
727 "data",
728 &super::VolumeKind::Shared {
729 owner: "vm".into(),
730 users: vec!["both".into(), "vm".into()]
731 }
732 ),
733 ("own", &super::VolumeKind::Own)
734 ]
735 );
736 }
737
738 #[test]
739 fn a_windows_vm_answers_winrm_its_way() {
740 let env = testing::environment(SHAPES, Target::Vagrant);
741 let win = env.machines.get("win").expect("planned");
742 assert!(win.is_windows() && !win.winssh);
743 assert_eq!(win.winrm, Some(isoloom_spec::model::Winrm::Ssl));
744 let vm = env.machines.get("vm").expect("planned");
745 assert!(!vm.is_windows() && vm.winrm.is_none());
746 assert!(super::on_docker(Target::Docker) && super::on_docker(Target::Hosted) && !super::on_docker(Target::DockerVm));
747 }
748
749 #[test]
750 fn only_playbooks_need_a_controller() {
751 let lab = testing::lab(
752 "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n web: { networks: { lan: 10 }, os: debian@12 }\nchecks: [checks/a.sh]\n",
753 );
754 let spec = lab.for_target(Target::Vagrant);
755 assert!(!Controller::needed(&spec) && Environment::build(&spec, Target::Vagrant, None).controller.is_none());
756 let playbook = testing::lab(
757 "version: 1\nname: t\nnetworks: { lan: { cidr: 10.9.0.0/24 } }\nmachines:\n web: { networks: { lan: 10 }, os: debian@12 }\nchecks: [checks/a.yml]\n",
758 );
759 assert!(Controller::needed(&playbook.for_target(Target::Vagrant)));
760 assert_eq!(Controller::of(&spec).memory_mb, Controller::MEMORY_MB);
761 }
762}