nixfleet_control_plane/
rollouts_source.rs1use std::path::PathBuf;
11use std::time::Duration;
12
13use anyhow::{Context, Result, anyhow};
14
15use crate::polling::signed_fetch;
16
17pub const ROLLOUT_ID_PLACEHOLDER: &str = "{rolloutId}";
18
19#[derive(Debug, Clone)]
20pub struct RolloutsSource {
21 pub artifact_url_template: String,
23 pub signature_url_template: String,
25 pub token_file: Option<PathBuf>,
27 pub timeout: Duration,
28}
29
30impl RolloutsSource {
31 pub fn new(
33 artifact_url_template: String,
34 signature_url_template: String,
35 token_file: Option<PathBuf>,
36 ) -> Result<Self> {
37 if !artifact_url_template.contains(ROLLOUT_ID_PLACEHOLDER) {
38 return Err(anyhow!(
39 "rollouts source: artifact_url_template must contain {ROLLOUT_ID_PLACEHOLDER}"
40 ));
41 }
42 if !signature_url_template.contains(ROLLOUT_ID_PLACEHOLDER) {
43 return Err(anyhow!(
44 "rollouts source: signature_url_template must contain {ROLLOUT_ID_PLACEHOLDER}"
45 ));
46 }
47 Ok(Self {
48 artifact_url_template,
49 signature_url_template,
50 token_file,
51 timeout: Duration::from_secs(15),
52 })
53 }
54
55 pub async fn fetch_pair(&self, rollout_id: &str) -> Result<(Vec<u8>, Vec<u8>)> {
62 let artifact_url = self
63 .artifact_url_template
64 .replace(ROLLOUT_ID_PLACEHOLDER, rollout_id);
65 let signature_url = self
66 .signature_url_template
67 .replace(ROLLOUT_ID_PLACEHOLDER, rollout_id);
68
69 let token = signed_fetch::read_token(self.token_file.as_deref())?;
70 let client = reqwest::Client::builder()
71 .use_rustls_tls()
72 .timeout(self.timeout)
73 .build()
74 .context("build rollouts-source client")?;
75
76 signed_fetch::fetch_signed_pair(&client, &artifact_url, &signature_url, token.as_deref())
77 .await
78 .with_context(|| format!("fetch rollout pair for {rollout_id}"))
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn new_rejects_template_without_placeholder() {
88 let err = RolloutsSource::new(
89 "https://example/no-placeholder.json".to_string(),
90 "https://example/no-placeholder.json.sig".to_string(),
91 None,
92 )
93 .unwrap_err();
94 assert!(err.to_string().contains(ROLLOUT_ID_PLACEHOLDER));
95 }
96
97 #[test]
98 fn new_rejects_signature_template_without_placeholder() {
99 let err = RolloutsSource::new(
100 format!("https://example/{ROLLOUT_ID_PLACEHOLDER}.json"),
101 "https://example/no-placeholder.json.sig".to_string(),
102 None,
103 )
104 .unwrap_err();
105 assert!(err.to_string().contains("signature_url_template"));
106 }
107
108 #[test]
109 fn new_accepts_valid_templates() {
110 let s = RolloutsSource::new(
111 format!("https://example/rollouts/{ROLLOUT_ID_PLACEHOLDER}.json"),
112 format!("https://example/rollouts/{ROLLOUT_ID_PLACEHOLDER}.json.sig"),
113 Some(PathBuf::from("/run/agenix/token")),
114 )
115 .unwrap();
116 assert!(s.artifact_url_template.contains(ROLLOUT_ID_PLACEHOLDER));
117 assert!(s.signature_url_template.contains(ROLLOUT_ID_PLACEHOLDER));
118 assert_eq!(
119 s.token_file.as_deref(),
120 Some(std::path::Path::new("/run/agenix/token"))
121 );
122 }
123}