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}