Skip to main content

isoloom_render/
constraints.rs

1//! What the network an environment runs in imposes (see [`Constraints`]), as each target needs
2//! it:
3//!
4//! - images: every `image:` of the Compose file and the Kubernetes manifests, and the `FROM` of
5//!   the Dockerfiles Isoloom writes, go through the registry mirrors ([`rewrite_image`], as each
6//!   backend writes them);
7//! - containers: the machines (never Isoloom's own checks or router) get the proxy and the
8//!   package mirrors as environment variables ([`machine_env`]), their builds the proxy as build
9//!   arguments ([`build_args`]);
10//! - Linux VMs: a shell preamble run as root before anything else ([`linux_prelude`]) sets the
11//!   proxy for the shell, apt, dnf and Docker, rewrites the apt sources to the mirrors, writes
12//!   pip's and npm's configuration and the time servers.
13
14use std::collections::HashSet;
15
16pub use isoloom_plan::images::rewrite_image;
17use isoloom_spec::model::{Constraints, Mirrors, Spec};
18use isoloom_spec::shell::quote;
19use isoloom_spec::validate::docker_cidrs;
20
21/// The spec's constraints, when it has any that change something.
22#[must_use]
23pub fn of(spec: &Spec) -> Option<&Constraints> {
24    spec.constraints
25        .as_ref()
26        .filter(|c| c.proxy.is_some() || !c.registries.is_empty() || c.mirrors.is_some() || !c.ntp.is_empty())
27}
28
29/// The proxy's URLs, `https` defaulting to `http`; `None` without a proxy (validation gives
30/// every proxy its `http`).
31fn proxies(c: &Constraints) -> Option<(&str, &str)> {
32    let p = c.proxy.as_ref()?;
33    let http = p.http.as_deref()?;
34    Some((http, p.https.as_deref().unwrap_or(http)))
35}
36
37/// What is reached without the proxy: localhost, the environment's machine names and network
38/// blocks (as written, and Docker's), then the spec's own list, each once.
39#[must_use]
40pub fn no_proxy(spec: &Spec) -> Vec<String> {
41    let local = ["localhost", "127.0.0.1", "::1"].map(str::to_owned);
42    let names = spec.machines.iter().flat_map(|(name, m)| std::iter::once(name).chain(&m.aliases).cloned());
43    let blocks = spec.networks.values().map(|n| n.cidr.clone());
44    let docker = docker_cidrs(spec).into_iter().map(|(_, c)| c.to_string());
45    let own = spec.constraints.iter().flat_map(|c| &c.proxy).flat_map(|p| p.no_proxy.iter().cloned());
46    let mut seen = HashSet::new();
47    local
48        .into_iter()
49        .chain(names)
50        .chain(blocks)
51        .chain(docker)
52        .chain(own)
53        .filter(|x| seen.insert(x.clone()))
54        .collect()
55}
56
57/// The proxy's `(http, https)` URLs as `NAME=value` pairs, both cases (tools read one or the
58/// other), and what bypasses it.
59fn proxy_env(spec: &Spec, (http, https): (&str, &str)) -> Vec<(String, String)> {
60    let np = no_proxy(spec).join(",");
61    [
62        ("HTTP_PROXY", "http_proxy", http),
63        ("HTTPS_PROXY", "https_proxy", https),
64        ("NO_PROXY", "no_proxy", np.as_str()),
65    ]
66    .into_iter()
67    .flat_map(|(upper, lower, value)| [(upper.to_owned(), value.to_owned()), (lower.to_owned(), value.to_owned())])
68    .collect()
69}
70
71/// The environment a machine's container gets: the proxy, and the package mirrors tools read
72/// from the environment.
73#[must_use]
74pub fn machine_env(spec: &Spec) -> Vec<(String, String)> {
75    let Some(c) = of(spec) else { return Vec::new() };
76    let proxy = proxies(c).map(|urls| proxy_env(spec, urls)).unwrap_or_default();
77    let mirrors = c.mirrors.iter().flat_map(|m| {
78        [("PIP_INDEX_URL", &m.pypi), ("NPM_CONFIG_REGISTRY", &m.npm)]
79            .into_iter()
80            .filter_map(|(k, url)| Some((k.to_owned(), url.clone()?)))
81    });
82    proxy.into_iter().chain(mirrors).collect()
83}
84
85/// The build arguments a machine's image build gets: the proxy (Docker's predefined `ARG`s,
86/// so a Dockerfile needs no change), and the mirrors as the machine's environment has them.
87#[must_use]
88pub fn build_args(spec: &Spec) -> Vec<(String, String)> {
89    machine_env(spec)
90}
91
92/// The shell (run as root, idempotent) that applies the constraints on a Linux VM, before its
93/// own set-up and steps. `None` when the spec has none.
94#[must_use]
95pub fn linux_prelude(spec: &Spec) -> Option<String> {
96    let c = of(spec)?;
97    let mirrors = c.mirrors.as_ref().map(mirror_lines).unwrap_or_default();
98    Some(format!("set -e\n{}{mirrors}{}", proxy(spec, c), ntp(&c.ntp)))
99}
100
101/// The mirrors: Debian's and Ubuntu's apt sources rewritten to them, pip's and npm's
102/// configuration.
103fn mirror_lines(m: &Mirrors) -> String {
104    let sources = "/etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources";
105    let debian = m.debian.as_deref().map(|u| {
106        format!(
107            "# Debian's packages from the mirror.\n. /etc/os-release; if [ \"$ID\" = debian ]; then for f in {sources}; do if [ -f \"$f\" ]; then sed -i -E 's#https?://(deb|httpredir|ftp)\\.debian\\.org/debian/?([[:space:]]|$)#{}/\\2#g' \"$f\"; fi; done; fi\n",
108            u.trim_end_matches('/')
109        )
110    });
111    let ubuntu = m.ubuntu.as_deref().map(|u| {
112        format!(
113            "# Ubuntu's packages from the mirror.\n. /etc/os-release; if [ \"$ID\" = ubuntu ]; then for f in {sources}; do if [ -f \"$f\" ]; then sed -i -E 's#https?://([a-z0-9-]+\\.)?(archive|security)\\.ubuntu\\.com/ubuntu/?([[:space:]]|$)#{}/\\3#g' \"$f\"; fi; done; fi\n",
114            u.trim_end_matches('/')
115        )
116    });
117    let pypi = m
118        .pypi
119        .as_deref()
120        .map(|u| format!("printf '[global]\\nindex-url = %s\\n' {} > /etc/pip.conf\n", quote(u)));
121    let npm = m.npm.as_deref().map(|u| format!("printf 'registry=%s\\n' {} > /etc/npmrc\n", quote(u)));
122    [debian, ubuntu, pypi, npm].into_iter().flatten().collect()
123}
124
125/// The time servers: chrony's when it's there, else systemd-timesyncd's. Nothing without any.
126fn ntp(servers: &[String]) -> String {
127    if servers.is_empty() {
128        return String::new();
129    }
130    format!(
131        "# Time from the site's servers: chrony when it's there, else systemd-timesyncd.\n\
132         if [ -d /etc/chrony ]; then mkdir -p /etc/chrony/conf.d /etc/chrony/sources.d; printf '%s\\n' {} > /etc/chrony/sources.d/isoloom.sources; systemctl restart chrony 2>/dev/null || systemctl restart chronyd 2>/dev/null || true; \
133         else mkdir -p /etc/systemd/timesyncd.conf.d && printf '[Time]\\nNTP=%s\\n' {} > /etc/systemd/timesyncd.conf.d/isoloom.conf && (systemctl restart systemd-timesyncd 2>/dev/null || true); fi\n",
134        servers.iter().map(|s| quote(&format!("server {s} iburst"))).collect::<Vec<_>>().join(" "),
135        quote(&servers.join(" ")),
136    )
137}
138
139/// Whether a variable is spelled in capitals (`HTTP_PROXY`): the case systemd and Docker read.
140fn upper(k: &str) -> bool {
141    k.chars().all(|ch| ch.is_ascii_uppercase() || ch == '_')
142}
143
144/// The proxy, when the constraints set one: for every login shell, systemd's services, sudo,
145/// apt, dnf/yum and Docker's daemon. Nothing without one.
146fn proxy(spec: &Spec, c: &Constraints) -> String {
147    let Some(urls) = proxies(c) else { return String::new() };
148    let env = proxy_env(spec, urls);
149    let lines: String = env.iter().map(|(k, v)| format!("{k}={v}\n")).collect::<Vec<_>>().concat();
150    let exports: String = env.iter().map(|(k, v)| format!("export {k}={}\n", quote(v))).collect::<Vec<_>>().concat();
151    let systemd = env
152        .iter()
153        .filter(|(k, _)| upper(k))
154        .map(|(k, v)| format!("\"{k}={v}\""))
155        .collect::<Vec<_>>()
156        .join(" ");
157    format!(
158        "# The proxy, for every login shell, systemd's services and sudo.\n\
159         grep -v -i -E '^(http|https|no)_proxy=' /etc/environment > /etc/environment.isoloom 2>/dev/null || true\n\
160         printf %s {} >> /etc/environment.isoloom && mv /etc/environment.isoloom /etc/environment\n\
161         printf %s {} > /etc/profile.d/isoloom-proxy.sh\n\
162         mkdir -p /etc/systemd/system.conf.d && printf '[Manager]\\nDefaultEnvironment=%s\\n' {} > /etc/systemd/system.conf.d/isoloom-proxy.conf\n\
163         if [ -d /etc/sudoers.d ]; then printf 'Defaults env_keep += \"http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY\"\\n' > /etc/sudoers.d/isoloom-proxy && chmod 0440 /etc/sudoers.d/isoloom-proxy; fi\n{}",
164        quote(&lines),
165        quote(&exports),
166        quote(&systemd),
167        package_proxies(urls, &env),
168    )
169}
170
171/// The proxy's `(http, https)` URLs for apt, dnf/yum and Docker's daemon (`env`: the proxy's
172/// variables).
173fn package_proxies((http, https): (&str, &str), env: &[(String, String)]) -> String {
174    let apt = format!("Acquire::http::Proxy \"{http}\";\nAcquire::https::Proxy \"{https}\";\n");
175    let docker: String = env
176        .iter()
177        .filter(|(k, _)| upper(k))
178        .map(|(k, v)| format!("Environment=\"{k}={v}\"\n"))
179        .collect::<Vec<_>>()
180        .concat();
181    format!(
182        "# apt, dnf/yum and Docker's daemon through it.\n\
183         if [ -d /etc/apt/apt.conf.d ]; then printf %s {} > /etc/apt/apt.conf.d/95isoloom-proxy; fi\n\
184         for f in /etc/dnf/dnf.conf /etc/yum.conf; do if [ -f \"$f\" ]; then sed -i '/^proxy=/d' \"$f\" && printf 'proxy=%s\\n' {} >> \"$f\"; fi; done\n\
185         mkdir -p /etc/systemd/system/docker.service.d && printf '[Service]\\n%s' {} > /etc/systemd/system/docker.service.d/isoloom-proxy.conf\n\
186         systemctl daemon-reload 2>/dev/null || true; if systemctl is-active --quiet docker 2>/dev/null; then systemctl restart docker; fi\n",
187        quote(&apt),
188        quote(http),
189        quote(&docker),
190    )
191}
192
193#[cfg(test)]
194mod tests;