Skip to main content

runner_manager_platform/
coordinator.rs

1//! Whether this host has joined a coordinator, and how its link is doing.
2//!
3//! Two small records, one in each direction, like [`crate::polling`]:
4//!
5//! * **`config/coordinator.toml`** — the coordinator's URL, this host's id
6//!   there and the name it was given. Written by `coordinator join`, removed
7//!   by `coordinator leave`. Nothing secret: the host's private key lives in
8//!   the secret store ([`crate::secrets::SecretItem::CoordinatorKey`]), never
9//!   here. An absent file means "not configured", and the daemon then behaves
10//!   exactly as it does without a coordinator.
11//! * **`state/coordinator-link.toml`** — written by the daemon, read by
12//!   `status`, `host doctor` and the TUI, which run in the operator's
13//!   terminal while the daemon runs in a service.
14
15use std::path::PathBuf;
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20use crate::paths::AppPaths;
21use crate::service::{ServiceError, read_state_record, remove_state_record, write_state_record};
22
23/// The setting, inside `config/`.
24pub const CONFIG_FILE: &str = "coordinator.toml";
25
26/// The daemon's link state, inside `state/`.
27pub const LINK_FILE: &str = "coordinator-link.toml";
28
29const CONFIG_SCHEMA_VERSION: u32 = 1;
30const LINK_SCHEMA_VERSION: u32 = 1;
31
32/// What `coordinator join` recorded.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct CoordinatorConfig {
35    pub schema_version: u32,
36    /// The coordinator's origin, `https://host[:port]`.
37    pub url: String,
38    /// This host's id at the coordinator.
39    pub host_id: String,
40    /// The name the coordinator's operator gave this host.
41    pub name: String,
42    pub joined_at: DateTime<Utc>,
43}
44
45impl CoordinatorConfig {
46    #[must_use]
47    pub const fn new(url: String, host_id: String, name: String, joined_at: DateTime<Utc>) -> Self {
48        Self {
49            schema_version: CONFIG_SCHEMA_VERSION,
50            url,
51            host_id,
52            name,
53            joined_at,
54        }
55    }
56}
57
58fn config_path(paths: &AppPaths) -> PathBuf {
59    paths.config_dir().join(CONFIG_FILE)
60}
61
62fn link_path(paths: &AppPaths) -> PathBuf {
63    paths.state_dir().join(LINK_FILE)
64}
65
66/// The joined coordinator, or `None` when this host has not joined one.
67///
68/// # Errors
69/// [`ServiceError::Record`] when the file exists and cannot be read.
70pub fn configured(paths: &AppPaths) -> Result<Option<CoordinatorConfig>, ServiceError> {
71    read_state_record(&config_path(paths))
72}
73
74/// Records the joined coordinator.
75///
76/// # Errors
77/// [`ServiceError::Record`] when `config/` cannot be written.
78pub fn save(paths: &AppPaths, config: &CoordinatorConfig) -> Result<(), ServiceError> {
79    write_state_record(&config_path(paths), config)
80}
81
82/// Forgets the coordinator and the daemon's last link state.
83///
84/// # Errors
85/// [`ServiceError::Record`] when either file cannot be removed.
86pub fn forget(paths: &AppPaths) -> Result<(), ServiceError> {
87    remove_state_record(&config_path(paths))?;
88    remove_state_record(&link_path(paths))
89}
90
91/// The daemon's link to its coordinator, as it last wrote it.
92// `default` on every field: an older or newer CLI must read what the service
93// wrote.
94#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
95#[serde(default)]
96pub struct LinkRecord {
97    pub schema_version: u32,
98    pub written_at: DateTime<Utc>,
99    pub connected: bool,
100    /// Since when the link has been in its current state.
101    pub since: Option<DateTime<Utc>>,
102    /// Why the link is down, as a fixed word (`unreachable`, `rejected`,
103    /// `no_key`, …); never text from the wire.
104    pub reason: Option<String>,
105    pub last_wake_at: Option<DateTime<Utc>>,
106    /// Wakes received since the daemon started.
107    pub wakes_received: u64,
108    /// How often the coordinator's reconciliation sweep runs, as its last
109    /// `hello_ack` said; `None` when it runs none or never said.
110    pub reconcile_interval_secs: Option<u32>,
111    /// The targets that sweep covers, scope included: `repo:owner/name` or
112    /// `org:login`.
113    pub covered: Vec<String>,
114}
115
116impl LinkRecord {
117    #[must_use]
118    pub fn new(written_at: DateTime<Utc>) -> Self {
119        Self {
120            schema_version: LINK_SCHEMA_VERSION,
121            written_at,
122            ..Self::default()
123        }
124    }
125}
126
127/// Writes the link state.
128///
129/// # Errors
130/// [`ServiceError::Record`] when `state/` cannot be written.
131pub fn record_link(paths: &AppPaths, link: &LinkRecord) -> Result<(), ServiceError> {
132    write_state_record(&link_path(paths), link)
133}
134
135/// The daemon's last link state, or `None` when it never wrote one.
136///
137/// # Errors
138/// [`ServiceError::Record`] when the file exists and cannot be read.
139pub fn last_link(paths: &AppPaths) -> Result<Option<LinkRecord>, ServiceError> {
140    read_state_record(&link_path(paths))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn the_setting_and_the_link_round_trip_and_leave_removes_both() {
149        let dir = tempfile::tempdir().expect("a temporary directory");
150        let paths = AppPaths::rooted_at(dir.path());
151        assert_eq!(configured(&paths).expect("absent"), None);
152        let config = CoordinatorConfig::new(
153            "https://runners.example.com".into(),
154            "h-1".into(),
155            "win-big".into(),
156            Utc::now(),
157        );
158        save(&paths, &config).expect("written");
159        assert_eq!(configured(&paths).expect("read"), Some(config));
160
161        let mut link = LinkRecord::new(Utc::now());
162        link.connected = true;
163        link.wakes_received = 3;
164        record_link(&paths, &link).expect("written");
165        assert_eq!(last_link(&paths).expect("read"), Some(link));
166
167        forget(&paths).expect("removed");
168        assert_eq!(configured(&paths).expect("absent"), None);
169        assert_eq!(last_link(&paths).expect("absent"), None);
170        forget(&paths).expect("removing nothing is fine");
171    }
172}