1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command as ProcessCommand;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use serde_yaml_bw as serde_yaml;
10
11use crate::config::{OutputFormat, Provider};
12use crate::error::{DeployerError, Result};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct AdminSecretRefs {
16 pub admin_ca_secret_ref: Option<String>,
17 pub admin_server_cert_secret_ref: Option<String>,
18 pub admin_server_key_secret_ref: Option<String>,
19 pub admin_client_cert_secret_ref: Option<String>,
20 pub admin_client_key_secret_ref: Option<String>,
21 pub admin_relay_token_secret_ref: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub enum AdminAccessMode {
26 AwsSsmPortForward,
27 LoopbackOnly,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct AdminTunnelSupport {
32 pub supported: bool,
33 pub mode: Option<AdminAccessMode>,
34 pub reason: Option<String>,
35 pub command_hint: Option<String>,
36 pub local_port_default: u16,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct AdminAccessInfo {
41 pub provider: String,
42 pub bundle_dir: PathBuf,
43 pub deploy_dir: PathBuf,
44 pub local_cert_dir: PathBuf,
45 pub admin_access_mode: Option<String>,
46 pub admin_public_endpoint: Option<String>,
47 pub operator_endpoint: Option<String>,
48 pub deployment_name_prefix: Option<String>,
49 pub operator_host: Option<String>,
50 pub provider_details: AdminProviderDetails,
51 pub admin_listener: String,
52 pub admin_secret_refs: AdminSecretRefs,
53 pub client_credentials_available: bool,
54 pub missing_requirements: Vec<String>,
55 pub tunnel_support: AdminTunnelSupport,
56 pub suggested_commands: Vec<String>,
57 pub curl_health_example: Option<String>,
58 pub notes: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct MaterializedAdminCerts {
63 pub provider: String,
64 pub cert_dir: PathBuf,
65 pub ca_cert_path: PathBuf,
66 pub client_cert_path: PathBuf,
67 pub client_key_path: PathBuf,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct MaterializedAdminRelayToken {
72 pub provider: String,
73 pub token: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct AdminHealthProbe {
78 pub provider: String,
79 pub endpoint: String,
80 pub status: u16,
81 pub ok: bool,
82 pub body: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
86pub struct AdminProviderDetails {
87 pub aws_region: Option<String>,
88 pub aws_cluster_name: Option<String>,
89 pub aws_service_name: Option<String>,
90 pub azure_resource_group_name: Option<String>,
91 pub azure_container_app_name: Option<String>,
92 pub gcp_project_id: Option<String>,
93 pub gcp_cloud_run_service_name: Option<String>,
94}
95
96pub fn resolve_admin_access(bundle_dir: &Path, provider: Provider) -> Result<AdminAccessInfo> {
97 match provider {
98 Provider::Aws => resolve_provider_admin_access(bundle_dir, "aws", provider),
99 Provider::Azure => resolve_provider_admin_access(bundle_dir, "azure", provider),
100 Provider::Gcp => resolve_provider_admin_access(bundle_dir, "gcp", provider),
101 other => Err(DeployerError::Other(format!(
102 "admin access is only available for cloud providers (aws, azure, gcp), got {}",
103 other.as_str()
104 ))),
105 }
106}
107
108pub fn render_admin_access(info: &AdminAccessInfo, output: OutputFormat) -> Result<String> {
109 match output {
110 OutputFormat::Text => Ok(render_admin_access_text(info)),
111 OutputFormat::Json => {
112 serde_json::to_string_pretty(info).map_err(|err| DeployerError::Other(err.to_string()))
113 }
114 OutputFormat::Yaml => {
115 serde_yaml::to_string(info).map_err(|err| DeployerError::Other(err.to_string()))
116 }
117 }
118}
119
120pub fn materialize_admin_client_certs(
121 bundle_dir: &Path,
122 provider: Provider,
123) -> Result<MaterializedAdminCerts> {
124 let info = resolve_admin_access(bundle_dir, provider)?;
125 let cert_dir = local_admin_cert_dir(&info);
126 fs::create_dir_all(&cert_dir)?;
127
128 let ca_ref = info
129 .admin_secret_refs
130 .admin_ca_secret_ref
131 .as_deref()
132 .ok_or_else(|| DeployerError::Other("missing admin_ca_secret_ref".to_string()))?;
133 let client_cert_ref = info
134 .admin_secret_refs
135 .admin_client_cert_secret_ref
136 .as_deref()
137 .ok_or_else(|| DeployerError::Other("missing admin_client_cert_secret_ref".to_string()))?;
138 let client_key_ref = info
139 .admin_secret_refs
140 .admin_client_key_secret_ref
141 .as_deref()
142 .ok_or_else(|| DeployerError::Other("missing admin_client_key_secret_ref".to_string()))?;
143
144 fs::write(
145 cert_dir.join("ca.crt"),
146 fetch_secret_value(provider, ca_ref, &info)?,
147 )?;
148 fs::write(
149 cert_dir.join("client.crt"),
150 fetch_secret_value(provider, client_cert_ref, &info)?,
151 )?;
152 fs::write(
153 cert_dir.join("client.key"),
154 fetch_secret_value(provider, client_key_ref, &info)?,
155 )?;
156
157 Ok(MaterializedAdminCerts {
158 provider: provider.as_str().to_string(),
159 cert_dir: cert_dir.clone(),
160 ca_cert_path: cert_dir.join("ca.crt"),
161 client_cert_path: cert_dir.join("client.crt"),
162 client_key_path: cert_dir.join("client.key"),
163 })
164}
165
166pub fn render_materialized_admin_certs(
167 value: &MaterializedAdminCerts,
168 output: OutputFormat,
169) -> Result<String> {
170 match output {
171 OutputFormat::Text => Ok(format!(
172 "provider: {}\ncert_dir: {}\nca_cert_path: {}\nclient_cert_path: {}\nclient_key_path: {}",
173 value.provider,
174 value.cert_dir.display(),
175 value.ca_cert_path.display(),
176 value.client_cert_path.display(),
177 value.client_key_path.display()
178 )),
179 OutputFormat::Json => {
180 serde_json::to_string_pretty(value).map_err(|err| DeployerError::Other(err.to_string()))
181 }
182 OutputFormat::Yaml => {
183 serde_yaml::to_string(value).map_err(|err| DeployerError::Other(err.to_string()))
184 }
185 }
186}
187
188pub fn materialize_admin_relay_token(bundle_dir: &Path, provider: Provider) -> Result<String> {
189 let info = resolve_admin_access(bundle_dir, provider)?;
190 let token_ref = info
191 .admin_secret_refs
192 .admin_relay_token_secret_ref
193 .as_deref()
194 .ok_or_else(|| DeployerError::Other("missing admin_relay_token_secret_ref".to_string()))?;
195 fetch_secret_value(provider, token_ref, &info)
196}
197
198pub fn render_materialized_admin_relay_token(
199 provider: Provider,
200 _token: &str,
201 output: OutputFormat,
202) -> Result<String> {
203 let value = MaterializedAdminRelayToken {
204 provider: provider.as_str().to_string(),
205 token: "[REDACTED]".to_string(),
206 };
207 match output {
208 OutputFormat::Text => Ok("[REDACTED]".to_string()),
209 OutputFormat::Json => serde_json::to_string_pretty(&value)
210 .map_err(|err| DeployerError::Other(err.to_string())),
211 OutputFormat::Yaml => {
212 serde_yaml::to_string(&value).map_err(|err| DeployerError::Other(err.to_string()))
213 }
214 }
215}
216
217pub fn probe_admin_health(bundle_dir: &Path, provider: Provider) -> Result<AdminHealthProbe> {
218 let info = resolve_admin_access(bundle_dir, provider)?;
219 let endpoint = info
220 .admin_public_endpoint
221 .clone()
222 .ok_or_else(|| DeployerError::Other("missing admin_public_endpoint".to_string()))?;
223 let token = materialize_admin_relay_token(bundle_dir, provider)?;
224 let url = format!("{}/health", endpoint.trim_end_matches('/'));
225 let response = reqwest::blocking::Client::builder()
226 .build()
227 .map_err(|err| DeployerError::Other(format!("build admin health client: {err}")))?
228 .get(&url)
229 .bearer_auth(token)
230 .send()
231 .map_err(|err| DeployerError::Other(format!("request admin health endpoint: {err}")))?;
232 let status = response.status().as_u16();
233 let ok = response.status().is_success();
234 let body = response
235 .text()
236 .map_err(|err| DeployerError::Other(format!("read admin health response: {err}")))?;
237
238 Ok(AdminHealthProbe {
239 provider: provider.as_str().to_string(),
240 endpoint: url,
241 status,
242 ok,
243 body,
244 })
245}
246
247pub fn render_admin_health_probe(value: &AdminHealthProbe, output: OutputFormat) -> Result<String> {
248 match output {
249 OutputFormat::Text => Ok(format!(
250 "provider: {}\nendpoint: {}\nstatus: {}\nok: {}\nbody: {}",
251 value.provider, value.endpoint, value.status, value.ok, value.body
252 )),
253 OutputFormat::Json => {
254 serde_json::to_string_pretty(value).map_err(|err| DeployerError::Other(err.to_string()))
255 }
256 OutputFormat::Yaml => {
257 serde_yaml::to_string(value).map_err(|err| DeployerError::Other(err.to_string()))
258 }
259 }
260}
261
262pub(crate) fn resolve_latest_deploy_dir(bundle_dir: &Path, provider: &str) -> Result<PathBuf> {
263 let mut candidates = Vec::new();
264 for ancestor in bundle_dir.ancestors() {
265 candidates.push(ancestor.join(".greentic").join("deploy").join(provider));
266 }
267 if let Some(home_dir) = env::var_os("HOME") {
268 candidates.push(
269 PathBuf::from(home_dir)
270 .join(".greentic")
271 .join("deploy")
272 .join(provider),
273 );
274 }
275
276 let mut latest: Option<(SystemTime, PathBuf)> = None;
277 for root in candidates {
278 if root.as_os_str().is_empty() || !root.exists() {
279 continue;
280 }
281 let mut stack = vec![root];
282 while let Some(dir) = stack.pop() {
283 let entries = fs::read_dir(&dir)?;
284 for entry in entries.flatten() {
285 let path = entry.path();
286 if path.is_dir() {
287 let outputs = path.join("terraform-outputs.json");
288 if outputs.is_file() {
289 let modified = fs::metadata(&outputs)
290 .and_then(|meta| meta.modified())
291 .unwrap_or(UNIX_EPOCH);
292 match latest.as_ref() {
293 Some((current, _)) if modified <= *current => {}
294 _ => latest = Some((modified, path.clone())),
295 }
296 }
297 stack.push(path);
298 }
299 }
300 }
301 }
302
303 latest.map(|(_, path)| path).ok_or_else(|| {
304 DeployerError::Other(format!(
305 "{} deploy state not found under {} or any parent workspace .greentic/deploy/{}, or ~/.greentic/deploy/{}; deploy the bundle first",
306 provider,
307 bundle_dir.join(".greentic").join("deploy").join(provider).display(),
308 provider,
309 provider
310 ))
311 })
312}
313
314pub(crate) fn load_terraform_outputs(path: &Path) -> Result<Value> {
315 let raw = fs::read_to_string(path)?;
316 Ok(serde_json::from_str(&raw)?)
317}
318
319pub(crate) fn terraform_output_string(outputs: &Value, key: &str) -> Option<String> {
320 outputs
321 .get(key)
322 .and_then(|value| value.get("value"))
323 .and_then(Value::as_str)
324 .map(|value| value.to_string())
325}
326
327pub(crate) fn tunnel_admin_cert_dir(bundle_dir: &Path, deploy_name_prefix: &str) -> PathBuf {
328 bundle_dir
329 .join(".greentic")
330 .join("admin")
331 .join("tunnels")
332 .join(deploy_name_prefix)
333}
334
335fn resolve_provider_admin_access(
336 bundle_dir: &Path,
337 provider_name: &str,
338 provider: Provider,
339) -> Result<AdminAccessInfo> {
340 let deploy_dir = resolve_latest_deploy_dir(bundle_dir, provider_name)?;
341 let outputs = load_terraform_outputs(&deploy_dir.join("terraform-outputs.json"))?;
342 let deployment_name_prefix = deployment_name_prefix(&outputs, provider);
343 let operator_host = operator_host(&outputs);
344 let local_cert_dir = local_admin_cert_dir_for_values(
345 bundle_dir,
346 deployment_name_prefix.as_deref(),
347 operator_host.as_deref(),
348 provider.as_str(),
349 );
350
351 Ok(AdminAccessInfo {
352 provider: provider.as_str().to_string(),
353 bundle_dir: bundle_dir.to_path_buf(),
354 deploy_dir,
355 local_cert_dir,
356 admin_access_mode: terraform_output_string(&outputs, "admin_access_mode"),
357 admin_public_endpoint: terraform_output_string(&outputs, "admin_public_endpoint"),
358 operator_endpoint: terraform_output_string(&outputs, "operator_endpoint"),
359 deployment_name_prefix,
360 operator_host,
361 provider_details: provider_details(&outputs, provider),
362 admin_listener: "127.0.0.1:8433".to_string(),
363 admin_secret_refs: AdminSecretRefs {
364 admin_ca_secret_ref: terraform_output_string(&outputs, "admin_ca_secret_ref"),
365 admin_server_cert_secret_ref: terraform_output_string(
366 &outputs,
367 "admin_server_cert_secret_ref",
368 ),
369 admin_server_key_secret_ref: terraform_output_string(
370 &outputs,
371 "admin_server_key_secret_ref",
372 ),
373 admin_client_cert_secret_ref: terraform_output_string(
374 &outputs,
375 "admin_client_cert_secret_ref",
376 ),
377 admin_client_key_secret_ref: terraform_output_string(
378 &outputs,
379 "admin_client_key_secret_ref",
380 ),
381 admin_relay_token_secret_ref: terraform_output_string(
382 &outputs,
383 "admin_relay_token_secret_ref",
384 ),
385 },
386 client_credentials_available: client_credentials_available(&outputs),
387 missing_requirements: missing_requirements(&outputs, provider),
388 tunnel_support: tunnel_support_for_provider(provider),
389 suggested_commands: suggested_commands(&outputs, provider),
390 curl_health_example: curl_health_example(provider),
391 notes: notes_for_provider(provider),
392 })
393}
394
395fn deployment_name_prefix(outputs: &Value, provider: Provider) -> Option<String> {
396 let admin_ca_ref = terraform_output_string(outputs, "admin_ca_secret_ref")?;
397 match provider {
398 Provider::Aws => deploy_name_prefix_from_aws_secret_arn(&admin_ca_ref),
399 Provider::Azure => deploy_name_prefix_from_azure_secret_ref(&admin_ca_ref),
400 Provider::Gcp => deploy_name_prefix_from_gcp_secret_ref(&admin_ca_ref),
401 _ => None,
402 }
403}
404
405fn operator_host(outputs: &Value) -> Option<String> {
406 let endpoint = terraform_output_string(outputs, "operator_endpoint")?;
407 host_from_url(&endpoint)
408}
409
410fn provider_details(outputs: &Value, provider: Provider) -> AdminProviderDetails {
411 let deployment_name_prefix = deployment_name_prefix(outputs, provider);
412 let operator_host = operator_host(outputs);
413 let admin_ca_ref = terraform_output_string(outputs, "admin_ca_secret_ref");
414
415 match provider {
416 Provider::Aws => {
417 let aws_region = admin_ca_ref.as_deref().and_then(aws_region_from_secret_arn);
418 let aws_cluster_name = deployment_name_prefix
419 .as_ref()
420 .map(|prefix| format!("{prefix}-cluster"));
421 let aws_service_name = deployment_name_prefix
422 .as_ref()
423 .map(|prefix| format!("{prefix}-service"));
424 AdminProviderDetails {
425 aws_region,
426 aws_cluster_name,
427 aws_service_name,
428 ..Default::default()
429 }
430 }
431 Provider::Azure => {
432 let azure_resource_group_name = deployment_name_prefix
433 .as_ref()
434 .map(|prefix| format!("{prefix}-rg"));
435 let azure_container_app_name = operator_host
436 .as_deref()
437 .and_then(azure_container_app_name_from_host)
438 .or_else(|| {
439 deployment_name_prefix
440 .as_ref()
441 .map(|prefix| format!("{prefix}-app"))
442 });
443 AdminProviderDetails {
444 azure_resource_group_name,
445 azure_container_app_name,
446 ..Default::default()
447 }
448 }
449 Provider::Gcp => {
450 let gcp_project_id = admin_ca_ref
451 .as_deref()
452 .and_then(gcp_project_id_from_secret_ref);
453 let gcp_cloud_run_service_name = operator_host
454 .as_deref()
455 .and_then(gcp_cloud_run_service_name_from_host)
456 .or_else(|| {
457 deployment_name_prefix
458 .as_ref()
459 .map(|prefix| format!("{prefix}-run"))
460 });
461 AdminProviderDetails {
462 gcp_project_id,
463 gcp_cloud_run_service_name,
464 ..Default::default()
465 }
466 }
467 _ => AdminProviderDetails::default(),
468 }
469}
470
471fn tunnel_support_for_provider(provider: Provider) -> AdminTunnelSupport {
472 match provider {
473 Provider::Aws => AdminTunnelSupport {
474 supported: true,
475 mode: Some(AdminAccessMode::AwsSsmPortForward),
476 reason: None,
477 command_hint: Some(
478 "greentic-deployer aws admin-tunnel --bundle-dir <BUNDLE_DIR> --local-port 8443"
479 .to_string(),
480 ),
481 local_port_default: 8443,
482 },
483 Provider::Azure => AdminTunnelSupport {
484 supported: false,
485 mode: Some(AdminAccessMode::LoopbackOnly),
486 reason: Some(
487 "the admin server stays loopback-only inside Azure Container Apps; use the public HTTPS admin relay instead of a direct tunnel".to_string(),
488 ),
489 command_hint: None,
490 local_port_default: 8443,
491 },
492 Provider::Gcp => AdminTunnelSupport {
493 supported: false,
494 mode: Some(AdminAccessMode::LoopbackOnly),
495 reason: Some(
496 "the admin server stays loopback-only inside Cloud Run; use the public HTTPS admin relay instead of a direct tunnel".to_string(),
497 ),
498 command_hint: None,
499 local_port_default: 8443,
500 },
501 _ => AdminTunnelSupport {
502 supported: false,
503 mode: None,
504 reason: Some("admin access is only defined for cloud deployment targets".to_string()),
505 command_hint: None,
506 local_port_default: 8443,
507 },
508 }
509}
510
511fn notes_for_provider(provider: Provider) -> Vec<String> {
512 match provider {
513 Provider::Aws => vec![
514 "AWS admin access is currently implemented through ECS Exec / SSM port forwarding."
515 .to_string(),
516 "The admin endpoint itself remains mTLS-protected and loopback-bound in the runtime."
517 .to_string(),
518 ],
519 Provider::Azure => vec![
520 "Azure deploys the admin server inside Container Apps with a loopback-only listener."
521 .to_string(),
522 "greentic-start now exposes a public HTTPS admin relay path guarded by a bearer token and an internal mTLS hop.".to_string(),
523 "Direct Azure tunnel parity with AWS SSM is still not implemented.".to_string(),
524 ],
525 Provider::Gcp => vec![
526 "GCP deploys the admin server inside Cloud Run with a loopback-only listener."
527 .to_string(),
528 "greentic-start now exposes a public HTTPS admin relay path guarded by a bearer token and an internal mTLS hop.".to_string(),
529 "Direct GCP tunnel parity with AWS SSM is still not implemented.".to_string(),
530 ],
531 _ => Vec::new(),
532 }
533}
534
535fn client_credentials_available(outputs: &Value) -> bool {
536 terraform_output_string(outputs, "admin_client_cert_secret_ref").is_some()
537 && terraform_output_string(outputs, "admin_client_key_secret_ref").is_some()
538}
539
540fn missing_requirements(outputs: &Value, provider: Provider) -> Vec<String> {
541 let mut missing = Vec::new();
542 let has_public_relay = matches!(provider, Provider::Azure | Provider::Gcp)
543 && terraform_output_string(outputs, "admin_public_endpoint").is_some()
544 && terraform_output_string(outputs, "admin_relay_token_secret_ref").is_some();
545 if terraform_output_string(outputs, "admin_client_cert_secret_ref").is_none() {
546 missing.push("admin client certificate reference".to_string());
547 }
548 if terraform_output_string(outputs, "admin_client_key_secret_ref").is_none() {
549 missing.push("admin client key reference".to_string());
550 }
551 if matches!(provider, Provider::Azure | Provider::Gcp)
552 && terraform_output_string(outputs, "admin_relay_token_secret_ref").is_none()
553 {
554 missing.push("admin relay token secret reference".to_string());
555 }
556 if matches!(provider, Provider::Azure | Provider::Gcp)
557 && terraform_output_string(outputs, "admin_public_endpoint").is_none()
558 {
559 missing.push("public admin relay endpoint".to_string());
560 }
561 if !(tunnel_support_for_provider(provider).supported || has_public_relay) {
562 missing.push("cloud-side tunnel or controlled admin access path".to_string());
563 }
564 missing
565}
566
567fn suggested_commands(outputs: &Value, provider: Provider) -> Vec<String> {
568 let details = provider_details(outputs, provider);
569 let mut commands = Vec::new();
570
571 match provider {
572 Provider::Aws => {
573 commands
574 .push("greentic-deployer aws admin-certs --bundle-dir <BUNDLE_DIR>".to_string());
575 if let (Some(region), Some(cluster), Some(service)) = (
576 details.aws_region.as_deref(),
577 details.aws_cluster_name.as_deref(),
578 details.aws_service_name.as_deref(),
579 ) {
580 commands.push(format!(
581 "aws ecs list-tasks --region {region} --cluster {cluster} --service-name {service}"
582 ));
583 commands.push(
584 "greentic-deployer aws admin-tunnel --bundle-dir <BUNDLE_DIR> --local-port 8443"
585 .to_string(),
586 );
587 commands.push(
588 "curl --cacert <CERT_DIR>/ca.crt --cert <CERT_DIR>/client.crt --key <CERT_DIR>/client.key https://127.0.0.1:8443/admin/v1/health".to_string(),
589 );
590 }
591 }
592 Provider::Azure => {
593 commands
594 .push("greentic-deployer azure admin-certs --bundle-dir <BUNDLE_DIR>".to_string());
595 commands
596 .push("greentic-deployer azure admin-token --bundle-dir <BUNDLE_DIR>".to_string());
597 if let Some(app_name) = details.azure_container_app_name.as_deref() {
598 let resource_group = details.azure_resource_group_name.clone().or_else(|| {
599 app_name
600 .strip_suffix("-app")
601 .map(|prefix| format!("{prefix}-rg"))
602 });
603 if let Some(resource_group) = resource_group {
604 commands.push(format!(
605 "az containerapp show --resource-group {resource_group} --name {app_name}"
606 ));
607 commands.push(format!(
608 "az containerapp logs show --resource-group {resource_group} --name {app_name} --follow"
609 ));
610 }
611 }
612 }
613 Provider::Gcp => {
614 commands
615 .push("greentic-deployer gcp admin-certs --bundle-dir <BUNDLE_DIR>".to_string());
616 commands
617 .push("greentic-deployer gcp admin-token --bundle-dir <BUNDLE_DIR>".to_string());
618 if let (Some(project_id), Some(service_name)) = (
619 details.gcp_project_id.as_deref(),
620 details.gcp_cloud_run_service_name.as_deref(),
621 ) {
622 commands.push(format!(
623 "gcloud run services describe {service_name} --project {project_id}"
624 ));
625 commands.push(format!(
626 "gcloud run services logs read {service_name} --project {project_id} --region us-central1"
627 ));
628 }
629 }
630 _ => {}
631 }
632
633 commands
634}
635
636fn curl_health_example(provider: Provider) -> Option<String> {
637 match provider {
638 Provider::Aws => Some(
639 "curl --cacert <CERT_DIR>/ca.crt --cert <CERT_DIR>/client.crt --key <CERT_DIR>/client.key https://127.0.0.1:8443/admin/v1/health".to_string(),
640 ),
641 Provider::Azure | Provider::Gcp => Some(
642 "curl -H 'Authorization: Bearer <TOKEN>' <ADMIN_PUBLIC_ENDPOINT>/health".to_string(),
643 ),
644 _ => None,
645 }
646}
647
648fn render_admin_access_text(info: &AdminAccessInfo) -> String {
649 let mut lines = vec![
650 format!("provider: {}", info.provider),
651 format!("bundle_dir: {}", info.bundle_dir.display()),
652 format!("deploy_dir: {}", info.deploy_dir.display()),
653 format!("local_cert_dir: {}", info.local_cert_dir.display()),
654 format!(
655 "admin_access_mode: {}",
656 info.admin_access_mode.as_deref().unwrap_or("(missing)")
657 ),
658 format!(
659 "admin_public_endpoint: {}",
660 info.admin_public_endpoint.as_deref().unwrap_or("(missing)")
661 ),
662 format!(
663 "operator_endpoint: {}",
664 info.operator_endpoint.as_deref().unwrap_or("(missing)")
665 ),
666 format!(
667 "operator_host: {}",
668 info.operator_host.as_deref().unwrap_or("(missing)")
669 ),
670 format!(
671 "deployment_name_prefix: {}",
672 info.deployment_name_prefix
673 .as_deref()
674 .unwrap_or("(missing)")
675 ),
676 format!("admin_listener: {}", info.admin_listener),
677 format!(
678 "client_credentials_available: {}",
679 info.client_credentials_available
680 ),
681 format!("tunnel_supported: {}", info.tunnel_support.supported),
682 ];
683
684 if let Some(mode) = &info.tunnel_support.mode {
685 lines.push(format!("tunnel_mode: {:?}", mode));
686 }
687 if let Some(reason) = &info.tunnel_support.reason {
688 lines.push(format!("tunnel_reason: {reason}"));
689 }
690 if let Some(command_hint) = &info.tunnel_support.command_hint {
691 lines.push(format!("command_hint: {command_hint}"));
692 }
693 if let Some(example) = &info.curl_health_example {
694 lines.push(format!("curl_health_example: {example}"));
695 }
696
697 for (label, value) in [
698 (
699 "admin_ca_secret_ref",
700 info.admin_secret_refs.admin_ca_secret_ref.as_deref(),
701 ),
702 (
703 "admin_server_cert_secret_ref",
704 info.admin_secret_refs
705 .admin_server_cert_secret_ref
706 .as_deref(),
707 ),
708 (
709 "admin_server_key_secret_ref",
710 info.admin_secret_refs
711 .admin_server_key_secret_ref
712 .as_deref(),
713 ),
714 (
715 "admin_client_cert_secret_ref",
716 info.admin_secret_refs
717 .admin_client_cert_secret_ref
718 .as_deref(),
719 ),
720 (
721 "admin_client_key_secret_ref",
722 info.admin_secret_refs
723 .admin_client_key_secret_ref
724 .as_deref(),
725 ),
726 (
727 "admin_relay_token_secret_ref",
728 info.admin_secret_refs
729 .admin_relay_token_secret_ref
730 .as_deref(),
731 ),
732 ] {
733 lines.push(format!("{}: {}", label, value.unwrap_or("(missing)")));
734 }
735
736 for (label, value) in [
737 ("aws_region", info.provider_details.aws_region.as_deref()),
738 (
739 "aws_cluster_name",
740 info.provider_details.aws_cluster_name.as_deref(),
741 ),
742 (
743 "aws_service_name",
744 info.provider_details.aws_service_name.as_deref(),
745 ),
746 (
747 "azure_resource_group_name",
748 info.provider_details.azure_resource_group_name.as_deref(),
749 ),
750 (
751 "azure_container_app_name",
752 info.provider_details.azure_container_app_name.as_deref(),
753 ),
754 (
755 "gcp_project_id",
756 info.provider_details.gcp_project_id.as_deref(),
757 ),
758 (
759 "gcp_cloud_run_service_name",
760 info.provider_details.gcp_cloud_run_service_name.as_deref(),
761 ),
762 ] {
763 if let Some(value) = value {
764 lines.push(format!("{label}: {value}"));
765 }
766 }
767
768 if !info.notes.is_empty() {
769 lines.push("notes:".to_string());
770 for note in &info.notes {
771 lines.push(format!("- {note}"));
772 }
773 }
774
775 if !info.missing_requirements.is_empty() {
776 lines.push("missing_requirements:".to_string());
777 for requirement in &info.missing_requirements {
778 lines.push(format!("- {requirement}"));
779 }
780 }
781
782 if !info.suggested_commands.is_empty() {
783 lines.push("suggested_commands:".to_string());
784 for command in &info.suggested_commands {
785 lines.push(format!("- {command}"));
786 }
787 }
788
789 lines.join("\n")
790}
791
792fn local_admin_cert_dir(info: &AdminAccessInfo) -> PathBuf {
793 local_admin_cert_dir_for_values(
794 &info.bundle_dir,
795 info.deployment_name_prefix.as_deref(),
796 info.operator_host.as_deref(),
797 &info.provider,
798 )
799}
800
801fn local_admin_cert_dir_for_values(
802 bundle_dir: &Path,
803 deployment_name_prefix: Option<&str>,
804 operator_host: Option<&str>,
805 provider: &str,
806) -> PathBuf {
807 let suffix = deployment_name_prefix
808 .or(operator_host)
809 .unwrap_or(provider)
810 .replace('/', "_");
811 tunnel_admin_cert_dir(bundle_dir, &suffix)
812}
813
814fn fetch_secret_value(
815 provider: Provider,
816 secret_ref: &str,
817 info: &AdminAccessInfo,
818) -> Result<String> {
819 match provider {
820 Provider::Aws => {
821 let region = info.provider_details.aws_region.as_deref().ok_or_else(|| {
822 DeployerError::Other("missing aws region for admin secret fetch".to_string())
823 })?;
824 cli_capture(
825 "aws secretsmanager get-secret-value",
826 &[
827 "aws",
828 "secretsmanager",
829 "get-secret-value",
830 "--region",
831 region,
832 "--secret-id",
833 secret_ref,
834 "--query",
835 "SecretString",
836 "--output",
837 "text",
838 ],
839 )
840 }
841 Provider::Azure => cli_capture(
842 "az keyvault secret show",
843 &[
844 "az", "keyvault", "secret", "show", "--id", secret_ref, "--query", "value",
845 "--output", "tsv",
846 ],
847 )
848 .or_else(|_| azure_secret_value_from_terraform_state(info, secret_ref)),
849 Provider::Gcp => {
850 let (project_id, secret_name) = parse_gcp_secret_ref(secret_ref)?;
851 cli_capture(
852 "gcloud secrets versions access",
853 &[
854 "gcloud",
855 "secrets",
856 "versions",
857 "access",
858 "latest",
859 "--project",
860 &project_id,
861 "--secret",
862 &secret_name,
863 ],
864 )
865 .or_else(|_| gcp_secret_value_from_terraform_state(info, secret_ref))
866 }
867 other => Err(DeployerError::Other(format!(
868 "admin cert materialization is only available for aws, azure, gcp; got {}",
869 other.as_str()
870 ))),
871 }
872}
873
874fn cli_capture(label: &str, args: &[&str]) -> Result<String> {
875 let (program, rest) = args
876 .split_first()
877 .ok_or_else(|| DeployerError::Other(format!("{label}: missing program")))?;
878 let output = ProcessCommand::new(program).args(rest).output()?;
879 if !output.status.success() {
880 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
881 if stderr.is_empty() {
882 return Err(DeployerError::Other(format!(
883 "{label} failed with status {}",
884 output.status
885 )));
886 }
887 return Err(DeployerError::Other(format!("{label} failed: {stderr}")));
888 }
889 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
890}
891
892fn parse_gcp_secret_ref(secret_ref: &str) -> Result<(String, String)> {
893 let parts: Vec<&str> = secret_ref.split('/').collect();
894 let project_idx = parts
895 .iter()
896 .position(|part| *part == "projects")
897 .ok_or_else(|| DeployerError::Other(format!("invalid GCP secret ref: {secret_ref}")))?;
898 let secret_idx = parts
899 .iter()
900 .position(|part| *part == "secrets")
901 .ok_or_else(|| DeployerError::Other(format!("invalid GCP secret ref: {secret_ref}")))?;
902 let project_id = parts
903 .get(project_idx + 1)
904 .ok_or_else(|| DeployerError::Other(format!("invalid GCP secret ref: {secret_ref}")))?;
905 let secret_name = parts
906 .get(secret_idx + 1)
907 .ok_or_else(|| DeployerError::Other(format!("invalid GCP secret ref: {secret_ref}")))?;
908 Ok(((*project_id).to_string(), (*secret_name).to_string()))
909}
910
911fn gcp_secret_value_from_terraform_state(
912 info: &AdminAccessInfo,
913 secret_ref: &str,
914) -> Result<String> {
915 for state_path in [
916 info.deploy_dir.join("terraform").join("terraform.tfstate"),
917 info.deploy_dir
918 .join("terraform")
919 .join("terraform.tfstate.backup"),
920 ] {
921 if !state_path.is_file() {
922 continue;
923 }
924 let raw = fs::read_to_string(&state_path)?;
925 let state: Value = serde_json::from_str(&raw)?;
926 let Some(resources) = state.get("resources").and_then(Value::as_array) else {
927 continue;
928 };
929 for resource in resources {
930 if resource.get("type").and_then(Value::as_str)
931 != Some("google_secret_manager_secret_version")
932 {
933 continue;
934 }
935 let Some(instances) = resource.get("instances").and_then(Value::as_array) else {
936 continue;
937 };
938 for instance in instances {
939 let Some(attributes) = instance.get("attributes").and_then(Value::as_object) else {
940 continue;
941 };
942 if attributes.get("secret").and_then(Value::as_str) != Some(secret_ref) {
943 continue;
944 }
945 if let Some(secret_data) = attributes.get("secret_data").and_then(Value::as_str) {
946 return Ok(secret_data.to_string());
947 }
948 }
949 }
950 }
951
952 Err(DeployerError::Other(format!(
953 "gcp secret value not found in terraform state for {secret_ref}"
954 )))
955}
956
957fn azure_secret_value_from_terraform_state(
958 info: &AdminAccessInfo,
959 secret_ref: &str,
960) -> Result<String> {
961 for state_path in [
962 info.deploy_dir.join("terraform").join("terraform.tfstate"),
963 info.deploy_dir
964 .join("terraform")
965 .join("terraform.tfstate.backup"),
966 ] {
967 if !state_path.is_file() {
968 continue;
969 }
970 let raw = fs::read_to_string(&state_path)?;
971 let state: Value = serde_json::from_str(&raw)?;
972 let Some(resources) = state.get("resources").and_then(Value::as_array) else {
973 continue;
974 };
975 for resource in resources {
976 if resource.get("type").and_then(Value::as_str) != Some("azurerm_key_vault_secret") {
977 continue;
978 }
979 let Some(instances) = resource.get("instances").and_then(Value::as_array) else {
980 continue;
981 };
982 for instance in instances {
983 let Some(attributes) = instance.get("attributes").and_then(Value::as_object) else {
984 continue;
985 };
986 if attributes.get("versionless_id").and_then(Value::as_str) != Some(secret_ref) {
987 continue;
988 }
989 if let Some(value) = attributes.get("value").and_then(Value::as_str) {
990 return Ok(value.to_string());
991 }
992 }
993 }
994 }
995
996 Err(DeployerError::Other(format!(
997 "azure secret value not found in terraform state for {secret_ref}"
998 )))
999}
1000
1001fn host_from_url(value: &str) -> Option<String> {
1002 let without_scheme = value.split("://").nth(1)?;
1003 let host_port = without_scheme.split('/').next()?;
1004 let host = host_port.split(':').next()?;
1005 if host.is_empty() {
1006 None
1007 } else {
1008 Some(host.to_string())
1009 }
1010}
1011
1012fn aws_region_from_secret_arn(secret_arn: &str) -> Option<String> {
1013 secret_arn.split(':').nth(3).map(|value| value.to_string())
1014}
1015
1016fn deploy_name_prefix_from_aws_secret_arn(secret_arn: &str) -> Option<String> {
1017 let marker = ":secret:greentic/admin/";
1018 let start = secret_arn.find(marker)? + marker.len();
1019 let rest = &secret_arn[start..];
1020 let prefix = rest.split('/').next()?;
1021 if prefix.is_empty() {
1022 None
1023 } else {
1024 Some(prefix.to_string())
1025 }
1026}
1027
1028fn deploy_name_prefix_from_azure_secret_ref(secret_ref: &str) -> Option<String> {
1029 let _ = secret_ref;
1030 None
1031}
1032
1033fn deploy_name_prefix_from_gcp_secret_ref(secret_ref: &str) -> Option<String> {
1034 let _ = secret_ref;
1035 None
1036}
1037
1038fn gcp_project_id_from_secret_ref(secret_ref: &str) -> Option<String> {
1039 let parts: Vec<&str> = secret_ref.split('/').collect();
1040 let project_idx = parts.iter().position(|part| *part == "projects")?;
1041 parts.get(project_idx + 1).map(|value| value.to_string())
1042}
1043
1044fn azure_container_app_name_from_host(host: &str) -> Option<String> {
1045 let app_name = host.split("--").next()?;
1046 if app_name.is_empty() {
1047 None
1048 } else {
1049 Some(app_name.to_string())
1050 }
1051}
1052
1053fn gcp_cloud_run_service_name_from_host(host: &str) -> Option<String> {
1054 let prefix = host.split('.').next()?;
1055 let trimmed = prefix
1056 .strip_suffix("-uc")
1057 .or_else(|| prefix.strip_suffix("-eu"))
1058 .unwrap_or(prefix);
1059 let mut parts: Vec<&str> = trimmed.split('-').collect();
1060 if parts.len() >= 2 {
1061 parts.pop();
1062 let candidate = parts.join("-");
1063 if !candidate.is_empty() {
1064 return Some(candidate);
1065 }
1066 }
1067 if prefix.is_empty() {
1068 None
1069 } else {
1070 Some(prefix.to_string())
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077 use tempfile::tempdir;
1078
1079 #[test]
1080 fn terraform_output_string_reads_string_values() {
1081 let outputs: Value = serde_json::json!({
1082 "operator_endpoint": {
1083 "value": "https://example.test"
1084 }
1085 });
1086
1087 assert_eq!(
1088 terraform_output_string(&outputs, "operator_endpoint").as_deref(),
1089 Some("https://example.test")
1090 );
1091 assert_eq!(terraform_output_string(&outputs, "missing"), None);
1092 }
1093
1094 #[test]
1095 fn tunnel_admin_cert_dir_uses_bundle_local_admin_tunnels_path() {
1096 let path = tunnel_admin_cert_dir(Path::new("/tmp/demo-bundle"), "greentic-1234");
1097 assert_eq!(
1098 path,
1099 PathBuf::from("/tmp/demo-bundle/.greentic/admin/tunnels/greentic-1234")
1100 );
1101 }
1102
1103 #[test]
1104 fn resolve_admin_access_reports_aws_tunnel_support() {
1105 let tmp = tempdir().expect("tempdir");
1106 let bundle_dir = tmp.path().join("bundle");
1107 let deploy_dir = bundle_dir
1108 .join(".greentic")
1109 .join("deploy")
1110 .join("aws")
1111 .join("demo")
1112 .join("state");
1113 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
1114 fs::write(
1115 deploy_dir.join("terraform-outputs.json"),
1116 serde_json::to_vec_pretty(&serde_json::json!({
1117 "operator_endpoint": { "value": "https://example.aws.test" },
1118 "admin_access_mode": { "value": "aws-ssm-port-forward" },
1119 "admin_ca_secret_ref": { "value": "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/demo/ca" }
1120 }))
1121 .expect("serialize outputs"),
1122 )
1123 .expect("write outputs");
1124
1125 let info = resolve_admin_access(&bundle_dir, Provider::Aws).expect("resolve");
1126 assert_eq!(info.provider, "aws");
1127 assert!(info.tunnel_support.supported);
1128 assert_eq!(
1129 info.operator_endpoint.as_deref(),
1130 Some("https://example.aws.test")
1131 );
1132 assert_eq!(
1133 info.admin_access_mode.as_deref(),
1134 Some("aws-ssm-port-forward")
1135 );
1136 assert!(
1137 info.suggested_commands
1138 .iter()
1139 .any(|value| value.contains("aws admin-certs"))
1140 );
1141 assert!(
1142 info.curl_health_example
1143 .as_deref()
1144 .is_some_and(|value| value.contains("/admin/v1/health"))
1145 );
1146 }
1147
1148 #[test]
1149 fn resolve_admin_access_reports_azure_loopback_only_status() {
1150 let tmp = tempdir().expect("tempdir");
1151 let bundle_dir = tmp.path().join("bundle");
1152 let deploy_dir = bundle_dir
1153 .join(".greentic")
1154 .join("deploy")
1155 .join("azure")
1156 .join("demo")
1157 .join("state");
1158 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
1159 fs::write(
1160 deploy_dir.join("terraform-outputs.json"),
1161 serde_json::to_vec_pretty(&serde_json::json!({
1162 "operator_endpoint": { "value": "https://example.azure.test" },
1163 "admin_access_mode": { "value": "internal" }
1164 }))
1165 .expect("serialize outputs"),
1166 )
1167 .expect("write outputs");
1168
1169 let info = resolve_admin_access(&bundle_dir, Provider::Azure).expect("resolve");
1170 assert_eq!(info.provider, "azure");
1171 assert!(!info.tunnel_support.supported);
1172 assert_eq!(info.admin_access_mode.as_deref(), Some("internal"));
1173 assert_eq!(
1174 info.tunnel_support.reason.as_deref(),
1175 Some(
1176 "the admin server stays loopback-only inside Azure Container Apps; use the public HTTPS admin relay instead of a direct tunnel"
1177 )
1178 );
1179 }
1180
1181 #[test]
1182 fn resolve_latest_deploy_dir_finds_state_in_repo_root_for_nested_bundle() {
1183 let tmp = tempdir().expect("tempdir");
1184 let bundle_dir = tmp.path().join("gcp3").join("cloud-deploy-demo-bundle");
1185 let deploy_dir = tmp
1186 .path()
1187 .join(".greentic")
1188 .join("deploy")
1189 .join("gcp")
1190 .join("demo")
1191 .join("state");
1192 fs::create_dir_all(&bundle_dir).expect("create bundle dir");
1193 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
1194 fs::write(deploy_dir.join("terraform-outputs.json"), b"{}").expect("write outputs");
1195
1196 let resolved = resolve_latest_deploy_dir(&bundle_dir, "gcp").expect("resolve");
1197 assert_eq!(resolved, deploy_dir);
1198 }
1199
1200 #[test]
1201 fn resolve_admin_access_reports_gcp_public_relay_details() {
1202 let tmp = tempdir().expect("tempdir");
1203 let bundle_dir = tmp.path().join("bundle");
1204 let deploy_dir = bundle_dir
1205 .join(".greentic")
1206 .join("deploy")
1207 .join("gcp")
1208 .join("demo")
1209 .join("state");
1210 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
1211 fs::write(
1212 deploy_dir.join("terraform-outputs.json"),
1213 serde_json::to_vec_pretty(&serde_json::json!({
1214 "operator_endpoint": {
1215 "value": "https://greentic-demo-run-abc123-uc.a.run.app"
1216 },
1217 "admin_public_endpoint": {
1218 "value": "https://greentic-demo-run-abc123-uc.a.run.app/admin"
1219 },
1220 "admin_relay_token_secret_ref": {
1221 "value": "projects/demo-project/secrets/admin-relay-token"
1222 },
1223 "admin_ca_secret_ref": {
1224 "value": "projects/demo-project/secrets/greentic-demo-admin-ca"
1225 },
1226 "admin_client_cert_secret_ref": {
1227 "value": "projects/demo-project/secrets/admin-client-cert"
1228 },
1229 "admin_client_key_secret_ref": {
1230 "value": "projects/demo-project/secrets/admin-client-key"
1231 }
1232 }))
1233 .expect("serialize outputs"),
1234 )
1235 .expect("write outputs");
1236
1237 let info = resolve_admin_access(&bundle_dir, Provider::Gcp).expect("resolve");
1238 assert_eq!(info.provider, "gcp");
1239 assert_eq!(
1240 info.provider_details.gcp_project_id.as_deref(),
1241 Some("demo-project")
1242 );
1243 assert_eq!(
1244 info.provider_details.gcp_cloud_run_service_name.as_deref(),
1245 Some("greentic-demo-run")
1246 );
1247 assert!(info.client_credentials_available);
1248 assert!(info.missing_requirements.is_empty());
1249 assert!(
1250 info.suggested_commands
1251 .iter()
1252 .any(|command| command.contains("gcloud run services describe greentic-demo-run"))
1253 );
1254 }
1255
1256 #[test]
1257 fn render_admin_access_supports_text_json_and_yaml() {
1258 let info = AdminAccessInfo {
1259 provider: "azure".to_string(),
1260 bundle_dir: PathBuf::from("/tmp/bundle"),
1261 deploy_dir: PathBuf::from("/tmp/bundle/.greentic/deploy/azure/demo/state"),
1262 local_cert_dir: PathBuf::from("/tmp/bundle/.greentic/admin/certs/demo"),
1263 admin_access_mode: Some("internal".to_string()),
1264 admin_public_endpoint: Some("https://admin.example.test".to_string()),
1265 operator_endpoint: Some("https://greentic-demo-app.example.test".to_string()),
1266 deployment_name_prefix: Some("greentic-demo".to_string()),
1267 operator_host: Some("greentic-demo-app.example.test".to_string()),
1268 provider_details: AdminProviderDetails {
1269 azure_resource_group_name: Some("greentic-demo-rg".to_string()),
1270 azure_container_app_name: Some("greentic-demo-app".to_string()),
1271 ..Default::default()
1272 },
1273 admin_listener: "127.0.0.1:8433".to_string(),
1274 admin_secret_refs: AdminSecretRefs {
1275 admin_ca_secret_ref: Some("https://vault.example/secrets/ca".to_string()),
1276 admin_server_cert_secret_ref: None,
1277 admin_server_key_secret_ref: None,
1278 admin_client_cert_secret_ref: Some(
1279 "https://vault.example/secrets/client-cert".to_string(),
1280 ),
1281 admin_client_key_secret_ref: Some(
1282 "https://vault.example/secrets/client-key".to_string(),
1283 ),
1284 admin_relay_token_secret_ref: Some(
1285 "https://vault.example/secrets/relay-token".to_string(),
1286 ),
1287 },
1288 client_credentials_available: true,
1289 missing_requirements: Vec::new(),
1290 tunnel_support: tunnel_support_for_provider(Provider::Azure),
1291 suggested_commands: suggested_commands(&serde_json::json!({}), Provider::Azure),
1292 curl_health_example: curl_health_example(Provider::Azure),
1293 notes: notes_for_provider(Provider::Azure),
1294 };
1295
1296 let text = render_admin_access(&info, OutputFormat::Text).expect("render text");
1297 assert!(text.contains("provider: azure"));
1298 assert!(text.contains("admin_public_endpoint: https://admin.example.test"));
1299 assert!(text.contains("tunnel_supported: false"));
1300
1301 let json = render_admin_access(&info, OutputFormat::Json).expect("render json");
1302 assert!(json.contains(r#""provider": "azure""#));
1303 assert!(json.contains(r#""admin_access_mode": "internal""#));
1304
1305 let yaml = render_admin_access(&info, OutputFormat::Yaml).expect("render yaml");
1306 assert!(yaml.contains("provider: azure"));
1307 assert!(yaml.contains("admin_access_mode: internal"));
1308 }
1309
1310 #[test]
1311 fn parse_gcp_secret_ref_extracts_project_and_secret_name() {
1312 let (project_id, secret_name) =
1313 parse_gcp_secret_ref("projects/demo-project/secrets/admin-client-cert").expect("parse");
1314 assert_eq!(project_id, "demo-project");
1315 assert_eq!(secret_name, "admin-client-cert");
1316 }
1317
1318 #[test]
1319 fn gcp_secret_value_from_terraform_state_reads_secret_data() {
1320 let tmp = tempdir().expect("tempdir");
1321 let deploy_dir = tmp.path().join("deploy");
1322 fs::create_dir_all(deploy_dir.join("terraform")).expect("create terraform dir");
1323 fs::write(
1324 deploy_dir.join("terraform").join("terraform.tfstate"),
1325 serde_json::to_vec_pretty(&serde_json::json!({
1326 "resources": [
1327 {
1328 "type": "google_secret_manager_secret_version",
1329 "instances": [
1330 {
1331 "attributes": {
1332 "secret": "projects/demo-project/secrets/admin-relay-token",
1333 "secret_data": "demo-token"
1334 }
1335 }
1336 ]
1337 }
1338 ]
1339 }))
1340 .expect("serialize state"),
1341 )
1342 .expect("write state");
1343
1344 let info = AdminAccessInfo {
1345 provider: "gcp".to_string(),
1346 bundle_dir: tmp.path().join("bundle"),
1347 deploy_dir,
1348 local_cert_dir: tmp.path().join("certs"),
1349 admin_access_mode: None,
1350 admin_public_endpoint: None,
1351 operator_endpoint: None,
1352 deployment_name_prefix: None,
1353 operator_host: None,
1354 provider_details: AdminProviderDetails::default(),
1355 admin_listener: "127.0.0.1:8433".to_string(),
1356 admin_secret_refs: AdminSecretRefs {
1357 admin_ca_secret_ref: None,
1358 admin_server_cert_secret_ref: None,
1359 admin_server_key_secret_ref: None,
1360 admin_client_cert_secret_ref: None,
1361 admin_client_key_secret_ref: None,
1362 admin_relay_token_secret_ref: None,
1363 },
1364 client_credentials_available: false,
1365 missing_requirements: Vec::new(),
1366 tunnel_support: AdminTunnelSupport {
1367 supported: false,
1368 mode: None,
1369 reason: None,
1370 command_hint: None,
1371 local_port_default: 8443,
1372 },
1373 suggested_commands: Vec::new(),
1374 curl_health_example: None,
1375 notes: Vec::new(),
1376 };
1377
1378 let value = gcp_secret_value_from_terraform_state(
1379 &info,
1380 "projects/demo-project/secrets/admin-relay-token",
1381 )
1382 .expect("read token");
1383 assert_eq!(value, "demo-token");
1384 }
1385
1386 #[test]
1387 fn azure_secret_value_from_terraform_state_reads_value() {
1388 let tmp = tempdir().expect("tempdir");
1389 let deploy_dir = tmp.path().join("deploy");
1390 fs::create_dir_all(deploy_dir.join("terraform")).expect("create terraform dir");
1391 fs::write(
1392 deploy_dir.join("terraform").join("terraform.tfstate"),
1393 serde_json::to_vec_pretty(&serde_json::json!({
1394 "resources": [
1395 {
1396 "type": "azurerm_key_vault_secret",
1397 "instances": [
1398 {
1399 "attributes": {
1400 "versionless_id": "https://vault.example.net/secrets/admin-relay-token",
1401 "value": "demo-azure-token"
1402 }
1403 }
1404 ]
1405 }
1406 ]
1407 }))
1408 .expect("serialize state"),
1409 )
1410 .expect("write state");
1411
1412 let info = AdminAccessInfo {
1413 provider: "azure".to_string(),
1414 bundle_dir: tmp.path().join("bundle"),
1415 deploy_dir,
1416 local_cert_dir: tmp.path().join("certs"),
1417 admin_access_mode: None,
1418 admin_public_endpoint: None,
1419 operator_endpoint: None,
1420 deployment_name_prefix: None,
1421 operator_host: None,
1422 provider_details: AdminProviderDetails::default(),
1423 admin_listener: "127.0.0.1:8433".to_string(),
1424 admin_secret_refs: AdminSecretRefs {
1425 admin_ca_secret_ref: None,
1426 admin_server_cert_secret_ref: None,
1427 admin_server_key_secret_ref: None,
1428 admin_client_cert_secret_ref: None,
1429 admin_client_key_secret_ref: None,
1430 admin_relay_token_secret_ref: None,
1431 },
1432 client_credentials_available: false,
1433 missing_requirements: Vec::new(),
1434 tunnel_support: AdminTunnelSupport {
1435 supported: false,
1436 mode: None,
1437 reason: None,
1438 command_hint: None,
1439 local_port_default: 8443,
1440 },
1441 suggested_commands: Vec::new(),
1442 curl_health_example: None,
1443 notes: Vec::new(),
1444 };
1445
1446 let value = azure_secret_value_from_terraform_state(
1447 &info,
1448 "https://vault.example.net/secrets/admin-relay-token",
1449 )
1450 .expect("read token");
1451 assert_eq!(value, "demo-azure-token");
1452 }
1453
1454 #[test]
1455 fn render_materialized_admin_certs_text_lists_paths() {
1456 let value = MaterializedAdminCerts {
1457 provider: "gcp".to_string(),
1458 cert_dir: PathBuf::from("/tmp/demo"),
1459 ca_cert_path: PathBuf::from("/tmp/demo/ca.crt"),
1460 client_cert_path: PathBuf::from("/tmp/demo/client.crt"),
1461 client_key_path: PathBuf::from("/tmp/demo/client.key"),
1462 };
1463
1464 let rendered = render_materialized_admin_certs(&value, OutputFormat::Text).expect("render");
1465 assert!(rendered.contains("provider: gcp"));
1466 assert!(rendered.contains("ca_cert_path: /tmp/demo/ca.crt"));
1467 assert!(rendered.contains("client_cert_path: /tmp/demo/client.crt"));
1468 assert!(rendered.contains("client_key_path: /tmp/demo/client.key"));
1469 }
1470
1471 #[test]
1472 fn render_materialized_admin_relay_token_redacts_secret_value() {
1473 let rendered = render_materialized_admin_relay_token(
1474 Provider::Aws,
1475 "super-secret-token",
1476 OutputFormat::Json,
1477 )
1478 .expect("render");
1479 assert!(rendered.contains("\"token\": \"[REDACTED]\""));
1480 assert!(!rendered.contains("super-secret-token"));
1481 }
1482}