Skip to main content

greentic_deployer/
admin_access.rs

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}