Skip to main content

systemprompt_runtime/
storage.rs

1//! File-storage composition: the profile's `storage:` section resolved to a
2//! [`FileStorage`] backend, with the credential sources the object-store
3//! backends need.
4//!
5//! The storage crate stays free of the security crate, so the key-file token
6//! source ([`ServiceAccountTokens`]) lives here and wraps
7//! `systemprompt_security::google::access_token`.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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/// Cloud Storage tokens minted from a service-account key held in secrets.
33#[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
147// Why: `/files` streams object bodies straight to the client, so a total
148// request deadline would cut a large or slowly-consumed download mid-body
149// after its Content-Length was sent; only connect and per-read stalls are
150// bounded.
151fn 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}