//! Application inventory, read from the Docker daemon.
//!
//! `once` renders its own listing through a bubbletea TUI, so its stdout is
//! styled text rather than a stable machine format. The daemon, by contrast,
//! labels every `once`-managed container with a `once` JSON label — so that is
//! what `twice` reads. Nothing here mutates anything.
use std::collections::HashMap;
use bollard::Docker;
use bollard::query_parameters::ListContainersOptionsBuilder;
use serde::Deserialize;
use twice_core::{AppSummary, ContainerName, ContainerState, Host, ImageRef};
/// Label key `once` stamps onto the containers and volumes it manages.
const ONCE_LABEL: &str = "once";
/// Read-only view of the applications `once` manages on this host.
#[derive(Debug)]
pub(crate) struct DockerInventory {
client: Docker,
proxy_container: String,
}
/// The subset of the `once` label payload `twice` understands. Unknown keys are
/// ignored rather than rejected, because the label is `once`'s private format.
#[derive(Debug, Default, Deserialize)]
struct OnceLabel {
#[serde(default, alias = "hostname", alias = "Host")]
host: Option<String>,
#[serde(default, rename = "autoUpdate", alias = "auto_update")]
auto_update: Option<bool>,
#[serde(default, rename = "autoBackup", alias = "auto_backup")]
auto_backup: Option<bool>,
}
impl DockerInventory {
/// Connects to the local daemon over its usual socket or `DOCKER_HOST`.
pub(crate) fn connect(namespace: &str) -> Result<Self, bollard::errors::Error> {
Ok(Self {
client: Docker::connect_with_defaults()?,
proxy_container: format!("{namespace}-proxy"),
})
}
/// Lists every `once`-managed application, running or not.
pub(crate) async fn list_apps(&self) -> Result<Vec<AppSummary>, bollard::errors::Error> {
let mut filters = HashMap::new();
filters.insert("label".to_owned(), vec![ONCE_LABEL.to_owned()]);
let options = ListContainersOptionsBuilder::default()
.all(true)
.filters(&filters)
.build();
let mut apps: Vec<AppSummary> = self
.client
.list_containers(Some(options))
.await?
.into_iter()
.filter_map(|container| {
let name = container
.names
.unwrap_or_default()
.first()
.map(|n| n.trim_start_matches('/').to_owned())?;
if name == self.proxy_container {
return None;
}
let label = container
.labels
.as_ref()
.and_then(|labels| labels.get(ONCE_LABEL))
.and_then(|raw| serde_json::from_str::<OnceLabel>(raw).ok())
.unwrap_or_default();
let host = label
.host
.as_deref()
.and_then(|h| Host::parse(h).ok())
.or_else(|| Host::parse(&name).ok())?;
let image = container
.image
.as_deref()
.and_then(|i| ImageRef::parse(i).ok())?;
Some(AppSummary {
host,
container: ContainerName(name),
image,
state: container.state.map_or(ContainerState::Unknown, |s| {
ContainerState::from_docker(&s.to_string().to_lowercase())
}),
status: container.status.unwrap_or_default(),
auto_update: label.auto_update,
auto_backup: label.auto_backup,
})
})
.collect();
apps.sort_by(|a, b| a.host.cmp(&b.host));
Ok(apps)
}
/// Whether an application with this host currently exists.
pub(crate) async fn knows_host(&self, host: &Host) -> Result<bool, bollard::errors::Error> {
Ok(self.list_apps().await?.iter().any(|app| &app.host == host))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn once_label_tolerates_unknown_keys_and_casings() {
let raw = r#"{"host":"writebook.example.com","autoUpdate":true,"httpPort":80}"#;
let parsed: OnceLabel = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.host.as_deref(), Some("writebook.example.com"));
assert_eq!(parsed.auto_update, Some(true));
assert_eq!(parsed.auto_backup, None);
}
#[test]
fn once_label_without_a_host_parses_as_empty() {
let parsed: OnceLabel =
serde_json::from_str(r#"{"httpPort":80,"metricsPort":1318}"#).unwrap();
assert_eq!(parsed.host, None);
}
}