use serde_yaml_ng::{Mapping, Value};
use super::problem::{Code, FieldPath, Problem};
use super::step::has_extension;
pub(super) fn scan(doc: &Value) -> Vec<Problem> {
let mut out = Vec::new();
let Value::Mapping(top) = doc else { return out };
if let Some(Value::Mapping(machines)) = top.get("machines") {
for (name, m) in machines {
if let (Some(name), Value::Mapping(m)) = (name.as_str(), m) {
machine(&FieldPath::new("machines").key(name), name, m, &mut out);
}
}
}
if let Some(Value::Mapping(m)) = top.get("common") {
shared(&FieldPath::new("common"), m, &mut out);
}
if let Some(Value::Mapping(groups)) = top.get("groups") {
for (name, g) in groups {
if let (Some(name), Value::Mapping(g)) = (name.as_str(), g) {
shared(&FieldPath::new("groups").key(name), g, &mut out);
}
}
}
networks(top, &mut out);
volumes(top, &mut out);
if let Some(Value::Mapping(c)) = top.get("controller")
&& c.contains_key("image")
{
let fix = "`overrides: { vagrant: { controller: { box: <box>, box_version: <version> } } }`";
out.push(removed(FieldPath::new("controller.image"), "the controller's box is Vagrant's own fact").fix(fix));
}
out
}
fn removed(path: FieldPath, reason: &str) -> Problem {
Problem::new(Code::RemovedField, path, reason.to_owned())
}
fn machine(at: &FieldPath, name: &str, m: &Mapping, out: &mut Vec<Problem>) {
if let Some(Value::Mapping(d)) = m.get("docker") {
let reason = "a machine says what it runs, never how a platform runs it, so `docker:` is gone";
out.push(removed(at.key("docker"), reason).fix(docker_fix(name, d)));
}
if let Some(Value::Mapping(v)) = m.get("vm") {
let reason = "a machine says what it runs, never how a platform runs it, so `vm:` is gone";
out.push(removed(at.key("vm"), reason).fix(vm_fix(name, v)));
}
if m.contains_key("external") {
let fix = format!("`overrides: {{ external: {{ machines: {{ {name}: {{ ssh: {{ address, user, port, key }} }} }} }} }}`");
out.push(removed(at.key("external"), "an existing host is a fact of the `external` target").fix(fix));
}
if m.contains_key("services") {
out.push(removed(at.key("services"), "ports are `ports:`, keyed by name").fix("`ports: { web: { port: 80, protocol: http, publish: 8080 } }`"));
}
if let Some(Value::Sequence(list)) = m.get("ports") {
let p = Problem::new(Code::InvalidValue, at.key("ports"), "ports are a map of named ports, not a list");
out.push(p.fix(format!("`ports: {{ {} }}`", ports_fix(list))));
}
shared(at, m, out);
}
fn shared(at: &FieldPath, m: &Mapping, out: &mut Vec<Problem>) {
let moved: [(&str, &str, String); 8] = [
(
"inputs",
"an input reaches a machine as a file or an environment value",
"`files: [{ path: /ctf/flag, input: <NAME> }]`, or `environment: { <NAME>: { input: <NAME> } }` for a non-sensitive input".to_owned(),
),
(
"secrets",
"a secret reaches a machine as a file",
"`files: [{ path: /run/secrets/<name>, secret: <name> }]`".to_owned(),
),
("arch", "the architecture is hardware", "`hardware: { arch: <arch> }`".to_owned()),
("gpus", "GPUs are hardware", "`hardware: { gpus: <gpus> }`".to_owned()),
("devices", "host devices are hardware", "`hardware: { devices: [...] }`".to_owned()),
(
"nested_virtualization",
"nested virtualization is hardware",
"`hardware: { nested_virtualization: true }`".to_owned(),
),
(
"before",
"a per-run value lands as a file before the process starts, and set-up runs once the machine is up",
"`files: [{ path, input }]`, or `setup: [...]`".to_owned(),
),
(
"working_dir",
"the directory belongs to the command",
"`command: { run: <command>, cwd: <dir> }`".to_owned(),
),
];
for (key, reason, fix) in moved {
if m.contains_key(key) {
out.push(removed(at.key(key), reason).fix(fix));
}
}
if !at.as_str().starts_with("machines.") {
for key in ["docker", "vm"] {
if m.contains_key(key) {
let reason = format!("a machine says what it runs, never how a platform runs it, so `{key}:` is gone");
out.push(removed(at.key(key), &reason).fix("`os: <family>@<version>` and `app: { image | build | steps }`"));
}
}
}
}
fn docker_fix(name: &str, d: &Mapping) -> String {
let text = |k: &str| d.get(k).and_then(Value::as_str);
let mut parts = Vec::new();
if let Some(a) = text("appliance") {
parts.push(format!("`app: {{ appliance: {a}, image | firmware, config }}`"));
} else if d.get("idle").and_then(Value::as_bool) == Some(true) {
parts.push("`os: <family>@<version>` alone: the OS's own image, kept running idle".to_owned());
} else if let Some(b) = text("build") {
if d.contains_key("dockerfile") || d.contains_key("args") || d.contains_key("target") {
parts.push(format!("`app: {{ build: {{ context: {b}, dockerfile, target, args }} }}`"));
} else {
parts.push(format!("`app: {{ build: {b} }}`"));
}
} else if let Some(i) = text("image") {
parts.push(format!("`app: {{ image: {i} }}`"));
}
if d.contains_key("init") {
parts.push("`init` → `setup: [...]`, and a flag script becomes `files: [{ path, input }]`".to_owned());
}
if d.get("lxc").and_then(Value::as_bool) == Some(true) {
parts.push(format!("`overrides: {{ proxmox: {{ machines: {{ {name}: {{ lxc: true }} }} }} }}`"));
}
if parts.is_empty() {
parts.push("`app: { build: <folder> }` for your Dockerfile, or `app: { image: <ref> }`".to_owned());
}
parts.join("; ")
}
fn vm_fix(name: &str, v: &Mapping) -> String {
let mut parts = Vec::new();
if let Some(os) = v.get("os").and_then(Value::as_str) {
parts.push(format!("`os: {os}`"));
}
if let Some(Value::Sequence(steps)) = v.get("provision") {
let steps: Vec<String> = steps.iter().filter_map(Value::as_str).map(step_fix).collect();
parts.push(format!("`app: {{ steps: [{}] }}`", steps.join(", ")));
}
if v.contains_key("image") {
parts.push(format!(
"`vm.image` → `overrides: {{ vagrant: {{ machines: {{ {name}: {{ box, box_version }} }} }} }}` (and `proxmox`, the clouds)"
));
}
if let Some(tpm) = v.get("tpm").and_then(Value::as_bool) {
parts.push(format!("`hardware: {{ tpm: {} }}`", if tpm { "required" } else { "none" }));
}
if v.contains_key("boot") {
parts.push("`hardware: { boot: ... }`".to_owned());
}
if parts.is_empty() {
parts.push("`os: <family>@<version>`".to_owned());
}
parts.join("; ")
}
fn step_fix(s: &str) -> String {
if s == "reboot" {
"reboot".to_owned()
} else if has_extension(s, "yml") || has_extension(s, "yaml") {
format!("{{ playbook: {s} }}")
} else {
format!("{{ run: {s} }}")
}
}
fn ports_fix(list: &[Value]) -> String {
let items: Vec<String> = list
.iter()
.filter_map(Value::as_mapping)
.map(|p| {
let number = p.get("port").and_then(Value::as_u64).unwrap_or_default();
let flag = |k: &str| p.get(k).and_then(Value::as_bool) == Some(true);
let protocol = match (p.get("protocol").and_then(Value::as_str), flag("http"), flag("tls")) {
(Some("udp"), ..) => Some("udp"),
(_, true, true) => Some("https"),
(_, true, false) => Some("http"),
(_, false, true) => Some("tls"),
_ => None,
};
let name = p.get("name").and_then(Value::as_str).map_or_else(|| format!("p{number}"), str::to_owned);
let mut fields = vec![format!("port: {number}")];
fields.extend(protocol.map(|kind| format!("protocol: {kind}")));
if let Some(h) = p.get("publish").and_then(Value::as_u64) {
fields.push(if flag("fixed") {
format!("publish: {{ host: {h}, fixed: true }}")
} else {
format!("publish: {h}")
});
}
if flag("dormant") {
fields.push("dormant: true".to_owned());
}
format!("{name}: {{ {} }}", fields.join(", "))
})
.collect();
items.join(", ")
}
fn networks(top: &Mapping, out: &mut Vec<Problem>) {
let Some(Value::Mapping(nets)) = top.get("networks") else { return };
for (n, net) in nets {
let (Some(n), Value::Mapping(net)) = (n.as_str(), net) else { continue };
let at = FieldPath::new("networks").key(n);
if let Some(Value::Mapping(d)) = net.get("docker") {
let cidr = d.get("cidr").and_then(Value::as_str).unwrap_or("<block>");
let fix = format!("`overrides: {{ docker: {{ networks: {{ {n}: {{ cidr: {cidr} }} }} }} }}`");
out.push(removed(at.key("docker"), "a network's Docker block is a fact of the Docker targets").fix(fix));
}
if let Some(name) = net.get("external").and_then(Value::as_str) {
let fix = format!("`overrides: {{ <target>: {{ networks: {{ {n}: {{ external: {name} }} }} }} }}`, for `docker`, `proxmox` or `esxi`");
out.push(removed(at.key("external"), "an existing network has a name on one target").fix(fix));
}
}
}
fn volumes(top: &Mapping, out: &mut Vec<Problem>) {
let Some(Value::Mapping(vols)) = top.get("volumes") else { return };
for (v, vol) in vols {
let (Some(v), Value::Mapping(vol)) = (v.as_str(), vol) else { continue };
let at = FieldPath::new("volumes").key(v);
if let Some(name) = vol.get("external").and_then(Value::as_str) {
let fix = format!("`overrides: {{ docker: {{ volumes: {{ {v}: {{ external: {name} }} }} }} }}` (or `kubernetes`)");
out.push(removed(at.key("external"), "an existing volume has a name on one platform").fix(fix));
}
if vol.contains_key("virtiofs") {
let fix = format!("`overrides: {{ proxmox: {{ volumes: {{ {v}: {{ virtiofs: {{}} }} }} }} }}`");
out.push(removed(at.key("virtiofs"), "virtiofs is Proxmox's way to share a volume").fix(fix));
}
}
}
#[cfg(test)]
mod tests;