aether_api/
deployment.rs

1use crate::{
2    http_router_with_options, sidecar::sidecar_catalog_path_for_journal, ApiError, AuthScope,
3    HttpAccessToken, HttpAuthConfig, HttpKernelOptions, PrincipalStatusSummary, ServiceMode,
4    ServiceStatusResponse, ServiceStatusStorage, SqliteKernelService,
5};
6use aether_ast::PolicyContext;
7use serde::{Deserialize, Serialize};
8use std::{
9    env, fs,
10    path::{Component, Path, PathBuf},
11    process::{Command, Stdio},
12};
13use thiserror::Error;
14
15#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
16pub struct PilotServiceConfig {
17    #[serde(default = "default_config_version")]
18    pub config_version: String,
19    #[serde(default = "default_schema_version")]
20    pub schema_version: String,
21    #[serde(default)]
22    pub service_mode: ServiceMode,
23    pub bind_addr: String,
24    pub database_path: PathBuf,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub audit_log_path: Option<PathBuf>,
27    pub auth: PilotAuthConfig,
28}
29
30impl PilotServiceConfig {
31    pub fn load(path: impl AsRef<Path>) -> Result<Self, DeploymentError> {
32        let path = path.as_ref();
33        let contents = fs::read_to_string(path).map_err(|source| DeploymentError::ReadConfig {
34            path: path.to_path_buf(),
35            source,
36        })?;
37        serde_json::from_str(&contents).map_err(|source| DeploymentError::ParseConfig {
38            path: path.to_path_buf(),
39            source,
40        })
41    }
42
43    pub fn resolve(
44        self,
45        config_path: impl AsRef<Path>,
46    ) -> Result<ResolvedPilotServiceConfig, DeploymentError> {
47        let config_path = config_path.as_ref();
48        let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
49        if self.bind_addr.trim().is_empty() {
50            return Err(DeploymentError::Validation(
51                "pilot service bind_addr must not be empty".into(),
52            ));
53        }
54
55        let database_path = resolve_path(config_dir, &self.database_path);
56        let audit_log_path = self
57            .audit_log_path
58            .map(|path| resolve_path(config_dir, &path))
59            .unwrap_or_else(|| default_audit_log_path(&database_path));
60
61        let resolved_tokens = self.auth.resolve(config_dir)?;
62        let auth = resolved_tokens
63            .iter()
64            .fold(HttpAuthConfig::new(), |mut auth, token| {
65                auth.tokens.push(HttpAccessToken {
66                    token: token.token.clone(),
67                    token_id: token.token_id.clone(),
68                    principal: token.principal.clone(),
69                    principal_id: token.principal_id.clone(),
70                    scopes: token.scopes.clone(),
71                    policy_context: token.policy_context.clone(),
72                    source: token.source.clone(),
73                    revoked: token.revoked,
74                });
75                auth
76            });
77
78        Ok(ResolvedPilotServiceConfig {
79            config_path: config_path.to_path_buf(),
80            config_version: self.config_version,
81            schema_version: self.schema_version,
82            service_mode: self.service_mode,
83            bind_addr: self.bind_addr,
84            database_path,
85            audit_log_path,
86            auth,
87            token_summaries: resolved_tokens
88                .into_iter()
89                .map(|token| ResolvedPilotTokenSummary {
90                    principal: token.principal,
91                    principal_id: token.principal_id,
92                    token_id: token.token_id,
93                    scopes: token.scopes,
94                    policy_context: token.policy_context,
95                    source: token.source,
96                    revoked: token.revoked,
97                })
98                .collect(),
99        })
100    }
101}
102
103#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
104pub struct PilotAuthConfig {
105    pub tokens: Vec<PilotTokenConfig>,
106    #[serde(default)]
107    pub revoked_token_ids: Vec<String>,
108    #[serde(default)]
109    pub revoked_principal_ids: Vec<String>,
110}
111
112impl PilotAuthConfig {
113    fn resolve(&self, config_dir: &Path) -> Result<Vec<ResolvedPilotToken>, DeploymentError> {
114        if self.tokens.is_empty() {
115            return Err(DeploymentError::Validation(
116                "pilot service auth.tokens must contain at least one token".into(),
117            ));
118        }
119        let revoked_token_ids = self
120            .revoked_token_ids
121            .iter()
122            .map(|value| value.trim().to_string())
123            .filter(|value| !value.is_empty())
124            .collect::<std::collections::BTreeSet<_>>();
125        let revoked_principal_ids = self
126            .revoked_principal_ids
127            .iter()
128            .map(|value| value.trim().to_string())
129            .filter(|value| !value.is_empty())
130            .collect::<std::collections::BTreeSet<_>>();
131        let resolved = self
132            .tokens
133            .iter()
134            .map(|token| token.resolve(config_dir, &revoked_token_ids, &revoked_principal_ids))
135            .collect::<Result<Vec<_>, _>>()?;
136        let mut seen = std::collections::BTreeSet::new();
137        for token in &resolved {
138            if !seen.insert(token.token_id.clone()) {
139                return Err(DeploymentError::Validation(format!(
140                    "pilot auth token_id {} is duplicated",
141                    token.token_id
142                )));
143            }
144        }
145        Ok(resolved)
146    }
147}
148
149#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
150pub struct PilotTokenConfig {
151    pub principal: String,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub principal_id: Option<String>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub token_id: Option<String>,
156    pub scopes: Vec<AuthScope>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub policy_context: Option<PolicyContext>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub token: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub token_env: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub token_file: Option<PathBuf>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub token_command: Option<Vec<String>>,
167    #[serde(default)]
168    pub revoked: bool,
169}
170
171impl PilotTokenConfig {
172    fn resolve(
173        &self,
174        config_dir: &Path,
175        revoked_token_ids: &std::collections::BTreeSet<String>,
176        revoked_principal_ids: &std::collections::BTreeSet<String>,
177    ) -> Result<ResolvedPilotToken, DeploymentError> {
178        if self.principal.trim().is_empty() {
179            return Err(DeploymentError::Validation(
180                "pilot auth principal must not be empty".into(),
181            ));
182        }
183        if self.scopes.is_empty() {
184            return Err(DeploymentError::Validation(format!(
185                "pilot auth principal {} must declare at least one scope",
186                self.principal
187            )));
188        }
189        let principal_id = self
190            .principal_id
191            .clone()
192            .unwrap_or_else(|| format!("principal:{}", self.principal.trim()))
193            .trim()
194            .to_string();
195        if principal_id.is_empty() {
196            return Err(DeploymentError::Validation(format!(
197                "pilot auth principal {} resolved an empty principal_id",
198                self.principal
199            )));
200        }
201        let token_id = self
202            .token_id
203            .clone()
204            .unwrap_or_else(|| format!("token:{}", self.principal.trim()))
205            .trim()
206            .to_string();
207        if token_id.is_empty() {
208            return Err(DeploymentError::Validation(format!(
209                "pilot auth principal {} resolved an empty token_id",
210                self.principal
211            )));
212        }
213
214        let mut sources = 0;
215        if self.token.is_some() {
216            sources += 1;
217        }
218        if self.token_env.is_some() {
219            sources += 1;
220        }
221        if self.token_file.is_some() {
222            sources += 1;
223        }
224        if self.token_command.is_some() {
225            sources += 1;
226        }
227        if sources != 1 {
228            return Err(DeploymentError::Validation(format!(
229                "pilot auth principal {} must declare exactly one token source (token, token_env, token_file, or token_command)",
230                self.principal
231            )));
232        }
233
234        let (token, source) = if let Some(token) = &self.token {
235            let token = token.trim();
236            if token.is_empty() {
237                return Err(DeploymentError::Validation(format!(
238                    "pilot auth principal {} has an empty inline token",
239                    self.principal
240                )));
241            }
242            (token.to_string(), "inline".to_string())
243        } else if let Some(token_env) = &self.token_env {
244            let token = env::var(token_env)
245                .map_err(|_| DeploymentError::MissingTokenEnv(token_env.clone()))?;
246            let token = token.trim().to_string();
247            if token.is_empty() {
248                return Err(DeploymentError::Validation(format!(
249                    "environment token {} for principal {} is empty",
250                    token_env, self.principal
251                )));
252            }
253            (token, format!("env:{token_env}"))
254        } else if let Some(token_file_path) = &self.token_file {
255            let token_file = resolve_path(config_dir, token_file_path);
256            let token = fs::read_to_string(token_file.clone()).map_err(|source| {
257                DeploymentError::ReadTokenFile {
258                    path: token_file.clone(),
259                    source,
260                }
261            })?;
262            let token = token.trim().to_string();
263            if token.is_empty() {
264                return Err(DeploymentError::Validation(format!(
265                    "token file {} for principal {} is empty",
266                    token_file.display(),
267                    self.principal
268                )));
269            }
270            (token, format!("file:{}", token_file.display()))
271        } else {
272            let token_command = self
273                .token_command
274                .as_ref()
275                .expect("token_command source already validated");
276            let (token, source) =
277                resolve_token_command(config_dir, token_command, &self.principal)?;
278            (token, source)
279        };
280
281        Ok(ResolvedPilotToken {
282            principal: self.principal.clone(),
283            principal_id: principal_id.clone(),
284            token_id: token_id.clone(),
285            scopes: self.scopes.clone(),
286            policy_context: normalize_policy_context(self.policy_context.clone()),
287            token,
288            source,
289            revoked: self.revoked
290                || revoked_token_ids.contains(&token_id)
291                || revoked_principal_ids.contains(&principal_id),
292        })
293    }
294}
295
296#[derive(Clone, Debug, Eq, PartialEq)]
297pub struct ResolvedPilotServiceConfig {
298    pub config_path: PathBuf,
299    pub config_version: String,
300    pub schema_version: String,
301    pub service_mode: ServiceMode,
302    pub bind_addr: String,
303    pub database_path: PathBuf,
304    pub audit_log_path: PathBuf,
305    pub auth: HttpAuthConfig,
306    pub token_summaries: Vec<ResolvedPilotTokenSummary>,
307}
308
309impl ResolvedPilotServiceConfig {
310    pub fn sidecar_path(&self) -> PathBuf {
311        sidecar_catalog_path_for_journal(&self.database_path)
312    }
313
314    pub fn service_status(&self) -> ServiceStatusResponse {
315        ServiceStatusResponse {
316            status: "ok".into(),
317            build_version: env!("CARGO_PKG_VERSION").into(),
318            config_version: self.config_version.clone(),
319            schema_version: self.schema_version.clone(),
320            bind_addr: Some(self.bind_addr.clone()),
321            service_mode: self.service_mode.clone(),
322            storage: ServiceStatusStorage {
323                database_path: Some(self.database_path.clone()),
324                sidecar_path: Some(self.sidecar_path()),
325                audit_log_path: Some(self.audit_log_path.clone()),
326                partition_root: None,
327            },
328            principals: self
329                .token_summaries
330                .iter()
331                .map(|summary| summary.status_summary())
332                .collect(),
333            replicas: Vec::new(),
334        }
335    }
336}
337
338#[derive(Clone, Debug, Eq, PartialEq)]
339pub struct ResolvedPilotTokenSummary {
340    pub principal: String,
341    pub principal_id: String,
342    pub token_id: String,
343    pub scopes: Vec<AuthScope>,
344    pub policy_context: Option<PolicyContext>,
345    pub source: String,
346    pub revoked: bool,
347}
348
349impl ResolvedPilotTokenSummary {
350    pub fn status_summary(&self) -> PrincipalStatusSummary {
351        PrincipalStatusSummary {
352            principal: self.principal.clone(),
353            principal_id: self.principal_id.clone(),
354            token_id: self.token_id.clone(),
355            scopes: self
356                .scopes
357                .iter()
358                .map(|scope| format!("{scope:?}").to_lowercase())
359                .collect(),
360            policy_context: self.policy_context.clone(),
361            source: self.source.clone(),
362            revoked: self.revoked,
363        }
364    }
365}
366
367#[derive(Clone, Debug, Eq, PartialEq)]
368struct ResolvedPilotToken {
369    principal: String,
370    principal_id: String,
371    token_id: String,
372    scopes: Vec<AuthScope>,
373    policy_context: Option<PolicyContext>,
374    token: String,
375    source: String,
376    revoked: bool,
377}
378
379#[derive(Debug, Error)]
380pub enum DeploymentError {
381    #[error("failed to read pilot service config {path}: {source}")]
382    ReadConfig {
383        path: PathBuf,
384        #[source]
385        source: std::io::Error,
386    },
387    #[error("failed to parse pilot service config {path}: {source}")]
388    ParseConfig {
389        path: PathBuf,
390        #[source]
391        source: serde_json::Error,
392    },
393    #[error("missing required token environment variable {0}")]
394    MissingTokenEnv(String),
395    #[error("failed to read token file {path}: {source}")]
396    ReadTokenFile {
397        path: PathBuf,
398        #[source]
399        source: std::io::Error,
400    },
401    #[error("failed to launch token command {command}: {source}")]
402    RunTokenCommand {
403        command: String,
404        #[source]
405        source: std::io::Error,
406    },
407    #[error("token command {command} for principal {principal} exited with code {exit_code:?}: {stderr}")]
408    TokenCommandFailed {
409        principal: String,
410        command: String,
411        exit_code: Option<i32>,
412        stderr: String,
413    },
414    #[error("invalid pilot deployment configuration: {0}")]
415    Validation(String),
416    #[error(transparent)]
417    Api(#[from] ApiError),
418    #[error(transparent)]
419    Io(#[from] std::io::Error),
420}
421
422pub fn default_audit_log_path(database_path: &Path) -> PathBuf {
423    database_path.with_extension("audit.jsonl")
424}
425
426pub async fn serve_pilot_http_service(
427    resolved: ResolvedPilotServiceConfig,
428) -> Result<(), DeploymentError> {
429    let service = SqliteKernelService::open(&resolved.database_path)?;
430    let listener = tokio::net::TcpListener::bind(&resolved.bind_addr).await?;
431    let options = HttpKernelOptions::new()
432        .with_auth(resolved.auth.clone())
433        .with_audit_log_path(resolved.audit_log_path.clone())
434        .with_service_status(resolved.service_status())
435        .with_auth_reload_config_path(resolved.config_path.clone());
436    axum::serve(listener, http_router_with_options(service, options)).await?;
437    Ok(())
438}
439
440fn default_config_version() -> String {
441    "pilot-v1".into()
442}
443
444fn default_schema_version() -> String {
445    "v1".into()
446}
447
448fn resolve_path(base_dir: &Path, path: &Path) -> PathBuf {
449    let joined = if path.is_absolute() {
450        path.to_path_buf()
451    } else {
452        base_dir.join(path)
453    };
454    normalize_path(&joined)
455}
456
457fn resolve_token_command(
458    config_dir: &Path,
459    token_command: &[String],
460    principal: &str,
461) -> Result<(String, String), DeploymentError> {
462    let (program, args) = token_command.split_first().ok_or_else(|| {
463        DeploymentError::Validation(format!(
464            "pilot auth principal {principal} has an empty token_command"
465        ))
466    })?;
467    let command_path = resolve_command_path(config_dir, program);
468    let output = Command::new(command_path)
469        .args(args)
470        .stdin(Stdio::null())
471        .stdout(Stdio::piped())
472        .stderr(Stdio::piped())
473        .output()
474        .map_err(|source| DeploymentError::RunTokenCommand {
475            command: display_command(program, args),
476            source,
477        })?;
478    if !output.status.success() {
479        return Err(DeploymentError::TokenCommandFailed {
480            principal: principal.to_string(),
481            command: display_command(program, args),
482            exit_code: output.status.code(),
483            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
484        });
485    }
486
487    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
488    if token.is_empty() {
489        return Err(DeploymentError::Validation(format!(
490            "token command {} for principal {} returned an empty token",
491            display_command(program, args),
492            principal
493        )));
494    }
495
496    Ok((token, format!("command:{program}")))
497}
498
499fn resolve_command_path(config_dir: &Path, program: &str) -> PathBuf {
500    let program_path = Path::new(program);
501    if program_path.is_absolute()
502        || program_path.parent().is_some()
503        || program.starts_with('.')
504        || program.contains('/')
505        || program.contains('\\')
506    {
507        resolve_path(config_dir, program_path)
508    } else {
509        program_path.to_path_buf()
510    }
511}
512
513fn display_command(program: &str, args: &[String]) -> String {
514    std::iter::once(program.to_string())
515        .chain(args.iter().cloned())
516        .collect::<Vec<_>>()
517        .join(" ")
518}
519
520fn normalize_path(path: &Path) -> PathBuf {
521    let mut normalized = PathBuf::new();
522    for component in path.components() {
523        match component {
524            Component::CurDir => {}
525            Component::ParentDir => {
526                normalized.pop();
527            }
528            other => normalized.push(other.as_os_str()),
529        }
530    }
531    normalized
532}
533
534fn normalize_policy_context(policy_context: Option<PolicyContext>) -> Option<PolicyContext> {
535    match policy_context {
536        Some(policy_context) if policy_context.is_empty() => None,
537        other => other,
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::{
544        default_audit_log_path, DeploymentError, PilotAuthConfig, PilotServiceConfig,
545        PilotTokenConfig,
546    };
547    use crate::{AuthScope, ServiceMode};
548    use aether_ast::PolicyContext;
549    use std::{
550        fs,
551        path::PathBuf,
552        time::{SystemTime, UNIX_EPOCH},
553    };
554
555    #[test]
556    fn resolves_token_file_relative_to_config_path() {
557        let root = unique_temp_dir("pilot-config");
558        let config_dir = root.join("config");
559        fs::create_dir_all(&config_dir).expect("create config dir");
560        let token_path = config_dir.join("pilot.token");
561        fs::write(&token_path, "secret-token\n").expect("write token");
562
563        let config = PilotServiceConfig {
564            config_version: "pilot-v1".into(),
565            schema_version: "v1".into(),
566            service_mode: ServiceMode::SingleNode,
567            bind_addr: "127.0.0.1:3000".into(),
568            database_path: PathBuf::from("../data/coordination.sqlite"),
569            audit_log_path: None,
570            auth: PilotAuthConfig {
571                revoked_token_ids: Vec::new(),
572                revoked_principal_ids: Vec::new(),
573                tokens: vec![PilotTokenConfig {
574                    principal: "pilot-operator".into(),
575                    principal_id: Some("principal:pilot-operator".into()),
576                    token_id: Some("token:pilot-operator".into()),
577                    scopes: vec![AuthScope::Query, AuthScope::Explain],
578                    policy_context: Some(PolicyContext {
579                        capabilities: vec!["executor".into()],
580                        visibilities: Vec::new(),
581                    }),
582                    token: None,
583                    token_env: None,
584                    token_file: Some(PathBuf::from("pilot.token")),
585                    token_command: None,
586                    revoked: false,
587                }],
588            },
589        };
590
591        let resolved = config
592            .resolve(config_dir.join("pilot-service.json"))
593            .expect("resolve config");
594
595        assert_eq!(
596            resolved.database_path,
597            root.join("data").join("coordination.sqlite")
598        );
599        assert_eq!(
600            resolved.audit_log_path,
601            default_audit_log_path(&root.join("data").join("coordination.sqlite"))
602        );
603        assert_eq!(resolved.auth.tokens.len(), 1);
604        assert_eq!(resolved.auth.tokens[0].token, "secret-token");
605        assert_eq!(
606            resolved.token_summaries[0].source,
607            format!("file:{}", token_path.display())
608        );
609    }
610
611    #[test]
612    fn rejects_missing_or_ambiguous_token_sources() {
613        let config = PilotServiceConfig {
614            config_version: "pilot-v1".into(),
615            schema_version: "v1".into(),
616            service_mode: ServiceMode::SingleNode,
617            bind_addr: "127.0.0.1:3000".into(),
618            database_path: PathBuf::from("coordination.sqlite"),
619            audit_log_path: None,
620            auth: PilotAuthConfig {
621                revoked_token_ids: Vec::new(),
622                revoked_principal_ids: Vec::new(),
623                tokens: vec![PilotTokenConfig {
624                    principal: "pilot-operator".into(),
625                    principal_id: Some("principal:pilot-operator".into()),
626                    token_id: Some("token:pilot-operator".into()),
627                    scopes: vec![AuthScope::Query],
628                    policy_context: None,
629                    token: Some("inline".into()),
630                    token_env: Some("AETHER_TOKEN".into()),
631                    token_file: None,
632                    token_command: None,
633                    revoked: false,
634                }],
635            },
636        };
637
638        let error = config
639            .resolve(PathBuf::from("pilot-service.json"))
640            .expect_err("ambiguous token source should fail");
641        assert!(matches!(
642            error,
643            DeploymentError::Validation(message)
644                if message.contains("exactly one token source")
645        ));
646    }
647
648    #[test]
649    fn resolves_token_from_command() {
650        let command = token_command_fixture("command-token");
651        let config = PilotServiceConfig {
652            config_version: "pilot-v1".into(),
653            schema_version: "v1".into(),
654            service_mode: ServiceMode::SingleNode,
655            bind_addr: "127.0.0.1:3000".into(),
656            database_path: PathBuf::from("coordination.sqlite"),
657            audit_log_path: None,
658            auth: PilotAuthConfig {
659                revoked_token_ids: Vec::new(),
660                revoked_principal_ids: Vec::new(),
661                tokens: vec![PilotTokenConfig {
662                    principal: "pilot-operator".into(),
663                    principal_id: Some("principal:pilot-operator".into()),
664                    token_id: Some("token:pilot-operator".into()),
665                    scopes: vec![AuthScope::Query],
666                    policy_context: None,
667                    token: None,
668                    token_env: None,
669                    token_file: None,
670                    token_command: Some(command),
671                    revoked: false,
672                }],
673            },
674        };
675
676        let resolved = config
677            .resolve(PathBuf::from("pilot-service.json"))
678            .expect("resolve command token");
679        assert_eq!(resolved.auth.tokens[0].token, "command-token");
680        assert!(resolved.token_summaries[0].source.starts_with("command:"));
681    }
682
683    #[test]
684    fn rejects_empty_token_command_output() {
685        let config = PilotServiceConfig {
686            config_version: "pilot-v1".into(),
687            schema_version: "v1".into(),
688            service_mode: ServiceMode::SingleNode,
689            bind_addr: "127.0.0.1:3000".into(),
690            database_path: PathBuf::from("coordination.sqlite"),
691            audit_log_path: None,
692            auth: PilotAuthConfig {
693                revoked_token_ids: Vec::new(),
694                revoked_principal_ids: Vec::new(),
695                tokens: vec![PilotTokenConfig {
696                    principal: "pilot-operator".into(),
697                    principal_id: Some("principal:pilot-operator".into()),
698                    token_id: Some("token:pilot-operator".into()),
699                    scopes: vec![AuthScope::Query],
700                    policy_context: None,
701                    token: None,
702                    token_env: None,
703                    token_file: None,
704                    token_command: Some(empty_output_command_fixture()),
705                    revoked: false,
706                }],
707            },
708        };
709
710        let error = config
711            .resolve(PathBuf::from("pilot-service.json"))
712            .expect_err("empty command output should fail");
713        assert!(matches!(
714            error,
715            DeploymentError::Validation(message)
716                if message.contains("returned an empty token")
717        ));
718    }
719
720    #[test]
721    fn rejects_missing_token_file_path() {
722        let root = unique_temp_dir("pilot-missing-token");
723        let config_dir = root.join("config");
724        fs::create_dir_all(&config_dir).expect("create config dir");
725
726        let config = PilotServiceConfig {
727            config_version: "pilot-v1".into(),
728            schema_version: "v1".into(),
729            service_mode: ServiceMode::SingleNode,
730            bind_addr: "127.0.0.1:3000".into(),
731            database_path: PathBuf::from("coordination.sqlite"),
732            audit_log_path: None,
733            auth: PilotAuthConfig {
734                revoked_token_ids: Vec::new(),
735                revoked_principal_ids: Vec::new(),
736                tokens: vec![PilotTokenConfig {
737                    principal: "pilot-operator".into(),
738                    principal_id: Some("principal:pilot-operator".into()),
739                    token_id: Some("token:pilot-operator".into()),
740                    scopes: vec![AuthScope::Query],
741                    policy_context: None,
742                    token: None,
743                    token_env: None,
744                    token_file: Some(PathBuf::from("missing.token")),
745                    token_command: None,
746                    revoked: false,
747                }],
748            },
749        };
750
751        let error = config
752            .resolve(config_dir.join("pilot-service.json"))
753            .expect_err("missing token file should fail");
754        assert!(matches!(
755            error,
756            DeploymentError::ReadTokenFile { path, .. }
757                if path.ends_with("missing.token")
758        ));
759    }
760
761    #[test]
762    fn rejects_failed_token_command() {
763        let config = PilotServiceConfig {
764            config_version: "pilot-v1".into(),
765            schema_version: "v1".into(),
766            service_mode: ServiceMode::SingleNode,
767            bind_addr: "127.0.0.1:3000".into(),
768            database_path: PathBuf::from("coordination.sqlite"),
769            audit_log_path: None,
770            auth: PilotAuthConfig {
771                revoked_token_ids: Vec::new(),
772                revoked_principal_ids: Vec::new(),
773                tokens: vec![PilotTokenConfig {
774                    principal: "pilot-operator".into(),
775                    principal_id: Some("principal:pilot-operator".into()),
776                    token_id: Some("token:pilot-operator".into()),
777                    scopes: vec![AuthScope::Query],
778                    policy_context: None,
779                    token: None,
780                    token_env: None,
781                    token_file: None,
782                    token_command: Some(failing_command_fixture()),
783                    revoked: false,
784                }],
785            },
786        };
787
788        let error = config
789            .resolve(PathBuf::from("pilot-service.json"))
790            .expect_err("failed token command should fail");
791        assert!(matches!(
792            error,
793            DeploymentError::TokenCommandFailed {
794                principal,
795                stderr,
796                ..
797            } if principal == "pilot-operator" && stderr.contains("hard failure")
798        ));
799    }
800
801    fn token_command_fixture(token: &str) -> Vec<String> {
802        if cfg!(windows) {
803            vec![
804                "powershell".into(),
805                "-NoProfile".into(),
806                "-Command".into(),
807                format!("Write-Output '{token}'"),
808            ]
809        } else {
810            vec![
811                "sh".into(),
812                "-c".into(),
813                format!("printf '%s\\n' '{token}'"),
814            ]
815        }
816    }
817
818    fn empty_output_command_fixture() -> Vec<String> {
819        if cfg!(windows) {
820            vec![
821                "powershell".into(),
822                "-NoProfile".into(),
823                "-Command".into(),
824                "Write-Output ''".into(),
825            ]
826        } else {
827            vec!["sh".into(), "-c".into(), "printf ''".into()]
828        }
829    }
830
831    fn failing_command_fixture() -> Vec<String> {
832        if cfg!(windows) {
833            vec![
834                "powershell".into(),
835                "-NoProfile".into(),
836                "-Command".into(),
837                "Write-Error 'hard failure'; exit 9".into(),
838            ]
839        } else {
840            vec![
841                "sh".into(),
842                "-c".into(),
843                "printf '%s\\n' 'hard failure' >&2; exit 9".into(),
844            ]
845        }
846    }
847
848    fn unique_temp_dir(prefix: &str) -> PathBuf {
849        let unique = SystemTime::now()
850            .duration_since(UNIX_EPOCH)
851            .expect("clock")
852            .as_nanos();
853        std::env::temp_dir().join(format!("aether-{prefix}-{unique}"))
854    }
855}