Skip to main content

nix_env_manager/
attic.rs

1//! Attic binary cache client
2//!
3//! Provides integration with Attic for caching Nix build artifacts.
4//! Attic is a self-hosted Nix binary cache server.
5
6use 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/// Attic configuration
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AtticConfig {
17    /// Attic server URL
18    pub server_url: String,
19    /// Cache name to use
20    pub cache_name: String,
21    /// Authentication token (optional for public caches)
22    pub token: Option<String>,
23    /// Whether to use CLI or HTTP API
24    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    /// Create a new config from environment variables
41    pub fn from_env() -> Self {
42        Self::default()
43    }
44
45    /// Create config for a specific server
46    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    /// Set authentication token
56    pub fn with_token(mut self, token: &str) -> Self {
57        self.token = Some(token.to_string());
58        self
59    }
60}
61
62/// Attic client for binary cache operations
63pub struct AtticClient {
64    config: AtticConfig,
65    http_client: reqwest::Client,
66}
67
68impl AtticClient {
69    /// Create a new Attic client
70    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    /// Create client from environment variables
83    pub fn from_env() -> Self {
84        Self::new(AtticConfig::from_env())
85    }
86
87    /// Check if an environment is cached
88    ///
89    /// # TDD: test_pull_nonexistent_hash_fails_gracefully
90    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    /// Check cache using CLI
99    async fn is_cached_cli(&self, hash: &NixHash) -> bool {
100        // Use nix path-info to check if path exists in cache
101        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    /// Check cache using HTTP API
114    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    /// Pull environment from cache
129    ///
130    /// Returns the path to the cached environment
131    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    /// Pull using Attic CLI
142    async fn pull_cli(&self, hash: &NixHash) -> Result<PathBuf> {
143        let store_path = format!("/nix/store/{}-aivcs-env", hash.short());
144
145        // Try to fetch from cache
146        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    /// Pull using HTTP API (placeholder)
161    async fn pull_http(&self, hash: &NixHash) -> Result<PathBuf> {
162        // HTTP-based pulling would require implementing NAR fetching
163        // For now, fall back to CLI
164        warn!("HTTP pull not implemented, falling back to CLI");
165        self.pull_cli(hash).await
166    }
167
168    /// Push environment to cache
169    ///
170    /// Takes the store path of a built environment and pushes it to the cache
171    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    /// Push using Attic CLI
182    async fn push_cli(&self, _hash: &NixHash, store_path: &Path) -> Result<()> {
183        // Check if attic CLI is available
184        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            // Use attic CLI
192            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            // Fall back to nix copy
209            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    /// Push using HTTP API (placeholder)
229    async fn push_http(&self, _hash: &NixHash, _store_path: &Path) -> Result<()> {
230        // HTTP-based pushing would require implementing NAR uploading
231        warn!("HTTP push not implemented");
232        Err(NixError::AtticNotConfigured)
233    }
234
235    /// Build and cache an environment from a flake
236    pub async fn build_and_cache(&self, flake_path: &Path) -> Result<(NixHash, PathBuf)> {
237        info!("Building environment from {:?}", flake_path);
238
239        // Build the flake
240        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        // Parse the output to get the store path
251        #[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        // Generate the environment hash
265        let hash = crate::generate_environment_hash(flake_path)?;
266
267        // Push to cache
268        self.push_environment(&hash, &store_path).await?;
269
270        Ok((hash, store_path))
271    }
272
273    /// Get cache statistics
274    pub async fn get_cache_info(&self) -> Result<CacheInfo> {
275        if self.config.use_cli {
276            // Try attic cache info
277            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            // HTTP health check
300            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/// Cache information
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct CacheInfo {
325    /// Cache name
326    pub name: String,
327    /// Server URL
328    pub server: String,
329    /// Whether the cache is available
330    pub available: bool,
331    /// Additional info from the server
332    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        // Should return false, not panic
369        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        // Should succeed even if cache is not available
379        assert!(info.is_ok());
380    }
381}