greentic_deployer/env_packs/local_process/
credentials.rs1use 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
38pub const FS_WRITABLE_CAP: &str = "local-process.fs.writable";
40
41pub const PORT_AVAILABLE_CAP: &str = "local-process.port.available";
44
45pub const DEFAULT_PORT_RANGE: RangeInclusive<u16> = 8080..=8090;
50
51#[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
136fn 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 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
184fn probe_port_available(
195 range: RangeInclusive<u16>,
196 host_config: &EnvironmentHostConfig,
197) -> CapabilityStatus {
198 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 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 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 #[test]
328 fn validate_fails_port_when_range_is_occupied() {
329 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 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 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 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 #[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 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 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 #[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 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
454 let status = probe_fs_writable(dir.path());
455 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}