1use std::collections::BTreeMap;
6
7use serde::Serialize;
8use serde_yaml_ng::Value as Y;
9
10use super::{Template, Variable};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Status {
16 Clean,
18 Notes,
20 Refused,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct Report {
26 pub status: Status,
27 #[serde(skip_serializing_if = "Vec::is_empty")]
28 pub notes: Vec<String>,
29 #[serde(skip_serializing_if = "Vec::is_empty")]
30 pub refusals: Vec<String>,
31}
32
33pub(super) struct Tx {
34 pub(super) notes: Vec<String>,
35 pub(super) refusals: Vec<String>,
36 pub(super) vars: Vec<Variable>,
38 pub(super) names: BTreeMap<String, String>,
40}
41
42impl Tx {
43 pub(super) fn note(&mut self, s: impl Into<String>) {
44 let s = s.into();
45 if !self.notes.contains(&s) {
46 self.notes.push(s);
47 }
48 }
49
50 pub(super) fn refuse(&mut self, s: impl Into<String>) {
51 let s = s.into();
52 if !self.refusals.contains(&s) {
53 self.refusals.push(s);
54 }
55 }
56
57 pub(super) fn fresh_name(&self, base: &str) -> String {
58 let base = var_name(base);
59 let taken = |n: &str| self.vars.iter().any(|v| v.name == n);
60 if !taken(&base) {
61 return base;
62 }
63 (2..)
64 .map(|i| format!("{base}_{i}"))
65 .find(|n| !taken(n))
66 .expect("an unused name")
67 }
68}
69
70pub(super) fn var_name(s: &str) -> String {
72 let mut out: String = s
73 .to_ascii_lowercase()
74 .chars()
75 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
76 .collect();
77 if !out.starts_with(|c: char| c.is_ascii_lowercase()) {
78 out = format!("v_{out}");
79 }
80 out.truncate(60);
81 out
82}
83
84pub fn key_name(s: &str) -> String {
86 let k = crate::compose::sanitize_name(s);
87 let k = k.strip_prefix("isb-").map(String::from).unwrap_or(k);
88 let mut k: String = k.chars().take(30).collect();
89 while k.ends_with('-') {
90 k.pop();
91 }
92 if k.is_empty() || !k.starts_with(|c: char| c.is_ascii_lowercase()) {
93 k = format!("s{k}");
94 }
95 k.chars().take(30).collect()
96}
97
98pub(super) fn lit(s: &str) -> String {
99 s.replace('$', "$$")
100}
101
102pub(super) fn secretish(name: &str) -> bool {
103 let n = name.to_ascii_lowercase();
104 ["pass", "secret", "token", "key", "salt", "private", "jwt"]
105 .iter()
106 .any(|w| n.contains(w))
107}
108
109pub(super) fn yscalar(v: &Y) -> Option<String> {
110 match v {
111 Y::String(s) => Some(s.clone()),
112 Y::Number(n) => Some(n.to_string()),
113 Y::Bool(b) => Some(b.to_string()),
114 Y::Null => Some(String::new()),
115 _ => None,
116 }
117}
118
119pub fn image_ref(image: &str) -> String {
121 let (first, rest) = match image.split_once('/') {
122 Some((f, r)) => (f, Some(r)),
123 None => (image, None),
124 };
125 let is_host =
126 rest.is_some() && (first.contains('.') || first.contains(':') || first == "localhost");
127 match (is_host, rest) {
128 (true, Some(r)) => match first {
129 "docker.io" | "index.docker.io" | "registry-1.docker.io" => format!("docker:{r}"),
130 "ghcr.io" => format!("ghcr:{r}"),
131 "quay.io" => format!("quay:{r}"),
132 _ => format!("oci:{image}"),
133 },
134 _ => format!("docker:{image}"),
135 }
136}
137
138pub(super) fn rewrite_hosts(
143 expr: &str,
144 services: &[(String, String)],
145 whole: bool,
146) -> (String, Vec<String>) {
147 let mut hits = Vec::new();
148 if whole {
149 for (name, key) in services {
150 if expr == lit(name) {
151 return (format!("${{host:{key}}}"), vec![name.clone()]);
152 }
153 }
154 }
155 let mut out = String::new();
157 let mut rest = expr;
158 loop {
159 let (litpart, tail) = match next_ref(rest) {
160 Some((a, b)) => (&rest[..a], Some((a, b))),
161 None => (rest, None),
162 };
163 out.push_str(&rewrite_lit(litpart, services, &mut hits));
164 match tail {
165 Some((a, b)) => {
166 out.push_str(&rest[a..b]);
167 rest = &rest[b..];
168 }
169 None => break,
170 }
171 }
172 (out, hits)
173}
174
175fn next_ref(s: &str) -> Option<(usize, usize)> {
177 let b = s.as_bytes();
178 let mut i = 0;
179 while i + 1 < b.len() {
180 if b[i] == b'$' && b[i + 1] == b'$' {
181 i += 2;
182 } else if b[i] == b'$' && b[i + 1] == b'{' {
183 let end = s[i..].find('}')? + i + 1;
184 return Some((i, end));
185 } else {
186 i += 1;
187 }
188 }
189 None
190}
191
192fn rewrite_lit(s: &str, services: &[(String, String)], hits: &mut Vec<String>) -> String {
193 let word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-');
194 let mut out = String::new();
195 let mut i = 0;
196 'outer: while i < s.len() {
197 for (name, key) in services {
198 if !s[i..].starts_with(name.as_str()) {
199 continue;
200 }
201 let before = &s[..i];
202 let after = &s[i + name.len()..];
203 let left_ok = before.chars().next_back().is_none_or(|c| !word(c));
204 let right_ok = after.chars().next().is_none_or(|c| !word(c));
205 if !(left_ok && right_ok) {
206 continue;
207 }
208 let url_host = before.ends_with("//") || before.ends_with('@');
209 let host_port =
210 after.starts_with(':') && after[1..].starts_with(|c: char| c.is_ascii_digit());
211 if url_host || host_port {
212 out.push_str(&format!("${{host:{key}}}"));
213 if !hits.contains(name) {
214 hits.push(name.clone());
215 }
216 i += name.len();
217 continue 'outer;
218 }
219 }
220 let c = s[i..].chars().next().expect("in bounds");
221 out.push(c);
222 i += c.len_utf8();
223 }
224 out
225}
226
227pub(super) fn hostish_key(k: &str) -> bool {
228 let k = k.to_ascii_uppercase();
229 [
230 "HOST", "SERVER", "ADDR", "ENDPOINT", "URL", "URI", "DSN", "BROKER", "NODES", "UPSTREAM",
231 "BACKEND",
232 ]
233 .iter()
234 .any(|w| k.contains(w))
235}
236
237pub(super) const REFUSED_KEYS: &[(&str, &str)] = &[
240 ("privileged", "privileged mode"),
241 ("cap_add", "added capabilities"),
242 ("devices", "host devices"),
243 ("device_cgroup_rules", "device cgroup rules"),
244 ("gpus", "GPUs"),
245 ("pid", "the host's process namespace"),
246 ("ipc", "a shared IPC namespace"),
247 ("userns_mode", "a user-namespace mode"),
248 ("uts", "the host's UTS namespace"),
249 ("cgroup", "a cgroup namespace mode"),
250 ("cgroup_parent", "a cgroup parent"),
251 ("sysctls", "sysctls"),
252 ("runtime", "another container runtime"),
253 ("volumes_from", "volumes_from"),
254 ("extra_hosts", "extra_hosts (host-gateway reaches the host)"),
255 ("dns", "custom DNS servers"),
256 ("dns_search", "custom DNS search domains"),
257 (
258 "build",
259 "an image built from source (use an app with a git source)",
260 ),
261 ("extends", "extends"),
262 ("secrets", "compose secrets"),
263 ("configs", "compose configs"),
264 ("post_start", "lifecycle hooks"),
265 ("pre_stop", "lifecycle hooks"),
266 ("isolation", "an isolation technology"),
267];
268
269pub(super) const NOTED_KEYS: &[(&str, &str)] = &[
272 ("ulimits", "ulimits are not applied (incus defaults)"),
273 ("shm_size", "shm_size is not applied"),
274 (
275 "tmpfs",
276 "tmpfs mounts are not made; the paths are on the root filesystem",
277 ),
278 ("stop_signal", "stop_signal is not applied"),
279 ("stop_grace_period", "stop_grace_period is not applied"),
280 ("read_only", "a read-only root filesystem is not applied"),
281 (
282 "cap_drop",
283 "dropped capabilities are not applied (it runs as an unprivileged incus container)",
284 ),
285 (
286 "security_opt",
287 "security_opt is not applied (it runs as an unprivileged incus container)",
288 ),
289 ("init", "init is not applied"),
290 (
291 "platform",
292 "platform is ignored; the host's architecture is pulled",
293 ),
294 (
295 "hostname",
296 "its hostname is not set; apps reach it as <app>.<stack>",
297 ),
298 ("domainname", "domainname is not set"),
299 ("container_name", "container_name is ignored"),
300 ("mem_reservation", "mem_reservation is not applied"),
301 ("memswap_limit", "memswap_limit is not applied"),
302 ("pids_limit", "pids_limit is not applied"),
303 ("cpu_shares", "cpu_shares is not applied"),
304 ("cpuset", "cpuset is not applied"),
305 ("oom_kill_disable", "oom_kill_disable is not applied"),
306 ("oom_score_adj", "oom_score_adj is not applied"),
307 ("storage_opt", "storage_opt is not applied"),
308 ("blkio_config", "blkio_config is not applied"),
309 ("mac_address", "mac_address is not applied"),
310 ("stop_grace_period", "stop_grace_period is not applied"),
311];
312
313pub(super) const IGNORED_KEYS: &[&str] = &[
315 "image",
316 "exclude_from_hc",
317 "restart",
318 "logging",
319 "tty",
320 "stdin_open",
321 "pull_policy",
322 "expose",
323 "networks",
324 "labels",
325 "environment",
326 "env_file",
327 "volumes",
328 "ports",
329 "command",
330 "entrypoint",
331 "healthcheck",
332 "depends_on",
333 "user",
334 "working_dir",
335 "deploy",
336 "mem_limit",
337 "cpus",
338 "links",
339 "network_mode",
340 "scale",
341 "profiles",
342 "develop",
343 "annotations",
344 "attach",
345];
346
347pub(super) fn ymap(v: &Y) -> Option<&serde_yaml_ng::Mapping> {
348 v.as_mapping()
349}
350
351pub(super) fn yget<'a>(m: &'a serde_yaml_ng::Mapping, k: &str) -> Option<&'a Y> {
352 m.get(Y::String(k.into()))
353}
354
355pub(super) fn pairs(v: &Y) -> Vec<(String, Option<String>)> {
358 match v {
359 Y::Sequence(s) => s
360 .iter()
361 .filter_map(yscalar)
362 .map(|e| match e.split_once('=') {
363 Some((k, v)) => (k.to_string(), Some(v.to_string())),
364 None => (e, None),
365 })
366 .collect(),
367 Y::Mapping(m) => m
368 .iter()
369 .filter_map(|(k, v)| {
370 let k = yscalar(k)?;
371 Some((k, if v.is_null() { None } else { yscalar(v) }))
372 })
373 .collect(),
374 _ => vec![],
375 }
376}
377
378pub(super) fn words(v: &Y) -> Option<Vec<String>> {
379 match v {
380 Y::String(s) => crate::flex::split_words(s).ok(),
381 Y::Sequence(s) => s.iter().map(yscalar).collect(),
382 Y::Null => Some(vec![]),
383 _ => None,
384 }
385}
386
387pub(super) fn parse_compose(compose: &str, tx: &mut Tx) -> Option<Y> {
389 let mut doc: Y = match serde_yaml_ng::from_str(compose) {
390 Ok(v) => v,
391 Err(e) => {
392 tx.refuse(format!("docker-compose.yml does not parse: {e}"));
393 return None;
394 }
395 };
396 if let Err(e) = doc.apply_merge() {
397 tx.refuse(format!("docker-compose.yml merge keys: {e}"));
398 return None;
399 }
400 Some(doc)
401}
402
403pub(super) fn check_top_level(top: &serde_yaml_ng::Mapping, tx: &mut Tx) {
405 for (k, _) in top {
406 let k = yscalar(k).unwrap_or_default();
407 if !matches!(
408 k.as_str(),
409 "services" | "volumes" | "networks" | "version" | "name"
410 ) && !k.starts_with("x-")
411 {
412 if k == "secrets" || k == "configs" {
413 tx.refuse(format!("top-level compose {k}"));
414 } else {
415 tx.note(format!("top-level compose key {k} is ignored"));
416 }
417 }
418 }
419}
420
421pub(super) fn declared_volumes(
423 top: &serde_yaml_ng::Mapping,
424 tx: &mut Tx,
425) -> serde_yaml_ng::Mapping {
426 let declared_vols = yget(top, "volumes")
427 .and_then(ymap)
428 .cloned()
429 .unwrap_or_default();
430 for (k, v) in &declared_vols {
431 let k = yscalar(k).unwrap_or_default();
432 if let Some(m) = ymap(v) {
433 if yget(m, "external").and_then(Y::as_bool) == Some(true) {
434 tx.refuse(format!("volume {k} is external (it must exist beforehand)"));
435 }
436 if let Some(d) = yget(m, "driver").and_then(yscalar) {
437 if d != "local" {
438 tx.refuse(format!("volume {k} uses driver {d}"));
439 }
440 }
441 if yget(m, "driver_opts").is_some() {
442 tx.refuse(format!("volume {k} has driver_opts (a bind or NFS mount)"));
443 }
444 }
445 }
446 declared_vols
447}
448
449pub(super) fn service_names(
452 services: &serde_yaml_ng::Mapping,
453 tx: &mut Tx,
454) -> (BTreeMap<String, String>, Vec<(String, String)>) {
455 let mut keys: BTreeMap<String, String> = BTreeMap::new();
457 let mut aliases: Vec<(String, String)> = Vec::new();
458 for (name, s) in services {
459 let name = yscalar(name).unwrap_or_default();
460 if s.as_mapping().and_then(|m| yget(m, "profiles")).is_some() {
461 tx.note(format!(
462 "{name} has compose profiles (off by default in docker); it is not deployed"
463 ));
464 continue;
465 }
466 let key = key_name(&name);
467 if keys.values().any(|k| k == &key) {
468 tx.refuse(format!("services {name} and another both become app {key}"));
469 }
470 keys.insert(name.clone(), key.clone());
471 aliases.push((name.clone(), key.clone()));
472 if let Some(m) = ymap(s) {
473 aliases.extend(other_names(m, &name).into_iter().map(|h| (h, key.clone())));
474 }
475 }
476 aliases.sort_by_key(|a| std::cmp::Reverse(a.0.len()));
478 (keys, aliases)
479}
480
481fn other_names(m: &serde_yaml_ng::Mapping, name: &str) -> Vec<String> {
484 let mut out = Vec::new();
485 for k in ["hostname", "container_name"] {
486 if let Some(h) = yget(m, k).and_then(yscalar) {
487 if h != name && !h.contains('$') {
488 out.push(h);
489 }
490 }
491 }
492 if let Some(Y::Mapping(nets)) = yget(m, "networks") {
493 for (_, n) in nets {
494 if let Some(Y::Sequence(al)) = ymap(n).and_then(|n| yget(n, "aliases")) {
495 out.extend(al.iter().filter_map(yscalar));
496 }
497 }
498 }
499 out
500}
501
502pub(super) fn finish(t: Option<Template>, mut tx: Tx) -> (Option<Template>, Report) {
505 let t = match t {
506 Some(t) if tx.refusals.is_empty() => match t.validate() {
507 Ok(()) => Some(t),
508 Err(e) => {
509 tx.refuse(format!("the translation does not validate: {e}"));
510 None
511 }
512 },
513 _ => None,
514 };
515 let status = if !tx.refusals.is_empty() {
516 Status::Refused
517 } else if tx.notes.is_empty() {
518 Status::Clean
519 } else {
520 Status::Notes
521 };
522 (
523 t,
524 Report {
525 status,
526 notes: tx.notes,
527 refusals: tx.refusals,
528 },
529 )
530}