1use crate::error::NixError;
7use crate::flake::NixHash;
8use crate::Result;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use tracing::{debug, info, warn};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AtticConfig {
17 pub server_url: String,
19 pub cache_name: String,
21 pub token: Option<String>,
23 pub use_cli: bool,
25}
26
27impl Default for AtticConfig {
28 fn default() -> Self {
29 AtticConfig {
30 server_url: std::env::var("ATTIC_SERVER")
31 .unwrap_or_else(|_| "https://cache.nixos.org".to_string()),
32 cache_name: std::env::var("ATTIC_CACHE").unwrap_or_else(|_| "aivcs".to_string()),
33 token: std::env::var("ATTIC_TOKEN").ok(),
34 use_cli: true,
35 }
36 }
37}
38
39impl AtticConfig {
40 pub fn from_env() -> Self {
42 Self::default()
43 }
44
45 pub fn new(server_url: &str, cache_name: &str) -> Self {
47 AtticConfig {
48 server_url: server_url.to_string(),
49 cache_name: cache_name.to_string(),
50 token: None,
51 use_cli: true,
52 }
53 }
54
55 pub fn with_token(mut self, token: &str) -> Self {
57 self.token = Some(token.to_string());
58 self
59 }
60}
61
62pub struct AtticClient {
64 config: AtticConfig,
65 http_client: reqwest::Client,
66}
67
68impl AtticClient {
69 pub fn new(config: AtticConfig) -> Self {
71 let http_client = reqwest::Client::builder()
72 .user_agent("aivcs-nix-env-manager/0.1.0")
73 .build()
74 .expect("Failed to create HTTP client");
75
76 AtticClient {
77 config,
78 http_client,
79 }
80 }
81
82 pub fn from_env() -> Self {
84 Self::new(AtticConfig::from_env())
85 }
86
87 pub async fn is_environment_cached(&self, hash: &NixHash) -> bool {
91 if self.config.use_cli {
92 self.is_cached_cli(hash).await
93 } else {
94 self.is_cached_http(hash).await
95 }
96 }
97
98 async fn is_cached_cli(&self, hash: &NixHash) -> bool {
100 let store_path = format!("/nix/store/{}-aivcs-env", hash.short());
102
103 let output = Command::new("nix")
104 .args(["path-info", "--store", &self.config.server_url, &store_path])
105 .output();
106
107 match output {
108 Ok(o) => o.status.success(),
109 Err(_) => false,
110 }
111 }
112
113 async fn is_cached_http(&self, hash: &NixHash) -> bool {
115 let url = format!(
116 "{}/{}/{}.narinfo",
117 self.config.server_url,
118 self.config.cache_name,
119 hash.short()
120 );
121
122 match self.http_client.head(&url).send().await {
123 Ok(response) => response.status().is_success(),
124 Err(_) => false,
125 }
126 }
127
128 pub async fn pull_environment(&self, hash: &NixHash) -> Result<PathBuf> {
132 info!("Pulling environment {} from Attic", hash.short());
133
134 if self.config.use_cli {
135 self.pull_cli(hash).await
136 } else {
137 self.pull_http(hash).await
138 }
139 }
140
141 async fn pull_cli(&self, hash: &NixHash) -> Result<PathBuf> {
143 let store_path = format!("/nix/store/{}-aivcs-env", hash.short());
144
145 let output = Command::new("nix")
147 .args(["copy", "--from", &self.config.server_url, &store_path])
148 .output()?;
149
150 if output.status.success() {
151 debug!("Successfully pulled environment from cache");
152 Ok(PathBuf::from(&store_path))
153 } else {
154 let stderr = String::from_utf8_lossy(&output.stderr);
155 warn!("Failed to pull from cache: {}", stderr);
156 Err(NixError::EnvironmentNotCached(hash.hash.clone()))
157 }
158 }
159
160 async fn pull_http(&self, hash: &NixHash) -> Result<PathBuf> {
162 warn!("HTTP pull not implemented, falling back to CLI");
165 self.pull_cli(hash).await
166 }
167
168 pub async fn push_environment(&self, hash: &NixHash, store_path: &Path) -> Result<()> {
172 info!("Pushing environment {} to Attic", hash.short());
173
174 if self.config.use_cli {
175 self.push_cli(hash, store_path).await
176 } else {
177 self.push_http(hash, store_path).await
178 }
179 }
180
181 async fn push_cli(&self, _hash: &NixHash, store_path: &Path) -> Result<()> {
183 let attic_available = Command::new("attic")
185 .arg("--version")
186 .output()
187 .map(|o| o.status.success())
188 .unwrap_or(false);
189
190 if attic_available {
191 let output = Command::new("attic")
193 .args([
194 "push",
195 &self.config.cache_name,
196 &store_path.to_string_lossy(),
197 ])
198 .output()?;
199
200 if output.status.success() {
201 debug!("Successfully pushed to Attic cache");
202 Ok(())
203 } else {
204 let stderr = String::from_utf8_lossy(&output.stderr);
205 Err(NixError::AtticCommandFailed(stderr.to_string()))
206 }
207 } else {
208 let output = Command::new("nix")
210 .args([
211 "copy",
212 "--to",
213 &self.config.server_url,
214 &store_path.to_string_lossy(),
215 ])
216 .output()?;
217
218 if output.status.success() {
219 debug!("Successfully pushed using nix copy");
220 Ok(())
221 } else {
222 let stderr = String::from_utf8_lossy(&output.stderr);
223 Err(NixError::NixCommandFailed(stderr.to_string()))
224 }
225 }
226 }
227
228 async fn push_http(&self, _hash: &NixHash, _store_path: &Path) -> Result<()> {
230 warn!("HTTP push not implemented");
232 Err(NixError::AtticNotConfigured)
233 }
234
235 pub async fn build_and_cache(&self, flake_path: &Path) -> Result<(NixHash, PathBuf)> {
237 info!("Building environment from {:?}", flake_path);
238
239 let output = Command::new("nix")
241 .args(["build", "--json", "--no-link"])
242 .current_dir(flake_path)
243 .output()?;
244
245 if !output.status.success() {
246 let stderr = String::from_utf8_lossy(&output.stderr);
247 return Err(NixError::NixCommandFailed(stderr.to_string()));
248 }
249
250 #[derive(Deserialize)]
252 struct BuildOutput {
253 outputs: std::collections::HashMap<String, String>,
254 }
255
256 let outputs: Vec<BuildOutput> = serde_json::from_slice(&output.stdout)?;
257 let store_path = outputs
258 .first()
259 .and_then(|o| o.outputs.get("out"))
260 .ok_or_else(|| NixError::NixCommandFailed("No output path".to_string()))?;
261
262 let store_path = PathBuf::from(store_path);
263
264 let hash = crate::generate_environment_hash(flake_path)?;
266
267 self.push_environment(&hash, &store_path).await?;
269
270 Ok((hash, store_path))
271 }
272
273 pub async fn get_cache_info(&self) -> Result<CacheInfo> {
275 if self.config.use_cli {
276 let output = Command::new("attic")
278 .args(["cache", "info", &self.config.cache_name])
279 .output();
280
281 match output {
282 Ok(o) if o.status.success() => {
283 let stdout = String::from_utf8_lossy(&o.stdout);
284 Ok(CacheInfo {
285 name: self.config.cache_name.clone(),
286 server: self.config.server_url.clone(),
287 available: true,
288 info: Some(stdout.to_string()),
289 })
290 }
291 _ => Ok(CacheInfo {
292 name: self.config.cache_name.clone(),
293 server: self.config.server_url.clone(),
294 available: false,
295 info: None,
296 }),
297 }
298 } else {
299 let url = format!("{}/nix-cache-info", self.config.server_url);
301 match self.http_client.get(&url).send().await {
302 Ok(response) if response.status().is_success() => {
303 let body = response.text().await.unwrap_or_default();
304 Ok(CacheInfo {
305 name: self.config.cache_name.clone(),
306 server: self.config.server_url.clone(),
307 available: true,
308 info: Some(body),
309 })
310 }
311 _ => Ok(CacheInfo {
312 name: self.config.cache_name.clone(),
313 server: self.config.server_url.clone(),
314 available: false,
315 info: None,
316 }),
317 }
318 }
319 }
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct CacheInfo {
325 pub name: String,
327 pub server: String,
329 pub available: bool,
331 pub info: Option<String>,
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn test_attic_config_default() {
341 let config = AtticConfig::default();
342 assert!(!config.server_url.is_empty());
343 assert!(!config.cache_name.is_empty());
344 assert!(config.use_cli);
345 }
346
347 #[test]
348 fn test_attic_config_new() {
349 let config = AtticConfig::new("https://my-cache.example.com", "my-cache");
350 assert_eq!(config.server_url, "https://my-cache.example.com");
351 assert_eq!(config.cache_name, "my-cache");
352 }
353
354 #[test]
355 fn test_attic_config_with_token() {
356 let config = AtticConfig::default().with_token("secret-token");
357 assert_eq!(config.token, Some("secret-token".to_string()));
358 }
359
360 #[tokio::test]
361 async fn test_pull_nonexistent_hash_fails_gracefully() {
362 let client = AtticClient::from_env();
363 let fake_hash = NixHash::new(
364 "0000000000000000000000000000000000000000000000000000000000000000".to_string(),
365 crate::flake::HashSource::FlakeLock,
366 );
367
368 let cached = client.is_environment_cached(&fake_hash).await;
370 assert!(!cached);
371 }
372
373 #[tokio::test]
374 async fn test_get_cache_info() {
375 let client = AtticClient::from_env();
376 let info = client.get_cache_info().await;
377
378 assert!(info.is_ok());
380 }
381}