Skip to main content

greentic_deployer/env_packs/local_process/
credentials.rs

1//! [`DeployerCredentials`] impl for the local-process deployer.
2//!
3//! The local-process deployer needs **no real credential material** —
4//! there are no IAM roles or cluster RBAC to provision locally. The
5//! credentials story reduces to "can the deployer actually run here":
6//!
7//! - [`FS_WRITABLE_CAP`] — the env's state directory must be writable
8//!   (the deployer writes runtime config + cached artifacts there).
9//! - [`PORT_AVAILABLE_CAP`] — at least one port in the configured range
10//!   must be bindable on `127.0.0.1` (the deployer spawns child
11//!   processes that listen on these).
12//!
13//! The probes touch only the local filesystem and a single bind+drop
14//! socket each; sub-100ms.
15//!
16//! [`bootstrap`](DeployerCredentials::bootstrap) returns
17//! [`BootstrapError::NotApplicable`] — local-process has no admin
18//! escalation path, so the honest answer to `gtc op credentials bootstrap
19//! local` is "nothing to bootstrap, run `requirements` instead". Returning
20//! `Ok` with a sentinel `credentials_ref` would leave the env pointing at
21//! material that doesn't exist.
22//!
23//! Reference shape for Phase D deployers: own [`required_capabilities`]
24//! ID strings, per-probe `Pass | Fail { reason } | Skipped { reason }`,
25//! `bootstrap` either runs or refuses with a structured message.
26
27use std::net::{Ipv4Addr, SocketAddr, TcpListener};
28use std::ops::RangeInclusive;
29use std::path::Path;
30
31use greentic_deploy_spec::EnvironmentHostConfig;
32
33use crate::credentials::{
34    BootstrapError, BootstrapInput, BootstrapOutcome, Capability, CapabilityCheck,
35    CapabilityStatus, DeployerCredentials, RequirementsReport, ValidationContext,
36};
37
38/// Stable ID for the "env state dir is writable" capability.
39pub const FS_WRITABLE_CAP: &str = "local-process.fs.writable";
40
41/// Stable ID for the "at least one port in the range is bindable"
42/// capability.
43pub const PORT_AVAILABLE_CAP: &str = "local-process.port.available";
44
45/// Default port range the local-process deployer treats as its child
46/// process listening pool. Picked to match what `greentic-start`'s
47/// default config range expects today; can be overridden per-handler
48/// via [`LocalProcessCredentials::with_port_range`].
49pub const DEFAULT_PORT_RANGE: RangeInclusive<u16> = 8080..=8090;
50
51/// Credentials handler for the local-process deployer.
52///
53/// Holds the configured port range (defaults to [`DEFAULT_PORT_RANGE`]).
54/// The handler is the same singleton for the whole process — there is
55/// nothing per-env to remember today.
56#[derive(Debug, Clone)]
57pub struct LocalProcessCredentials {
58    port_range: RangeInclusive<u16>,
59}
60
61impl Default for LocalProcessCredentials {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl LocalProcessCredentials {
68    pub fn new() -> Self {
69        Self {
70            port_range: DEFAULT_PORT_RANGE,
71        }
72    }
73
74    pub fn with_port_range(range: RangeInclusive<u16>) -> Self {
75        Self { port_range: range }
76    }
77
78    fn fs_writable_capability(&self) -> Capability {
79        Capability::new(
80            FS_WRITABLE_CAP,
81            "Env state directory is writable for the local-process deployer",
82        )
83    }
84
85    fn port_available_capability(&self, host_config: Option<&EnvironmentHostConfig>) -> Capability {
86        let description = if let Some(addr) = host_config.and_then(|hc| hc.listen_addr) {
87            format!("Configured listen_addr {addr} is bindable")
88        } else {
89            format!(
90                "At least one port in [{}-{}] is bindable on 127.0.0.1",
91                self.port_range.start(),
92                self.port_range.end()
93            )
94        };
95        Capability::new(PORT_AVAILABLE_CAP, description)
96    }
97}
98
99impl DeployerCredentials for LocalProcessCredentials {
100    fn requires_credentials_material(&self) -> bool {
101        false
102    }
103
104    fn required_capabilities(&self) -> Vec<Capability> {
105        vec![
106            self.fs_writable_capability(),
107            self.port_available_capability(None),
108        ]
109    }
110
111    fn validate(&self, ctx: &ValidationContext<'_>) -> RequirementsReport {
112        let fs_status = probe_fs_writable(ctx.env_root);
113        let port_status = probe_port_available(self.port_range.clone(), ctx.host_config);
114        RequirementsReport::new(vec![
115            CapabilityCheck {
116                capability: self.fs_writable_capability(),
117                status: fs_status,
118            },
119            CapabilityCheck {
120                capability: self.port_available_capability(Some(ctx.host_config)),
121                status: port_status,
122            },
123        ])
124    }
125
126    fn bootstrap(&self, _input: &BootstrapInput<'_>) -> Result<BootstrapOutcome, BootstrapError> {
127        Err(BootstrapError::NotApplicable(
128            "the local-process deployer has no admin escalation path — there are no \
129             IAM roles or cluster RBAC to provision locally. Run \
130             `gtc op credentials requirements <env>` instead."
131                .to_string(),
132        ))
133    }
134}
135
136/// Probe whether the env's state dir is writable by exercising the
137/// canonical `crate::environment::atomic_write::atomic_write_bytes`
138/// helper (NamedTempFile → flush → sync_all → persist → fsync parent).
139/// Catches read-only mounts and filesystems that accept buffered writes
140/// but fail on sync or rename — the original tempfile-only probe missed
141/// those. Sharing the helper guarantees the probe stays in lock-step
142/// with what the store actually does at runtime.
143///
144/// The probe writes `.local-process-creds-probe` under `env_root`, then
145/// deletes it on success. On atomic-write failure the partial file is
146/// cleaned up by the helper's NamedTempFile Drop.
147///
148/// **Limitation:** probes only `env_root` itself. The deployer writes
149/// under several subdirs (`runtime-config.json`, `revisions/`,
150/// `messaging/`, `env-packs/`, `backups/`); a read-only sub-mount on a
151/// child could still fail at startup. Enumerating subdirs is fragile
152/// (the list rotates as Phase B/D add artifacts), so this probe covers
153/// the parent and documents the gap.
154fn probe_fs_writable(env_root: &Path) -> CapabilityStatus {
155    if !env_root.exists() {
156        return CapabilityStatus::Fail {
157            reason: format!(
158                "env root `{}` does not exist (run `gtc op env init` first)",
159                env_root.display()
160            ),
161        };
162    }
163    let probe_target = env_root.join(".local-process-creds-probe");
164    match crate::environment::atomic_write::atomic_write_bytes(
165        &probe_target,
166        b"local-process-creds-probe",
167    ) {
168        Ok(()) => {
169            // Clean up the probe file. A failure to remove is non-fatal —
170            // the deployer would tolerate a stray probe file on a
171            // writable mount, and the probe has already proven its point.
172            let _ = std::fs::remove_file(&probe_target);
173            CapabilityStatus::Pass
174        }
175        Err(e) => CapabilityStatus::Fail {
176            reason: format!(
177                "atomic write probe at `{}` failed: {e}",
178                probe_target.display()
179            ),
180        },
181    }
182}
183
184/// Probe whether the deployer can bind a network listener.
185///
186/// When `host_config.listen_addr` is `Some(addr)`, probes that exact
187/// address — the env has an explicit bind target and the handler's port
188/// range is irrelevant. When `None`, falls back to the handler-level
189/// range on `127.0.0.1`.
190///
191/// **TOCTOU caveat:** Bind-and-drop is best-effort; the port may be
192/// claimed by another process between probe and deployer startup. Treat
193/// `Pass` as advisory, not a guarantee.
194fn probe_port_available(
195    range: RangeInclusive<u16>,
196    host_config: &EnvironmentHostConfig,
197) -> CapabilityStatus {
198    // Explicit listen_addr on the env — probe that exact address.
199    if let Some(addr) = host_config.listen_addr {
200        return if TcpListener::bind(addr).is_ok() {
201            CapabilityStatus::Pass
202        } else {
203            CapabilityStatus::Fail {
204                reason: format!(
205                    "configured listen_addr {addr} is not bindable — \
206                     another process may be using it"
207                ),
208            }
209        };
210    }
211
212    // No explicit listen_addr — fall back to the handler range.
213    let start = *range.start();
214    let end = *range.end();
215    if start > end {
216        return CapabilityStatus::Fail {
217            reason: format!("invalid port range [{start}-{end}]"),
218        };
219    }
220    for port in range {
221        let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
222        if TcpListener::bind(addr).is_ok() {
223            return CapabilityStatus::Pass;
224        }
225    }
226    CapabilityStatus::Fail {
227        reason: format!(
228            "no port in [{}-{}] is bindable on 127.0.0.1 — every port in the range is occupied",
229            start, end
230        ),
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::credentials::{BootstrapError, ZeroizedAdmin};
238    use greentic_deploy_spec::{EnvId, EnvironmentHostConfig};
239    use tempfile::tempdir;
240
241    fn default_host_config(env_id: &EnvId) -> EnvironmentHostConfig {
242        EnvironmentHostConfig {
243            env_id: env_id.clone(),
244            region: None,
245            tenant_org_id: None,
246            listen_addr: None,
247            public_base_url: None,
248            gui_enabled: None,
249        }
250    }
251
252    fn ctx<'a>(
253        env_root: &'a Path,
254        env_id: &'a EnvId,
255        host_config: &'a EnvironmentHostConfig,
256    ) -> ValidationContext<'a> {
257        ValidationContext {
258            env_id,
259            env_root,
260            host_config,
261        }
262    }
263
264    #[test]
265    fn required_capabilities_are_the_documented_two() {
266        let creds = LocalProcessCredentials::default();
267        let caps: Vec<_> = creds
268            .required_capabilities()
269            .into_iter()
270            .map(|c| c.id)
271            .collect();
272        assert_eq!(caps, vec![FS_WRITABLE_CAP, PORT_AVAILABLE_CAP]);
273    }
274
275    #[test]
276    fn requires_credentials_material_is_false() {
277        let creds = LocalProcessCredentials::default();
278        assert!(
279            !creds.requires_credentials_material(),
280            "local-process deployer needs no credential material"
281        );
282    }
283
284    #[test]
285    fn validate_passes_on_writable_dir_with_free_port() {
286        // tempdir is writable; pick a range likely to have a free port
287        // (very high range; if every port in 49000..=49100 is taken on
288        // the test runner we have bigger problems).
289        let dir = tempdir().unwrap();
290        let env_id = EnvId::try_from("local").unwrap();
291        let hc = default_host_config(&env_id);
292        let creds = LocalProcessCredentials::with_port_range(49000..=49100);
293        let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
294        assert!(report.passed(), "report: {report:?}");
295        assert!(
296            report.missing().is_empty(),
297            "no missing caps; got {:?}",
298            report.missing()
299        );
300    }
301
302    #[test]
303    fn validate_fails_fs_when_env_root_missing() {
304        let env_id = EnvId::try_from("local").unwrap();
305        let hc = default_host_config(&env_id);
306        let creds = LocalProcessCredentials::default();
307        let missing_root = Path::new("/this/path/does/not/exist/for/probing");
308        let report = creds.validate(&ctx(missing_root, &env_id, &hc));
309        assert!(!report.passed());
310        let fs_check = report
311            .checks
312            .iter()
313            .find(|c| c.capability.id == FS_WRITABLE_CAP)
314            .unwrap();
315        match &fs_check.status {
316            CapabilityStatus::Fail { reason } => {
317                assert!(reason.contains("does not exist"), "reason: {reason}");
318            }
319            other => panic!("expected Fail, got {other:?}"),
320        }
321    }
322
323    /// Bind every port in a tiny range first, then assert the probe
324    /// reports Fail for that range. Holds the listeners for the
325    /// duration of the probe so the bind contention is real, not
326    /// flaky.
327    #[test]
328    fn validate_fails_port_when_range_is_occupied() {
329        // Bind two arbitrary high ports the OS gives us, then ask the
330        // probe to scan that exact pair. We must hold the listeners
331        // through the probe call; dropping them releases the ports.
332        let l1 = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
333        let l2 = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
334        let p1 = l1.local_addr().unwrap().port();
335        let p2 = l2.local_addr().unwrap().port();
336        let (lo, hi) = if p1 <= p2 { (p1, p2) } else { (p2, p1) };
337
338        let creds = LocalProcessCredentials::with_port_range(lo..=hi);
339        // If `lo..=hi` contains a port other than p1/p2 that happens to
340        // be free, the probe legitimately passes — that's not a probe
341        // bug, it's the probe doing its job. Only assert Fail when the
342        // range is exactly two contiguous ports we hold.
343        if hi == lo + 1 {
344            let env_id = EnvId::try_from("local").unwrap();
345            let hc = default_host_config(&env_id);
346            let dir = tempdir().unwrap();
347            let report = creds.validate(&ctx(dir.path(), &env_id, &hc));
348            let port_check = report
349                .checks
350                .iter()
351                .find(|c| c.capability.id == PORT_AVAILABLE_CAP)
352                .unwrap();
353            assert!(
354                matches!(port_check.status, CapabilityStatus::Fail { .. }),
355                "expected Fail (every port in [{lo}-{hi}] is bound), got {:?}",
356                port_check.status
357            );
358        }
359        // Hold the listeners until the assertions complete.
360        drop(l1);
361        drop(l2);
362    }
363
364    #[test]
365    fn bootstrap_rejects_as_not_applicable() {
366        let creds = LocalProcessCredentials::default();
367        let env_id = EnvId::try_from("local").unwrap();
368        let dir = tempdir().unwrap();
369        let admin = ZeroizedAdmin::new("admin", "irrelevant".to_string());
370        let input = BootstrapInput {
371            env_id: &env_id,
372            env_root: dir.path(),
373            admin: &admin,
374        };
375        let err = creds.bootstrap(&input).unwrap_err();
376        match err {
377            BootstrapError::NotApplicable(msg) => {
378                assert!(msg.contains("no admin escalation"), "msg: {msg}");
379                assert!(
380                    msg.contains("requirements"),
381                    "msg should point user at `requirements`: {msg}"
382                );
383            }
384            other => panic!("expected NotApplicable, got {other:?}"),
385        }
386    }
387
388    #[test]
389    fn invalid_port_range_fails_loudly() {
390        // RangeInclusive::new(10, 9) is constructible but empty; the
391        // probe must report Fail rather than vacuously Pass. Construct
392        // via `new` to dodge clippy::reversed_empty_ranges on literal
393        // syntax — the lint protects against accidental reversal, but
394        // here the reversal is the test subject.
395        let range = std::ops::RangeInclusive::new(10u16, 9u16);
396        let env_id = EnvId::try_from("local").unwrap();
397        let hc = default_host_config(&env_id);
398        let status = probe_port_available(range, &hc);
399        match status {
400            CapabilityStatus::Fail { reason } => {
401                assert!(reason.contains("invalid port range"), "reason: {reason}");
402            }
403            other => panic!("expected Fail, got {other:?}"),
404        }
405    }
406
407    /// When `host_config.listen_addr` is set, the port probe targets
408    /// that exact address instead of scanning the handler range. Binding
409    /// the address first makes the probe fail — confirming it respects
410    /// the env's configured listen_addr.
411    #[test]
412    fn probe_port_available_respects_host_config_listen_addr() {
413        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
414        let bound_addr = listener.local_addr().unwrap();
415        let env_id = EnvId::try_from("local").unwrap();
416        let hc = EnvironmentHostConfig {
417            env_id: env_id.clone(),
418            region: None,
419            tenant_org_id: None,
420            listen_addr: Some(bound_addr),
421            public_base_url: None,
422            gui_enabled: None,
423        };
424        // With the port occupied and listen_addr pointing at it, probe
425        // must report Fail.
426        let status = probe_port_available(DEFAULT_PORT_RANGE, &hc);
427        match status {
428            CapabilityStatus::Fail { reason } => {
429                assert!(
430                    reason.contains(&bound_addr.to_string()),
431                    "reason should mention the configured addr, got: {reason}"
432                );
433            }
434            other => panic!("expected Fail for occupied listen_addr, got {other:?}"),
435        }
436        // After releasing the port, probe must Pass.
437        drop(listener);
438        let status = probe_port_available(DEFAULT_PORT_RANGE, &hc);
439        assert!(
440            matches!(status, CapabilityStatus::Pass),
441            "expected Pass after releasing listen_addr, got {status:?}"
442        );
443    }
444
445    /// The atomic-write probe fails on a read-only directory (Unix
446    /// only — uses `chmod 0o555`).
447    #[cfg(unix)]
448    #[test]
449    fn probe_fs_writable_fails_on_read_only_dir() {
450        use std::os::unix::fs::PermissionsExt;
451        let dir = tempdir().unwrap();
452        // Make the directory read-only.
453        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
454        let status = probe_fs_writable(dir.path());
455        // Restore write permission before assertions so the tempdir
456        // cleanup doesn't fail.
457        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
458        match status {
459            CapabilityStatus::Fail { reason } => {
460                assert!(
461                    reason.contains("could not create probe file")
462                        || reason.contains("persist")
463                        || reason.contains("write probe"),
464                    "reason should indicate a write failure, got: {reason}"
465                );
466            }
467            other => panic!("expected Fail on read-only dir, got {other:?}"),
468        }
469    }
470}