Skip to main content

gobby_core/provisioning/
hub.rs

1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct EnsureHubOptions {
5    pub gobby_home: PathBuf,
6    pub service_options: DockerServiceOptions,
7    pub candidate_database_urls: Vec<String>,
8    pub provision_services: bool,
9}
10
11impl EnsureHubOptions {
12    pub fn new(gobby_home: PathBuf) -> Self {
13        Self {
14            service_options: DockerServiceOptions::new(gobby_home.clone()),
15            gobby_home,
16            candidate_database_urls: Vec::new(),
17            provision_services: true,
18        }
19    }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct HubIdentity {
24    pub system_identifier: String,
25    pub database_name: String,
26}
27
28impl HubIdentity {
29    fn display_label(&self) -> String {
30        format!(
31            "system_identifier={}, database={}",
32            self.system_identifier, self.database_name
33        )
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum HubIdentityProbeResult {
39    Known(HubIdentity),
40    UnknownInsufficientPrivilege { message: String },
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum RecordedHubIdentityStatus {
45    SingleReachable,
46    VerifiedSameHub,
47    IdentityUnknownInsufficientPrivilege { message: String },
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct RecordedHubResolution {
52    pub database_url: String,
53    pub identity_status: RecordedHubIdentityStatus,
54}
55
56pub fn ensure_hub(
57    options: &EnsureHubOptions,
58) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
59    ensure_hub_with_identity(
60        options,
61        |name| std::env::var(name).ok(),
62        postgres_database_reachable,
63        probe_postgres_hub_identity,
64        provision_docker_services,
65    )
66}
67
68#[cfg(test)]
69pub(crate) fn ensure_hub_with(
70    options: &EnsureHubOptions,
71    get_env: impl FnMut(&str) -> Option<String>,
72    database_reachable: impl FnMut(&str) -> bool,
73    provision: impl FnOnce(&DockerServiceOptions) -> anyhow::Result<DockerProvisioningReport>,
74) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
75    ensure_hub_with_identity(
76        options,
77        get_env,
78        database_reachable,
79        |_| {
80            Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
81                message: "identity_unknown_insufficient_privilege: test probe not configured"
82                    .to_string(),
83            })
84        },
85        provision,
86    )
87}
88
89pub(crate) fn ensure_hub_with_identity(
90    options: &EnsureHubOptions,
91    mut get_env: impl FnMut(&str) -> Option<String>,
92    mut database_reachable: impl FnMut(&str) -> bool,
93    mut identity_probe: impl FnMut(&str) -> anyhow::Result<HubIdentityProbeResult>,
94    provision: impl FnOnce(&DockerServiceOptions) -> anyhow::Result<DockerProvisioningReport>,
95) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
96    let mut override_database_url = None;
97    let mut gcore_database_url = None;
98    let mut bootstrap_database_url = None;
99
100    for candidate in resolve_hub_database_urls(options, &mut get_env)? {
101        match candidate.source {
102            HubDatabaseUrlSource::Candidate | HubDatabaseUrlSource::Env => {
103                if override_database_url.is_none()
104                    && explicit_database_url_reachable(
105                        &candidate.database_url,
106                        &mut database_reachable,
107                    )
108                {
109                    override_database_url = Some(candidate.database_url);
110                }
111            }
112            HubDatabaseUrlSource::GcoreConfig => {
113                gcore_database_url = Some(candidate.database_url);
114            }
115            HubDatabaseUrlSource::Bootstrap => {
116                bootstrap_database_url = Some(candidate.database_url);
117            }
118        }
119    }
120
121    let recorded_resolution = resolve_recorded_hub_database_url(
122        gcore_database_url.as_deref(),
123        bootstrap_database_url.as_deref(),
124        &mut database_reachable,
125        &mut identity_probe,
126    )?;
127
128    if let Some(override_database_url) = override_database_url {
129        if let Some(recorded) = recorded_resolution
130            && let Some(resolution) = resolve_recorded_hub_database_url(
131                Some(&recorded.database_url),
132                Some(&override_database_url),
133                &mut database_reachable,
134                &mut identity_probe,
135            )?
136        {
137            if let RecordedHubIdentityStatus::IdentityUnknownInsufficientPrivilege { message } =
138                &resolution.identity_status
139            {
140                log::warn!("{message}");
141            }
142            return Ok((resolution.database_url, None));
143        }
144        return Ok((override_database_url, None));
145    }
146
147    if let Some(resolution) = recorded_resolution {
148        if let RecordedHubIdentityStatus::IdentityUnknownInsufficientPrivilege { message } =
149            &resolution.identity_status
150        {
151            log::warn!("{message}");
152        }
153        return Ok((resolution.database_url, None));
154    }
155
156    if !options.provision_services {
157        anyhow::bail!(
158            "no reachable Gobby PostgreSQL hub found and service provisioning is disabled"
159        );
160    }
161
162    let report = provision(&options.service_options).context("failed to provision Gobby hub")?;
163    Ok((
164        default_database_url(options.service_options.postgres_port),
165        Some(report),
166    ))
167}
168
169pub fn resolve_recorded_hub_database_url(
170    existing_database_url: Option<&str>,
171    daemon_database_url: Option<&str>,
172    mut database_reachable: impl FnMut(&str) -> bool,
173    mut identity_probe: impl FnMut(&str) -> anyhow::Result<HubIdentityProbeResult>,
174) -> anyhow::Result<Option<RecordedHubResolution>> {
175    let existing_database_url =
176        existing_database_url.and_then(|value| non_empty_trimmed(Some(value.to_string())));
177    let daemon_database_url =
178        daemon_database_url.and_then(|value| non_empty_trimmed(Some(value.to_string())));
179
180    match (existing_database_url, daemon_database_url) {
181        (None, None) => Ok(None),
182        (Some(existing), None) => {
183            if database_reachable(&existing) {
184                Ok(Some(RecordedHubResolution {
185                    database_url: existing,
186                    identity_status: RecordedHubIdentityStatus::SingleReachable,
187                }))
188            } else {
189                Ok(None)
190            }
191        }
192        (None, Some(daemon)) => {
193            if database_reachable(&daemon) {
194                Ok(Some(RecordedHubResolution {
195                    database_url: daemon,
196                    identity_status: RecordedHubIdentityStatus::SingleReachable,
197                }))
198            } else {
199                Ok(None)
200            }
201        }
202        (Some(existing), Some(daemon)) if existing == daemon => {
203            if database_reachable(&daemon) {
204                Ok(Some(RecordedHubResolution {
205                    database_url: daemon,
206                    identity_status: RecordedHubIdentityStatus::VerifiedSameHub,
207                }))
208            } else {
209                Ok(None)
210            }
211        }
212        (Some(existing), Some(daemon)) => {
213            let existing_reachable = database_reachable(&existing);
214            let daemon_reachable = database_reachable(&daemon);
215
216            match (existing_reachable, daemon_reachable) {
217                (false, false) => Ok(None),
218                (true, false) => Ok(Some(RecordedHubResolution {
219                    database_url: existing,
220                    identity_status: RecordedHubIdentityStatus::SingleReachable,
221                })),
222                (false, true) => Ok(Some(RecordedHubResolution {
223                    database_url: daemon,
224                    identity_status: RecordedHubIdentityStatus::SingleReachable,
225                })),
226                (true, true) => {
227                    let existing_redacted = redacted_postgres_dsn_placeholder("existing");
228                    let daemon_redacted = redacted_postgres_dsn_placeholder("daemon");
229                    let existing_identity = identity_probe(&existing).with_context(|| {
230                        format!("failed to probe PostgreSQL hub identity for {existing_redacted}")
231                    })?;
232                    let daemon_identity = identity_probe(&daemon).with_context(|| {
233                        format!("failed to probe PostgreSQL hub identity for {daemon_redacted}")
234                    })?;
235
236                    match (existing_identity, daemon_identity) {
237                        (
238                            HubIdentityProbeResult::Known(existing_identity),
239                            HubIdentityProbeResult::Known(daemon_identity),
240                        ) if existing_identity == daemon_identity => {
241                            Ok(Some(RecordedHubResolution {
242                                database_url: daemon,
243                                identity_status: RecordedHubIdentityStatus::VerifiedSameHub,
244                            }))
245                        }
246                        (
247                            HubIdentityProbeResult::Known(existing_identity),
248                            HubIdentityProbeResult::Known(daemon_identity),
249                        ) => Err(CoreError::HubConflict {
250                            existing_database_url: existing_redacted,
251                            existing_identity: existing_identity.display_label(),
252                            daemon_database_url: daemon_redacted,
253                            daemon_identity: daemon_identity.display_label(),
254                        }
255                        .into()),
256                        (
257                            HubIdentityProbeResult::UnknownInsufficientPrivilege { message },
258                            _,
259                        )
260                        | (
261                            _,
262                            HubIdentityProbeResult::UnknownInsufficientPrivilege { message },
263                        ) => Ok(Some(RecordedHubResolution {
264                            database_url: existing,
265                            identity_status:
266                                RecordedHubIdentityStatus::IdentityUnknownInsufficientPrivilege {
267                                    message: format!(
268                                        "identity_unknown_insufficient_privilege: preserving existing recorded hub {}; daemon hub {} was not adopted because identity could not be verified ({message})",
269                                        existing_redacted,
270                                        daemon_redacted,
271                                    ),
272                                },
273                        })),
274                    }
275                }
276            }
277        }
278    }
279}
280
281fn redacted_postgres_dsn_placeholder(source: &str) -> String {
282    format!("<redacted-{source}-postgres-dsn>")
283}
284
285#[cfg(feature = "postgres")]
286pub fn probe_postgres_hub_identity(database_url: &str) -> anyhow::Result<HubIdentityProbeResult> {
287    use anyhow::Context;
288    use postgres::error::SqlState;
289
290    fn insufficient_privilege(error: &postgres::Error) -> bool {
291        error.code() == Some(&SqlState::INSUFFICIENT_PRIVILEGE)
292    }
293
294    let mut conn = crate::postgres::connect_readonly(database_url)?;
295    let has_privilege = match conn.query_one(
296        "SELECT has_function_privilege(current_user, 'pg_control_system()', 'execute')",
297        &[],
298    ) {
299        Ok(row) => row.get::<_, bool>(0),
300        Err(error) if insufficient_privilege(&error) => {
301            return Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
302                message: "identity_unknown_insufficient_privilege: current role cannot preflight pg_control_system()".to_string(),
303            });
304        }
305        Err(error) => {
306            return Err(error).context("failed to preflight pg_control_system() privilege");
307        }
308    };
309
310    if !has_privilege {
311        return Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
312            message: "identity_unknown_insufficient_privilege: current role cannot execute pg_control_system()".to_string(),
313        });
314    }
315
316    let row = match conn.query_one(
317        "SELECT system_identifier::text AS system_identifier, current_database() AS database_name FROM pg_control_system()",
318        &[],
319    ) {
320        Ok(row) => row,
321        Err(error) if insufficient_privilege(&error) => {
322            return Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
323                message: "identity_unknown_insufficient_privilege: current role cannot execute pg_control_system()".to_string(),
324            });
325        }
326        Err(error) => return Err(error).context("failed to query PostgreSQL hub identity"),
327    };
328
329    Ok(HubIdentityProbeResult::Known(HubIdentity {
330        system_identifier: row
331            .try_get("system_identifier")
332            .context("PostgreSQL hub identity did not include system_identifier")?,
333        database_name: row
334            .try_get("database_name")
335            .context("PostgreSQL hub identity did not include database_name")?,
336    }))
337}
338
339#[cfg(not(feature = "postgres"))]
340pub fn probe_postgres_hub_identity(_database_url: &str) -> anyhow::Result<HubIdentityProbeResult> {
341    Ok(HubIdentityProbeResult::UnknownInsufficientPrivilege {
342        message: "identity_unknown_insufficient_privilege: gobby-core was built without PostgreSQL support".to_string(),
343    })
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347enum HubDatabaseUrlSource {
348    Candidate,
349    Env,
350    GcoreConfig,
351    Bootstrap,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
355struct HubDatabaseUrl {
356    source: HubDatabaseUrlSource,
357    database_url: String,
358}
359
360fn resolve_hub_database_urls(
361    options: &EnsureHubOptions,
362    get_env: &mut impl FnMut(&str) -> Option<String>,
363) -> anyhow::Result<Vec<HubDatabaseUrl>> {
364    let mut urls = Vec::new();
365    urls.extend(
366        options
367            .candidate_database_urls
368            .iter()
369            .filter_map(|value| non_empty_trimmed(Some(value.clone())))
370            .map(|database_url| HubDatabaseUrl {
371                source: HubDatabaseUrlSource::Candidate,
372                database_url,
373            }),
374    );
375    if let Some(database_url) = non_empty_trimmed(get_env("GOBBY_POSTGRES_DSN")) {
376        urls.push(HubDatabaseUrl {
377            source: HubDatabaseUrlSource::Env,
378            database_url,
379        });
380    }
381    if let Some(database_url) = resolve_database_url_from_gcore_config(&options.gobby_home)? {
382        urls.push(HubDatabaseUrl {
383            source: HubDatabaseUrlSource::GcoreConfig,
384            database_url,
385        });
386    }
387    if let Some(database_url) =
388        resolve_database_url_from_bootstrap_file(&options.gobby_home.join("bootstrap.yaml"))?
389    {
390        urls.push(HubDatabaseUrl {
391            source: HubDatabaseUrlSource::Bootstrap,
392            database_url,
393        });
394    }
395    Ok(urls)
396}
397
398fn resolve_database_url_from_gcore_config(home: &Path) -> anyhow::Result<Option<String>> {
399    if !services_dir(home).is_dir() || !compose_file_path(home).is_file() {
400        return Ok(None);
401    }
402    let Some(config) = StandaloneConfig::read_at(&gcore_config_path(home))? else {
403        return Ok(None);
404    };
405    Ok(config
406        .get("databases.postgres.dsn")
407        .and_then(|value| non_empty_trimmed(Some(value.to_string()))))
408}
409
410#[derive(Debug, Deserialize)]
411struct HubBootstrap {
412    hub_backend: Option<String>,
413    database_url: Option<String>,
414}
415
416fn resolve_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
417    if !path.exists() {
418        return Ok(None);
419    }
420    let contents = fs::read_to_string(path)
421        .with_context(|| format!("failed to read Gobby bootstrap at {}", path.display()))?;
422    let bootstrap: HubBootstrap = serde_yaml::from_str(&contents)
423        .with_context(|| format!("failed to parse {}", path.display()))?;
424    if matches!(bootstrap.hub_backend.as_deref(), Some(backend) if backend != "postgres") {
425        return Ok(None);
426    }
427    Ok(non_empty_trimmed(bootstrap.database_url))
428}
429
430fn non_empty_trimmed(value: Option<String>) -> Option<String> {
431    let trimmed = value.as_ref()?.trim();
432    if trimmed.is_empty() {
433        None
434    } else {
435        Some(trimmed.to_string())
436    }
437}
438
439#[cfg(feature = "postgres")]
440fn postgres_database_reachable(database_url: &str) -> bool {
441    crate::postgres::connect_readonly(database_url).is_ok()
442}
443
444#[cfg(not(feature = "postgres"))]
445fn postgres_database_reachable(_database_url: &str) -> bool {
446    false
447}
448
449#[cfg(feature = "postgres")]
450fn explicit_database_url_reachable(
451    database_url: &str,
452    database_reachable: &mut impl FnMut(&str) -> bool,
453) -> bool {
454    database_reachable(database_url)
455}
456
457#[cfg(not(feature = "postgres"))]
458fn explicit_database_url_reachable(
459    _database_url: &str,
460    _database_reachable: &mut impl FnMut(&str) -> bool,
461) -> bool {
462    // Without the postgres feature, gcore cannot open a connection to probe an
463    // explicit hub DSN. Preserve the configured DSN and let the consumer fail
464    // later if it actually needs PostgreSQL access.
465    log::warn!(
466        "postgres feature is disabled; preserving configured PostgreSQL hub {} without a reachability probe",
467        redacted_postgres_dsn_placeholder("explicit")
468    );
469    true
470}