Skip to main content

isb_core/
discovery.rs

1//! Service discovery: stable DNS names for a stack's services inside an org.
2//!
3//! An org's bridge runs incus' dnsmasq with `hostsdir=<root>/<org>` (set once,
4//! when the org is created). dnsmasq watches that directory with inotify and
5//! re-reads a file the moment it is renamed into place, so the controller
6//! keeps one hosts file per service there, listing the replicas in rotation:
7//!
8//! ```text
9//! 10.64.3.17 web.shop.acme.isb web.shop
10//! 10.64.3.18 web.shop.acme.isb web.shop
11//! ```
12//!
13//! A compose stack that belongs to a project environment also names its
14//! services in that environment, as its apps are named: the line goes on
15//! with `<service>.<project>-<env>.<org>.isb <service>.<project>-<env>`.
16//! The file is the same one, so the names come and go together.
17//!
18//! Every in-rotation replica's address is a record of the same name (DNS
19//! round-robin, like swarm's `dnsrr`). A service with none has no file, so
20//! the name does not resolve. dnsmasq skips dotfiles, which is where a file
21//! is written before the rename.
22//!
23//! dnsmasq runs as the `incus` user, so the root directory is owned by the
24//! daemon's user with group `incus` and the setgid bit (`isb host setup`
25//! makes it): what the daemon writes there is readable by dnsmasq and by
26//! nobody else.
27
28use std::net::IpAddr;
29use std::path::{Path, PathBuf};
30
31use crate::error::{Error, Result};
32use crate::org::OrgId;
33
34/// Where per-org hosts directories live, unless `ISB_DNS_DIR` says otherwise.
35pub const DEFAULT_ROOT: &str = "/var/lib/isb/dns";
36
37/// The group dnsmasq runs as under incus.
38pub const DNSMASQ_GROUP: &str = "incus";
39
40pub fn root() -> PathBuf {
41    std::env::var_os("ISB_DNS_DIR")
42        .filter(|s| !s.is_empty())
43        .map(PathBuf::from)
44        .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
45}
46
47/// The org's hosts directory.
48pub fn org_dir(org: &OrgId) -> PathBuf {
49    root().join(org.as_str())
50}
51
52/// The `raw.dnsmasq` line that points an org's dnsmasq at its directory.
53pub fn raw_dnsmasq(dir: &Path) -> String {
54    format!("hostsdir={}", dir.display())
55}
56
57/// Create the org's hosts directory under the root, if the root is there and
58/// ours to write. `Ok(None)` means discovery is off on this host (no `isb
59/// host setup`).
60pub fn prepare_org(org: &OrgId) -> Result<Option<PathBuf>> {
61    let dir = org_dir(org);
62    let root = root();
63    if !root.is_dir() || rustix::fs::access(&root, rustix::fs::Access::WRITE_OK).is_err() {
64        return Ok(None);
65    }
66    // The group (incus) and the setgid bit are inherited from the root, and
67    // a chmod by a user outside that group would clear the setgid bit, so
68    // the mode is right at mkdir or never: the umask must not narrow it.
69    use std::os::unix::fs::{DirBuilderExt, MetadataExt};
70    let old = rustix::process::umask(rustix::fs::Mode::from_raw_mode(0o022));
71    let made = std::fs::DirBuilder::new()
72        .mode(dir_mode(&root))
73        .create(&dir);
74    rustix::process::umask(old);
75    match made {
76        Ok(()) => {}
77        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
78        Err(e) => return Err(Error::Invalid(format!("create {}: {e}", dir.display()))),
79    }
80    let (rm, dm) = (std::fs::metadata(&root)?, std::fs::metadata(&dir)?);
81    if rm.mode() & 0o2000 != 0 && (dm.gid() != rm.gid() || dm.mode() & 0o2050 != 0o2050) {
82        return Err(Error::Invalid(format!(
83            "{} is not readable by dnsmasq (want group {DNSMASQ_GROUP}, setgid, g+rx); delete it and run this again",
84            dir.display()
85        )));
86    }
87    Ok(Some(dir))
88}
89
90/// Delete an org's hosts directory (after its network is gone).
91pub fn remove_org(org: &OrgId) {
92    let _ = std::fs::remove_dir_all(org_dir(org));
93}
94
95/// How long an org's hosts directory may sit with no org behind it before
96/// [`prune_orgs`] takes it: longer than an org's creation takes between
97/// making the directory and the project.
98pub const STALE_ORG_DIR: std::time::Duration = std::time::Duration::from_secs(600);
99
100/// Delete the hosts directories under the root of orgs not in `orgs` (the
101/// ones that exist) and untouched for `older_than`; returns the orgs whose
102/// directory went. An org removed past the daemon (another process, a test)
103/// leaves its directory behind, or the daemon's minute pass makes it again
104/// in the moment between the project going and the directory going.
105pub fn prune_orgs(orgs: &[OrgId], older_than: std::time::Duration) -> Vec<OrgId> {
106    prune_orgs_in(&root(), orgs, older_than)
107}
108
109fn prune_orgs_in(root: &Path, orgs: &[OrgId], older_than: std::time::Duration) -> Vec<OrgId> {
110    let Ok(rd) = std::fs::read_dir(root) else {
111        return Vec::new();
112    };
113    let mut gone = Vec::new();
114    for e in rd.flatten() {
115        let Ok(org) = OrgId::new(e.file_name().to_string_lossy().to_string()) else {
116            continue;
117        };
118        let Ok(md) = e.metadata() else { continue };
119        let old = md
120            .modified()
121            .ok()
122            .and_then(|m| m.elapsed().ok())
123            .is_some_and(|age| age >= older_than);
124        if md.is_dir() && old && !orgs.contains(&org) && std::fs::remove_dir_all(e.path()).is_ok() {
125            gone.push(org);
126        }
127    }
128    gone.sort();
129    gone
130}
131
132/// `rwxr-s---` under a group-only root, `rwxr-sr-x` under a world-readable
133/// one (a host whose dnsmasq is not in a group of its own).
134fn dir_mode(root: &Path) -> u32 {
135    use std::os::unix::fs::PermissionsExt;
136    let m = std::fs::metadata(root)
137        .map(|m| m.permissions().mode())
138        .unwrap_or(0o2750);
139    0o2750 | (m & 0o005)
140}
141
142fn file_mode(dir: &Path) -> u32 {
143    use std::os::unix::fs::PermissionsExt;
144    let m = std::fs::metadata(dir)
145        .map(|m| m.permissions().mode())
146        .unwrap_or(0o750);
147    0o640 | (m & 0o004)
148}
149
150fn set_mode(p: &Path, mode: u32) -> Result<()> {
151    use std::os::unix::fs::PermissionsExt;
152    std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))
153        .map_err(|e| Error::Invalid(format!("chmod {}: {e}", p.display())))
154}
155
156/// A service's DNS label: what instance names use for it.
157fn label(service: &str) -> String {
158    crate::compose::sanitize_name(service)
159}
160
161/// The file holding a service's records. Stack names and labels have no
162/// dots, so the name is unambiguous.
163pub fn file_name(stack: &str, service: &str) -> String {
164    format!("{stack}.{}", label(service))
165}
166
167/// The service's full name: `<service>.<stack>.<org>.isb`.
168pub fn fqdn(org: &OrgId, stack: &str, service: &str) -> String {
169    format!("{}.{stack}.{org}.isb", label(service))
170}
171
172/// The hosts file for a service: one line per address, sorted, with both the
173/// full name and the short `<service>.<stack>`, then the same two in
174/// `extra` (a project environment, `<project>-<env>`) when there is one.
175pub fn render(
176    org: &OrgId,
177    stack: &str,
178    service: &str,
179    ips: &[IpAddr],
180    extra: Option<&str>,
181) -> String {
182    let mut ips = ips.to_vec();
183    ips.sort();
184    ips.dedup();
185    let mut names = format!("{} {}.{stack}", fqdn(org, stack, service), label(service));
186    if let Some(x) = extra {
187        names.push_str(&format!(
188            " {} {}.{x}",
189            fqdn(org, x, service),
190            label(service)
191        ));
192    }
193    let mut out = String::new();
194    for ip in ips {
195        out.push_str(&format!("{ip} {names}\n"));
196    }
197    out
198}
199
200/// Publish a service's in-rotation addresses into `dir`: write a dotfile and
201/// rename it over the service's file (dnsmasq sees the rename), or remove the
202/// file when there are none. `extra` is [`render`]'s.
203pub fn publish(
204    dir: &Path,
205    org: &OrgId,
206    stack: &str,
207    service: &str,
208    ips: &[IpAddr],
209    extra: Option<&str>,
210) -> Result<()> {
211    use std::io::Write;
212    use std::os::unix::fs::OpenOptionsExt;
213    let name = file_name(stack, service);
214    let path = dir.join(&name);
215    if ips.is_empty() {
216        return match std::fs::remove_file(&path) {
217            Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
218                Err(Error::Invalid(format!("remove {}: {e}", path.display())))
219            }
220            _ => Ok(()),
221        };
222    }
223    let tmp = dir.join(format!(".{name}.tmp"));
224    let step = |e: std::io::Error| Error::Invalid(format!("write {}: {e}", tmp.display()));
225    let mode = file_mode(dir);
226    let mut f = std::fs::OpenOptions::new()
227        .write(true)
228        .create(true)
229        .truncate(true)
230        .mode(mode)
231        .open(&tmp)
232        .map_err(step)?;
233    f.write_all(render(org, stack, service, ips, extra).as_bytes())
234        .map_err(step)?;
235    drop(f);
236    // The umask may have narrowed the mode.
237    set_mode(&tmp, mode)?;
238    std::fs::rename(&tmp, &path)
239        .map_err(|e| Error::Invalid(format!("rename to {}: {e}", path.display())))
240}
241
242/// Delete the files of services that are not in `keep` (`(stack, service)`
243/// pairs): what a stack removed while the daemon was down left behind.
244pub fn prune(dir: &Path, keep: &[(String, String)]) {
245    let keep: std::collections::BTreeSet<String> =
246        keep.iter().map(|(s, v)| file_name(s, v)).collect();
247    let Ok(rd) = std::fs::read_dir(dir) else {
248        return;
249    };
250    for e in rd.flatten() {
251        let n = e.file_name().to_string_lossy().into_owned();
252        if !keep.contains(&n) {
253            let _ = std::fs::remove_file(e.path());
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn ip(s: &str) -> IpAddr {
263        s.parse().unwrap()
264    }
265
266    #[test]
267    fn renders_round_robin_records() {
268        let o = OrgId::new("acme").unwrap();
269        let t = render(
270            &o,
271            "shop",
272            "web",
273            &[ip("10.64.3.18"), ip("10.64.3.17"), ip("10.64.3.18")],
274            None,
275        );
276        assert_eq!(
277            t,
278            "10.64.3.17 web.shop.acme.isb web.shop\n10.64.3.18 web.shop.acme.isb web.shop\n"
279        );
280        assert_eq!(render(&o, "shop", "web", &[], None), "");
281        assert_eq!(fqdn(&o, "shop", "my_db"), "my-db.shop.acme.isb");
282        assert_eq!(file_name("shop", "my_db"), "shop.my-db");
283    }
284
285    #[test]
286    fn publish_renames_and_removes() {
287        let d = tempfile::tempdir().unwrap();
288        let o = OrgId::new("acme").unwrap();
289        publish(d.path(), &o, "shop", "web", &[ip("10.0.0.2")], None).unwrap();
290        let p = d.path().join("shop.web");
291        assert_eq!(
292            std::fs::read_to_string(&p).unwrap(),
293            "10.0.0.2 web.shop.acme.isb web.shop\n"
294        );
295        use std::os::unix::fs::PermissionsExt;
296        let m = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
297        assert_eq!(m & 0o640, 0o640, "{m:o}");
298        // Nothing left behind but the file itself.
299        assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1);
300        publish(d.path(), &o, "shop", "web", &[], None).unwrap();
301        assert!(!p.exists());
302        publish(d.path(), &o, "shop", "web", &[], None).unwrap();
303
304        publish(d.path(), &o, "shop", "web", &[ip("10.0.0.2")], None).unwrap();
305        publish(d.path(), &o, "old", "db", &[ip("10.0.0.3")], None).unwrap();
306        prune(d.path(), &[("shop".into(), "web".into())]);
307        assert!(p.exists());
308        assert!(!d.path().join("old.db").exists());
309    }
310
311    #[test]
312    fn an_environment_adds_names_and_leaves_the_rest_as_it_was() {
313        let o = OrgId::new("fiftytwolabs").unwrap();
314        let ips = [ip("10.64.3.18"), ip("10.64.3.17")];
315        // Without an environment, byte for byte what live orgs resolve today.
316        assert_eq!(
317            render(&o, "chat-production", "chat-postgres", &ips, None),
318            concat!(
319                "10.64.3.17 chat-postgres.chat-production.fiftytwolabs.isb chat-postgres.chat-production\n",
320                "10.64.3.18 chat-postgres.chat-production.fiftytwolabs.isb chat-postgres.chat-production\n",
321            )
322        );
323        assert_eq!(
324            render(&o, "wiki", "redis", &ips[..1], None),
325            "10.64.3.18 redis.wiki.fiftytwolabs.isb redis.wiki\n"
326        );
327        // With one, the same line goes on with the environment's names.
328        assert_eq!(
329            render(&o, "wiki", "my_redis", &ips[..1], Some("wiki-production")),
330            "10.64.3.18 my-redis.wiki.fiftytwolabs.isb my-redis.wiki my-redis.wiki-production.fiftytwolabs.isb my-redis.wiki-production\n"
331        );
332        // Same file name either way.
333        let d = tempfile::tempdir().unwrap();
334        publish(d.path(), &o, "wiki", "redis", &ips, Some("wiki-production")).unwrap();
335        let t = std::fs::read_to_string(d.path().join("wiki.redis")).unwrap();
336        assert!(t.ends_with("redis.wiki-production\n"), "{t}");
337    }
338
339    #[test]
340    fn hosts_directories_of_gone_orgs_are_pruned_once_stale() {
341        let root = tempfile::tempdir().unwrap();
342        for d in ["acme", "gone", "fresh"] {
343            std::fs::create_dir(root.path().join(d)).unwrap();
344            std::fs::write(root.path().join(d).join("web.shop"), "10.0.0.1 web\n").unwrap();
345        }
346        // Not an org's directory: left alone whatever its age.
347        std::fs::write(root.path().join("README"), "x").unwrap();
348        let acme = [OrgId::new("acme").unwrap()];
349        // Nothing is old enough yet.
350        let hour = std::time::Duration::from_secs(3600);
351        assert!(prune_orgs_in(root.path(), &acme, hour).is_empty());
352        let gone = prune_orgs_in(root.path(), &acme, std::time::Duration::ZERO);
353        let names: Vec<&str> = gone.iter().map(OrgId::as_str).collect();
354        assert_eq!(names, ["fresh", "gone"]);
355        assert!(root.path().join("acme/web.shop").is_file());
356        assert!(root.path().join("README").is_file());
357        assert!(!root.path().join("gone").exists());
358    }
359}