pub mod selection;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{fs, process::Command};
use crate::infra::io::durable::migrate_tier_entry;
use crate::platform::adapters::data::DataAdapter;
use crate::platform::error::PlatformError;
use crate::platform::model::{FileAdapterKind, ProjectFileLayout, slug_segment};
use crate::platform::services::project_config::ProjectConfigurationService;
use crate::zebfs::{FileBackend, FileStore, S3Config};
pub const S3_CREDENTIAL_KIND: &str = "s3";
pub trait FileAdapter: Send + Sync {
fn id(&self) -> &'static str;
fn initialize(&self) -> Result<(), PlatformError>;
fn ensure_project_layout(
&self,
owner: &str,
project: &str,
) -> Result<ProjectFileLayout, PlatformError>;
fn files_backend_selection(
&self,
owner: &str,
project: &str,
) -> Result<selection::FilesBackendSelection, PlatformError>;
fn select_files_backend(
&self,
owner: &str,
project: &str,
selection: &selection::FilesBackendSelection,
) -> Result<(), PlatformError>;
}
pub struct FilesystemFileAdapter {
root: PathBuf,
configs: Arc<ProjectConfigurationService>,
credentials: Option<Arc<dyn DataAdapter>>,
}
impl FilesystemFileAdapter {
pub fn new(root: PathBuf, configs: Arc<ProjectConfigurationService>) -> Self {
Self {
root,
configs,
credentials: None,
}
}
pub fn with_credentials(mut self, data: Arc<dyn DataAdapter>) -> Self {
self.credentials = Some(data);
self
}
fn s3_store(
&self,
owner: &str,
project: &str,
store_dir: &Path,
) -> Result<S3Config, PlatformError> {
let refuse = |message: String| PlatformError::new("PROJECT_FILES_BACKEND", message);
let selection = selection::read_selection(store_dir)?;
let Some(credential_id) = selection
.credential_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
else {
return Err(refuse(format!(
"spec.files.backend is '{}' but no credential is selected for it; choose an '{S3_CREDENTIAL_KIND}' credential on the Files page",
FileBackend::S3.as_str()
)));
};
let Some(data) = &self.credentials else {
return Err(refuse(format!(
"spec.files.backend is '{}' but this adapter has no credential store to resolve '{credential_id}' from",
FileBackend::S3.as_str()
)));
};
let credential = data
.get_project_credential(&slug_segment(owner), &slug_segment(project), credential_id)?
.ok_or_else(|| {
refuse(format!(
"the credential '{credential_id}' selected for this project's files does not exist"
))
})?;
if credential.kind != S3_CREDENTIAL_KIND {
return Err(refuse(format!(
"the credential '{credential_id}' selected for this project's files is of kind '{}', not '{S3_CREDENTIAL_KIND}'",
credential.kind
)));
}
crate::platform::services::credential::register_confidential(
owner,
project,
&credential.secret,
);
S3Config::from_credential(&credential.secret)
.map_err(|err| refuse(format!("credential '{credential_id}': {}", err.message)))
}
fn project_root(&self, owner: &str, project: &str) -> PathBuf {
self.root
.join(slug_segment(owner))
.join(slug_segment(project))
}
fn ensure_git_repo(repo_dir: &PathBuf, git_dir: &PathBuf) -> Result<(), PlatformError> {
if git_dir.exists() {
return Ok(());
}
let status = Command::new("git")
.arg("init")
.arg("-q")
.arg("--initial-branch=main")
.current_dir(repo_dir)
.status()
.map_err(|e| PlatformError::new("PLATFORM_GIT_INIT", e.to_string()))?;
if status.success() {
return Ok(());
}
Err(PlatformError::new(
"PLATFORM_GIT_INIT",
format!("git init failed with status {status}"),
))
}
}
impl FileAdapter for FilesystemFileAdapter {
fn id(&self) -> &'static str {
"file.filesystem"
}
fn initialize(&self) -> Result<(), PlatformError> {
std::fs::create_dir_all(&self.root)?;
Ok(())
}
fn files_backend_selection(
&self,
owner: &str,
project: &str,
) -> Result<selection::FilesBackendSelection, PlatformError> {
selection::read_selection(&self.project_root(owner, project).join("data").join("store"))
}
fn select_files_backend(
&self,
owner: &str,
project: &str,
selection: &selection::FilesBackendSelection,
) -> Result<(), PlatformError> {
selection::write_selection(
&self.project_root(owner, project).join("data").join("store"),
selection,
)
}
fn ensure_project_layout(
&self,
owner: &str,
project: &str,
) -> Result<ProjectFileLayout, PlatformError> {
let root = self.project_root(owner, project);
let data_dir = root.join("data");
let files_dir = root.join("files");
let repo_dir = root.join("repo");
let repo_git_dir = repo_dir.join(".git");
let project_config_file =
repo_dir.join(crate::contracts::kinds::PROJECT_CONFIGURATION_FILE);
let repo_layout = self.configs.project_layout(owner, project)?;
let file_backend = self.configs.project_file_backend(owner, project)?;
let file_store = match file_backend {
FileBackend::Zebfs => FileStore::Local(files_dir.clone()),
FileBackend::S3 => {
FileStore::S3(self.s3_store(owner, project, &data_dir.join("store"))?)
}
};
let resolved = ProjectFileLayout {
root,
data_dir,
files_dir,
repo_dir,
repo_git_dir,
project_config_file,
repo_layout,
file_store,
};
migrate_tier_entry(
&resolved.data_dir.join("runtime"),
&resolved.data_cache_dir(),
)
.map_err(|err| PlatformError::new("PLATFORM_DATA_TIER_MIGRATE", err.to_string()))?;
migrate_tier_entry(
&resolved.data_dir.join("nodes"),
&resolved.data_hub_nodes_dir(),
)
.map_err(|err| PlatformError::new("PLATFORM_DATA_TIER_MIGRATE", err.to_string()))?;
for dir in [
&resolved.root,
&resolved.data_dir,
&resolved.data_store_dir(),
&resolved.data_cache_dir(),
&resolved.data_cache_pipelines_dir(),
&resolved.data_cache_agent_docs_dir(),
&resolved.data_hub_dir(),
&resolved.data_hub_nodes_dir(),
&resolved.data_hub_rwe_libraries_dir(),
&resolved.data_recovery_dir(),
&resolved.data_logs_dir(),
&resolved.files_dir,
&resolved.repo_dir,
] {
fs::create_dir_all(dir)?;
}
Self::ensure_git_repo(&resolved.repo_dir, &resolved.repo_git_dir)?;
Ok(resolved)
}
}
pub fn build_file_adapter(
kind: FileAdapterKind,
data_root: PathBuf,
configs: Arc<ProjectConfigurationService>,
credentials: Option<Arc<dyn DataAdapter>>,
) -> Arc<dyn FileAdapter> {
match kind {
FileAdapterKind::Filesystem => {
let adapter = FilesystemFileAdapter::new(data_root.join("users"), configs);
Arc::new(match credentials {
Some(data) => adapter.with_credentials(data),
None => adapter,
})
}
}
}