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 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}