systemprompt_runtime/
storage.rs1use std::sync::Arc;
13use std::time::Duration;
14
15use async_trait::async_trait;
16use systemprompt_config::paths::AppPaths;
17use systemprompt_identifiers::{InstanceId, SecretName};
18use systemprompt_manifest::Secrets;
19use systemprompt_manifest::profile::{GcsCredentials, StorageBackend, StorageConfig};
20use systemprompt_security::google::{ServiceAccountKey, access_token};
21use systemprompt_storage::{
22 DEFAULT_METADATA_TOKEN_URL, FileStorageBackend, GcsError, GcsParams, GcsTokenSource,
23 MetadataServerTokens, S3Credentials, S3Params,
24};
25use systemprompt_traits::FileStorage;
26
27use crate::error::{RuntimeError, RuntimeResult};
28
29const OBJECT_STORE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
30const OBJECT_STORE_READ_TIMEOUT: Duration = Duration::from_secs(60);
31
32#[derive(Debug)]
34pub struct ServiceAccountTokens {
35 cache_key: String,
36 key: ServiceAccountKey,
37}
38
39impl ServiceAccountTokens {
40 #[must_use]
41 pub fn new(secret: &SecretName, key: ServiceAccountKey) -> Self {
42 Self {
43 cache_key: format!("storage-gcs:{secret}"),
44 key,
45 }
46 }
47}
48
49#[async_trait]
50impl GcsTokenSource for ServiceAccountTokens {
51 async fn bearer(&self) -> Result<String, GcsError> {
52 access_token(&self.cache_key, &self.key)
53 .await
54 .map_err(|e| GcsError::Token(Box::new(e)))
55 }
56}
57
58pub(crate) async fn init_file_storage(
59 storage: &StorageConfig,
60 app_paths: &AppPaths,
61 instance_id: &InstanceId,
62 secrets: &Secrets,
63) -> RuntimeResult<Arc<dyn FileStorage>> {
64 match storage.backend {
65 StorageBackend::Local => init_local(storage, app_paths, instance_id).await,
66 StorageBackend::Gcs => init_gcs(storage, secrets),
67 StorageBackend::S3 => init_s3(storage, secrets),
68 }
69}
70
71async fn init_local(
72 storage: &StorageConfig,
73 app_paths: &AppPaths,
74 instance_id: &InstanceId,
75) -> RuntimeResult<Arc<dyn FileStorage>> {
76 let root = app_paths.storage().root();
77 let report = systemprompt_storage::probe_shared_mount(root, instance_id)
78 .await
79 .map_err(|source| RuntimeError::StorageProbe {
80 path: root.to_path_buf(),
81 source,
82 })?;
83 if !report.write_read_ok {
84 return Err(RuntimeError::StorageReadBack {
85 path: root.to_path_buf(),
86 });
87 }
88 match (storage.shared, report.has_siblings()) {
89 (true, false) => tracing::warn!(
90 root = %root.display(),
91 "storage.shared is true but no other replica has marked this root; \
92 it may be a per-node disk"
93 ),
94 (false, true) => tracing::warn!(
95 root = %root.display(),
96 instances = ?report.instances,
97 "storage.shared is false but other replicas have marked this root; \
98 set storage.shared: true if it is a shared mount"
99 ),
100 _ => {},
101 }
102 Ok(systemprompt_storage::build_file_storage(
103 FileStorageBackend::Local {
104 root: root.to_path_buf(),
105 },
106 ))
107}
108
109fn init_gcs(storage: &StorageConfig, secrets: &Secrets) -> RuntimeResult<Arc<dyn FileStorage>> {
110 let http = object_store_http()?;
111 let tokens: Arc<dyn GcsTokenSource> = match storage.credentials.clone().unwrap_or_default() {
112 GcsCredentials::WorkloadIdentity => {
113 let endpoint = url::Url::parse(DEFAULT_METADATA_TOKEN_URL)
114 .map_err(RuntimeError::StorageEndpoint)?;
115 Arc::new(MetadataServerTokens::new(endpoint, http.clone()))
116 },
117 GcsCredentials::Secret(name) => {
118 let raw = secrets
119 .get(name.as_str())
120 .ok_or_else(|| RuntimeError::StorageCredentialMissing { name: name.clone() })?;
121 let key = serde_json::from_str::<ServiceAccountKey>(raw).map_err(|source| {
122 RuntimeError::StorageCredential {
123 name: name.clone(),
124 source,
125 }
126 })?;
127 Arc::new(ServiceAccountTokens::new(&name, key))
128 },
129 };
130 let bucket = required_bucket(storage)?;
131 let params = GcsParams::new(bucket, storage.prefix.clone(), storage.public_read)
132 .map_err(RuntimeError::StorageEndpoint)?;
133 tracing::info!(
134 bucket = %params.bucket,
135 prefix = params.prefix.as_deref().unwrap_or(""),
136 "file storage: Cloud Storage backend"
137 );
138 Ok(systemprompt_storage::build_file_storage(
139 FileStorageBackend::Gcs {
140 params: Box::new(params),
141 tokens,
142 http,
143 },
144 ))
145}
146
147fn object_store_http() -> RuntimeResult<reqwest::Client> {
152 reqwest::Client::builder()
153 .connect_timeout(OBJECT_STORE_CONNECT_TIMEOUT)
154 .read_timeout(OBJECT_STORE_READ_TIMEOUT)
155 .build()
156 .map_err(RuntimeError::StorageHttp)
157}
158
159fn required_bucket(storage: &StorageConfig) -> RuntimeResult<String> {
160 storage
161 .bucket
162 .clone()
163 .filter(|bucket| !bucket.is_empty())
164 .ok_or(RuntimeError::StorageBucketMissing)
165}
166
167fn init_s3(storage: &StorageConfig, secrets: &Secrets) -> RuntimeResult<Arc<dyn FileStorage>> {
168 let http = object_store_http()?;
169 let Some(GcsCredentials::Secret(name)) = storage.credentials.clone() else {
170 return Err(RuntimeError::StorageS3CredentialsRequired);
171 };
172 let raw = secrets
173 .get(name.as_str())
174 .ok_or_else(|| RuntimeError::StorageCredentialMissing { name: name.clone() })?;
175 let credentials = serde_json::from_str::<S3Credentials>(raw)
176 .map_err(|source| RuntimeError::StorageCredential { name, source })?;
177 let params = S3Params::new(
178 required_bucket(storage)?,
179 storage.region.clone(),
180 storage.endpoint.as_deref(),
181 )
182 .map_err(RuntimeError::StorageEndpoint)?
183 .with_prefix(storage.prefix.clone())
184 .with_path_style(storage.path_style)
185 .with_public_read(storage.public_read);
186 tracing::info!(
187 bucket = %params.bucket,
188 prefix = params.prefix.as_deref().unwrap_or(""),
189 endpoint = %params.endpoint,
190 path_style = params.path_style,
191 "file storage: S3-compatible backend"
192 );
193 Ok(systemprompt_storage::build_file_storage(
194 FileStorageBackend::S3 {
195 params: Box::new(params),
196 credentials,
197 http,
198 },
199 ))
200}