mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-02 02:13:49 +00:00
SandboxProviderKind is now a validated string newtype instead of a closed enum. The bundled kinds (local, docker, daytona) keep their constants and a BundledProvider enum for the code paths that still dispatch on them; any other well-formed sandbox-driver kind name is accepted and names a plugin executable. EnvironmentProvider is gone: environment settings carry SandboxProviderKind directly, and is_clone_based is replaced by a workspace policy where local runs in a designated directory and every other provider clones. Server sandbox policy is keyed by kind. [server.sandbox.providers.<kind>] accepts the bundled kinds with `enabled` and any plugin kind with its launch settings (path, sha256, dev, args, env, inherit_env); bundled kinds reject the plugin keys and a kind with no entry is disabled. The OpenAPI schema, generated Rust and TypeScript clients, web settings pages, and docs follow. The environments table drops its provider CHECK enumeration in favour of the kind name rules so a plugin environment can be stored. Bundled-only code paths (run start, preflight, reconnect, terminal, details) now fail with an explicit message for a plugin kind until the driver construction function lands in the next step. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1579 lines
54 KiB
Rust
1579 lines
54 KiB
Rust
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "fabro-install: sync CLI install/uninstall bookkeeping; not on a Tokio hot path"
|
|
)]
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use fabro_config::{Storage, envfile};
|
|
use fabro_static::EnvVars;
|
|
use fabro_types::{BundledProvider, SandboxProviderKind};
|
|
use fabro_util::dev_token;
|
|
use fabro_vault::SecretStore;
|
|
pub use fabro_vault::SecretStoreWrite;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct PendingSettingsWrite<'a> {
|
|
pub path: &'a Path,
|
|
pub contents: &'a str,
|
|
pub previous_contents: Option<&'a str>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PendingDevTokenWrite {
|
|
path: PathBuf,
|
|
token: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PreparedInstallDevToken {
|
|
pub token: String,
|
|
pub write: Option<PendingDevTokenWrite>,
|
|
}
|
|
|
|
pub const OBJECT_STORE_MANAGED_COMMENT: &str = "managed by fabro-install: object-store";
|
|
pub const OBJECT_STORE_ACCESS_KEY_ID_ENV: &str = EnvVars::AWS_ACCESS_KEY_ID;
|
|
pub const OBJECT_STORE_SECRET_ACCESS_KEY_ENV: &str = EnvVars::AWS_SECRET_ACCESS_KEY;
|
|
|
|
/// Every GitHub-install secret name. Used to drop stale entries from
|
|
/// `server.env` whenever an install runs so a switch between Token and App
|
|
/// strategies leaves no residue behind.
|
|
pub const GITHUB_INSTALL_SECRET_KEYS: &[&str] = &[
|
|
EnvVars::GITHUB_TOKEN,
|
|
EnvVars::GITHUB_APP_PRIVATE_KEY,
|
|
EnvVars::GITHUB_APP_CLIENT_SECRET,
|
|
EnvVars::GITHUB_APP_WEBHOOK_SECRET,
|
|
];
|
|
|
|
/// GitHub App vault secret names cleared when switching back to the Token
|
|
/// strategy.
|
|
pub const GITHUB_APP_VAULT_KEYS: &[&str] = &[
|
|
EnvVars::GITHUB_APP_PRIVATE_KEY,
|
|
EnvVars::GITHUB_APP_CLIENT_SECRET,
|
|
EnvVars::GITHUB_APP_WEBHOOK_SECRET,
|
|
];
|
|
|
|
pub struct InstallPersistencePlan<'a> {
|
|
pub storage_dir: &'a Path,
|
|
pub settings_write: Option<PendingSettingsWrite<'a>>,
|
|
pub server_env_writes: Vec<envfile::EnvFileUpdate>,
|
|
pub server_env_removals: Vec<envfile::EnvFileRemoval>,
|
|
pub dev_token_write: Option<PendingDevTokenWrite>,
|
|
pub vault_writes: Vec<SecretStoreWrite>,
|
|
pub vault_removals: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum InstallListenConfig {
|
|
Tcp(String),
|
|
Unix(PathBuf),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum InstallObjectStoreCredentialMode {
|
|
Runtime,
|
|
AccessKey,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum InstallObjectStoreSelection {
|
|
Local {
|
|
root: String,
|
|
},
|
|
S3 {
|
|
bucket: String,
|
|
region: String,
|
|
credential_mode: InstallObjectStoreCredentialMode,
|
|
access_key_id: Option<String>,
|
|
secret_access_key: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum InstallSandboxSelection {
|
|
Docker,
|
|
Daytona,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct InstallObjectStoreEnvPlan {
|
|
pub writes: Vec<envfile::EnvFileUpdate>,
|
|
pub removals: Vec<envfile::EnvFileRemoval>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct PersistInstallOutputsError {
|
|
source: anyhow::Error,
|
|
pub server_env_applied: bool,
|
|
pub removed_env_keys: Vec<String>,
|
|
}
|
|
|
|
impl PersistInstallOutputsError {
|
|
fn new(source: anyhow::Error, server_env_applied: bool, removed_env_keys: Vec<String>) -> Self {
|
|
Self {
|
|
source,
|
|
server_env_applied,
|
|
removed_env_keys,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for PersistInstallOutputsError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
self.source.fmt(f)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PersistInstallOutputsError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
self.source.source()
|
|
}
|
|
}
|
|
|
|
pub fn default_web_url() -> String {
|
|
"http://127.0.0.1:32276".to_string()
|
|
}
|
|
|
|
pub async fn seed_environments_in_storage(storage_dir: &Path) -> Result<()> {
|
|
seed_default_environment_in_storage(storage_dir, SandboxProviderKind::DOCKER).await
|
|
}
|
|
|
|
pub async fn seed_default_environment_in_storage(
|
|
storage_dir: &Path,
|
|
provider: SandboxProviderKind,
|
|
) -> Result<()> {
|
|
let database = open_migrated_database(storage_dir).await?;
|
|
fabro_environment::seed_default_environment(database.pool(), provider).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn open_migrated_database(storage_dir: &Path) -> Result<fabro_db::Database> {
|
|
let database = fabro_db::Database::connect(Storage::new(storage_dir).sqlite_path()).await?;
|
|
database.migrate().await?;
|
|
Ok(database)
|
|
}
|
|
|
|
pub fn prepare_dev_token_write_for_install(path: &Path) -> Result<PreparedInstallDevToken> {
|
|
if let Some(token) = dev_token::read_dev_token_for_install(path)? {
|
|
return Ok(PreparedInstallDevToken { token, write: None });
|
|
}
|
|
|
|
let token = dev_token::generate_dev_token();
|
|
Ok(PreparedInstallDevToken {
|
|
token: token.clone(),
|
|
write: Some(PendingDevTokenWrite {
|
|
path: path.to_path_buf(),
|
|
token,
|
|
}),
|
|
})
|
|
}
|
|
|
|
fn root_table_mut(doc: &mut toml::Value) -> Result<&mut toml::Table> {
|
|
doc.as_table_mut()
|
|
.context("settings.toml root is not a table")
|
|
}
|
|
|
|
fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table> {
|
|
table
|
|
.entry(key.to_string())
|
|
.or_insert_with(|| toml::Value::Table(toml::Table::default()))
|
|
.as_table_mut()
|
|
.with_context(|| format!("settings.toml [{key}] is not a table"))
|
|
}
|
|
|
|
fn github_integration_table(doc: &mut toml::Value) -> Result<&mut toml::Table> {
|
|
let root = doc
|
|
.as_table_mut()
|
|
.context("settings.toml root is not a table")?;
|
|
let server = root
|
|
.entry("server")
|
|
.or_insert_with(|| toml::Value::Table(toml::Table::default()));
|
|
let server_table = server
|
|
.as_table_mut()
|
|
.context("settings.toml [server] is not a table")?;
|
|
let integrations = server_table
|
|
.entry("integrations")
|
|
.or_insert_with(|| toml::Value::Table(toml::Table::default()));
|
|
let integrations_table = integrations
|
|
.as_table_mut()
|
|
.context("settings.toml [server.integrations] is not a table")?;
|
|
let github = integrations_table
|
|
.entry("github")
|
|
.or_insert_with(|| toml::Value::Table(toml::Table::default()));
|
|
github
|
|
.as_table_mut()
|
|
.context("settings.toml [server.integrations.github] is not a table")
|
|
}
|
|
|
|
fn set_server_listen(doc: &mut toml::Value, listen_config: &InstallListenConfig) -> Result<()> {
|
|
let root = root_table_mut(doc)?;
|
|
let server = ensure_table(root, "server")?;
|
|
let mut listen = toml::Table::default();
|
|
match listen_config {
|
|
InstallListenConfig::Tcp(address) => {
|
|
listen.insert("type".to_string(), toml::Value::String("tcp".to_string()));
|
|
listen.insert("address".to_string(), toml::Value::String(address.clone()));
|
|
}
|
|
InstallListenConfig::Unix(path) => {
|
|
listen.insert("type".to_string(), toml::Value::String("unix".to_string()));
|
|
listen.insert(
|
|
"path".to_string(),
|
|
toml::Value::String(path.display().to_string()),
|
|
);
|
|
}
|
|
}
|
|
server.insert("listen".to_string(), toml::Value::Table(listen));
|
|
Ok(())
|
|
}
|
|
|
|
fn set_cli_target_http(doc: &mut toml::Value, web_url: &str) -> Result<()> {
|
|
let root = root_table_mut(doc)?;
|
|
let cli = ensure_table(root, "cli")?;
|
|
let mut target = toml::Table::default();
|
|
target.insert("type".to_string(), toml::Value::String("http".to_string()));
|
|
target.insert("url".to_string(), toml::Value::String(web_url.to_string()));
|
|
cli.insert("target".to_string(), toml::Value::Table(target));
|
|
Ok(())
|
|
}
|
|
|
|
pub fn merge_server_settings(
|
|
doc: &mut toml::Value,
|
|
web_url: &str,
|
|
listen_config: &InstallListenConfig,
|
|
) -> Result<()> {
|
|
{
|
|
let root = root_table_mut(doc)?;
|
|
root.insert("_version".to_string(), toml::Value::Integer(1));
|
|
|
|
let server = ensure_table(root, "server")?;
|
|
|
|
let api = ensure_table(server, "api")?;
|
|
api.insert(
|
|
"url".to_string(),
|
|
toml::Value::String(format!("{web_url}/api/v1")),
|
|
);
|
|
}
|
|
|
|
set_server_listen(doc, listen_config)?;
|
|
|
|
let root = root_table_mut(doc)?;
|
|
let server = ensure_table(root, "server")?;
|
|
|
|
let web = ensure_table(server, "web")?;
|
|
web.insert("enabled".to_string(), toml::Value::Boolean(true));
|
|
web.insert("url".to_string(), toml::Value::String(web_url.to_string()));
|
|
|
|
let auth = ensure_table(server, "auth")?;
|
|
auth.insert(
|
|
"methods".to_string(),
|
|
toml::Value::Array(vec![toml::Value::String("dev-token".to_string())]),
|
|
);
|
|
|
|
set_cli_target_http(doc, web_url)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn write_token_settings(doc: &mut toml::Value) -> Result<()> {
|
|
if let Some(server) = doc.get_mut("server").and_then(toml::Value::as_table_mut) {
|
|
if let Some(auth) = server.get_mut("auth").and_then(toml::Value::as_table_mut) {
|
|
if let Some(methods) = auth.get_mut("methods").and_then(toml::Value::as_array_mut) {
|
|
methods.retain(|value| value.as_str() != Some("github"));
|
|
if methods.is_empty() {
|
|
methods.push(toml::Value::String("dev-token".to_string()));
|
|
}
|
|
}
|
|
auth.remove("github");
|
|
}
|
|
}
|
|
|
|
let github = github_integration_table(doc)?;
|
|
github.insert("strategy".into(), toml::Value::String("token".to_string()));
|
|
github.remove("app_id");
|
|
github.remove("slug");
|
|
github.remove("client_id");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn write_github_app_settings(
|
|
doc: &mut toml::Value,
|
|
app_id: &str,
|
|
slug: &str,
|
|
client_id: &str,
|
|
allowed_usernames: &[String],
|
|
) -> Result<()> {
|
|
anyhow::ensure!(
|
|
!allowed_usernames.is_empty(),
|
|
"GitHub App install requires at least one allowed GitHub username"
|
|
);
|
|
|
|
let root = root_table_mut(doc)?;
|
|
let server = ensure_table(root, "server")?;
|
|
let auth = ensure_table(server, "auth")?;
|
|
let methods = auth
|
|
.entry("methods".to_string())
|
|
.or_insert_with(|| toml::Value::Array(Vec::new()))
|
|
.as_array_mut()
|
|
.context("settings.toml [server.auth].methods is not an array")?;
|
|
if !methods.iter().any(|value| value.as_str() == Some("github")) {
|
|
methods.push(toml::Value::String("github".to_string()));
|
|
}
|
|
methods.retain(|value| value.as_str() != Some("dev-token"));
|
|
let github_auth = ensure_table(auth, "github")?;
|
|
github_auth.insert(
|
|
"allowed_usernames".to_string(),
|
|
toml::Value::Array(
|
|
allowed_usernames
|
|
.iter()
|
|
.cloned()
|
|
.map(toml::Value::String)
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
let github = github_integration_table(doc)?;
|
|
github.insert("strategy".into(), toml::Value::String("app".to_string()));
|
|
github.insert("app_id".into(), toml::Value::String(app_id.to_string()));
|
|
github.insert("slug".into(), toml::Value::String(slug.to_string()));
|
|
github.insert(
|
|
"client_id".into(),
|
|
toml::Value::String(client_id.to_string()),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn object_store_env_removals() -> Vec<envfile::EnvFileRemoval> {
|
|
[
|
|
OBJECT_STORE_ACCESS_KEY_ID_ENV,
|
|
OBJECT_STORE_SECRET_ACCESS_KEY_ENV,
|
|
]
|
|
.into_iter()
|
|
.map(|key| envfile::EnvFileRemoval {
|
|
key: key.to_string(),
|
|
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn write_s3_store_settings(
|
|
server: &mut toml::Table,
|
|
domain: &str,
|
|
prefix: &str,
|
|
bucket: &str,
|
|
region: &str,
|
|
) -> Result<()> {
|
|
let store = ensure_table(server, domain)?;
|
|
store.insert(
|
|
"provider".to_string(),
|
|
toml::Value::String("s3".to_string()),
|
|
);
|
|
store.insert(
|
|
"prefix".to_string(),
|
|
toml::Value::String(prefix.to_string()),
|
|
);
|
|
let s3 = ensure_table(store, "s3")?;
|
|
s3.insert(
|
|
"bucket".to_string(),
|
|
toml::Value::String(bucket.to_string()),
|
|
);
|
|
s3.insert(
|
|
"region".to_string(),
|
|
toml::Value::String(region.to_string()),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn write_local_store_settings(
|
|
server: &mut toml::Table,
|
|
domain: &str,
|
|
prefix: &str,
|
|
root: &str,
|
|
) -> Result<()> {
|
|
let store = ensure_table(server, domain)?;
|
|
store.insert(
|
|
"provider".to_string(),
|
|
toml::Value::String("local".to_string()),
|
|
);
|
|
store.insert(
|
|
"prefix".to_string(),
|
|
toml::Value::String(prefix.to_string()),
|
|
);
|
|
let local = ensure_table(store, "local")?;
|
|
local.insert("root".to_string(), toml::Value::String(root.to_string()));
|
|
Ok(())
|
|
}
|
|
|
|
pub fn write_object_store_settings(
|
|
doc: &mut toml::Value,
|
|
selection: &InstallObjectStoreSelection,
|
|
) -> Result<InstallObjectStoreEnvPlan> {
|
|
match selection {
|
|
InstallObjectStoreSelection::Local { root } => {
|
|
let root = root.trim();
|
|
if !root.is_empty() {
|
|
let root_table = root_table_mut(doc)?;
|
|
let server = ensure_table(root_table, "server")?;
|
|
write_local_store_settings(server, "artifacts", "artifacts", root)?;
|
|
write_local_store_settings(server, "slatedb", "slatedb", root)?;
|
|
}
|
|
Ok(InstallObjectStoreEnvPlan {
|
|
writes: Vec::new(),
|
|
removals: object_store_env_removals(),
|
|
})
|
|
}
|
|
InstallObjectStoreSelection::S3 {
|
|
bucket,
|
|
region,
|
|
credential_mode,
|
|
access_key_id,
|
|
secret_access_key,
|
|
} => {
|
|
let bucket = bucket.trim();
|
|
anyhow::ensure!(!bucket.is_empty(), "bucket is required");
|
|
let region = region.trim();
|
|
anyhow::ensure!(!region.is_empty(), "region is required");
|
|
|
|
let root = root_table_mut(doc)?;
|
|
let server = ensure_table(root, "server")?;
|
|
write_s3_store_settings(server, "artifacts", "artifacts", bucket, region)?;
|
|
write_s3_store_settings(server, "slatedb", "slatedb", bucket, region)?;
|
|
|
|
let removals = object_store_env_removals();
|
|
let writes = match credential_mode {
|
|
InstallObjectStoreCredentialMode::Runtime => Vec::new(),
|
|
InstallObjectStoreCredentialMode::AccessKey => {
|
|
let access_key_id = access_key_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.context("access_key_id is required for manual credentials")?;
|
|
let secret_access_key = secret_access_key
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.context("secret_access_key is required for manual credentials")?;
|
|
vec![
|
|
envfile::EnvFileUpdate {
|
|
key: OBJECT_STORE_ACCESS_KEY_ID_ENV.to_string(),
|
|
value: access_key_id.to_string(),
|
|
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
|
|
},
|
|
envfile::EnvFileUpdate {
|
|
key: OBJECT_STORE_SECRET_ACCESS_KEY_ENV.to_string(),
|
|
value: secret_access_key.to_string(),
|
|
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
|
|
},
|
|
]
|
|
}
|
|
};
|
|
|
|
Ok(InstallObjectStoreEnvPlan { writes, removals })
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_sandbox_provider_policy(
|
|
server: &mut toml::Table,
|
|
selection: InstallSandboxSelection,
|
|
allow_local: bool,
|
|
) -> Result<()> {
|
|
let sandbox = ensure_table(server, "sandbox")?;
|
|
let providers = ensure_table(sandbox, "providers")?;
|
|
for provider in SandboxProviderKind::bundled_kinds().filter_map(|kind| kind.bundled()) {
|
|
// Only enable the providers the operator configured or allowed in the
|
|
// install wizard: the chosen runtime, plus local when allowed.
|
|
let enabled = match provider {
|
|
BundledProvider::Local => allow_local,
|
|
BundledProvider::Docker => selection == InstallSandboxSelection::Docker,
|
|
BundledProvider::Daytona => selection == InstallSandboxSelection::Daytona,
|
|
};
|
|
let entry = ensure_table(providers, &provider.to_string())?;
|
|
entry.insert("enabled".to_string(), toml::Value::Boolean(enabled));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn write_sandbox_settings(
|
|
doc: &mut toml::Value,
|
|
selection: InstallSandboxSelection,
|
|
allow_local: bool,
|
|
) -> Result<()> {
|
|
let root = root_table_mut(doc)?;
|
|
let run = ensure_table(root, "run")?;
|
|
let environment = ensure_table(run, "environment")?;
|
|
environment.insert("id".to_string(), toml::Value::String("default".to_string()));
|
|
|
|
let server = ensure_table(root, "server")?;
|
|
write_sandbox_provider_policy(server, selection, allow_local)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn restore_optional_file(path: &Path, previous_contents: Option<&str>) -> Result<()> {
|
|
match previous_contents {
|
|
Some(contents) => {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("creating directory {}", parent.display()))?;
|
|
}
|
|
std::fs::write(path, contents)
|
|
.with_context(|| format!("restoring {}", path.display()))?;
|
|
}
|
|
None => match std::fs::remove_file(path) {
|
|
Ok(()) => {}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(err) => {
|
|
return Err(anyhow::Error::new(err).context(format!("removing {}", path.display())));
|
|
}
|
|
},
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn rollback_dev_token_write(write: &PendingDevTokenWrite) -> Result<()> {
|
|
match std::fs::remove_file(&write.path) {
|
|
Ok(()) => {}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(err) => {
|
|
return Err(anyhow::Error::new(err)
|
|
.context(format!("removing dev token {}", write.path.display())));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn write_pending_dev_token(write: &PendingDevTokenWrite) -> Result<()> {
|
|
dev_token::write_dev_token(&write.path, &write.token)
|
|
.with_context(|| format!("writing dev token {}", write.path.display()))
|
|
}
|
|
|
|
fn persist_server_env_secrets(
|
|
storage_dir: &Path,
|
|
writes: &[envfile::EnvFileUpdate],
|
|
removals: &[envfile::EnvFileRemoval],
|
|
) -> Result<envfile::EnvFileUpdateReport> {
|
|
if writes.is_empty() && removals.is_empty() {
|
|
return Ok(envfile::EnvFileUpdateReport {
|
|
entries: std::collections::HashMap::new(),
|
|
removed_keys: Vec::new(),
|
|
});
|
|
}
|
|
|
|
let env_path = Storage::new(storage_dir).runtime_directory().env_path();
|
|
envfile::update_env_file_with_report(
|
|
&env_path,
|
|
removals.iter().cloned(),
|
|
writes.iter().cloned(),
|
|
)
|
|
.with_context(|| format!("updating server env file {}", env_path.display()))
|
|
}
|
|
|
|
async fn persist_vault_secrets_direct(
|
|
storage_dir: &Path,
|
|
secrets: &[SecretStoreWrite],
|
|
removals: &[String],
|
|
) -> Result<()> {
|
|
if secrets.is_empty() && removals.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let storage = Storage::new(storage_dir);
|
|
SecretStore::open(storage.sqlite_path(), storage.secrets_path())
|
|
.await?
|
|
.apply(removals, secrets)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn direct_persistence_error(err: anyhow::Error, rollback_failures: &[String]) -> anyhow::Error {
|
|
if rollback_failures.is_empty() {
|
|
err.context("persisting install outputs directly")
|
|
} else {
|
|
err.context(format!(
|
|
"persisting install outputs directly; rollback failures: {}",
|
|
rollback_failures.join("; ")
|
|
))
|
|
}
|
|
}
|
|
|
|
fn rollback_direct_persistence(
|
|
settings_write: Option<&PendingSettingsWrite<'_>>,
|
|
dev_token_write: Option<&PendingDevTokenWrite>,
|
|
) -> Vec<String> {
|
|
let mut failures = Vec::new();
|
|
|
|
if let Some(write) = settings_write {
|
|
if let Err(err) = restore_optional_file(write.path, write.previous_contents) {
|
|
failures.push(err.to_string());
|
|
}
|
|
}
|
|
if let Some(write) = dev_token_write {
|
|
if let Err(err) = rollback_dev_token_write(write) {
|
|
failures.push(err.to_string());
|
|
}
|
|
}
|
|
|
|
failures
|
|
}
|
|
|
|
impl InstallPersistencePlan<'_> {
|
|
fn persist_files(&self) -> std::result::Result<Vec<String>, PersistInstallOutputsError> {
|
|
let server_env_report = persist_server_env_secrets(
|
|
self.storage_dir,
|
|
&self.server_env_writes,
|
|
&self.server_env_removals,
|
|
)
|
|
.map_err(|err| PersistInstallOutputsError::new(err, false, Vec::new()))?;
|
|
let removed_env_keys = server_env_report.removed_keys;
|
|
|
|
if let Some(write) = self.settings_write.as_ref() {
|
|
if let Some(parent) = write.path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("creating settings directory {}", parent.display()))
|
|
.map_err(|err| {
|
|
PersistInstallOutputsError::new(err, true, removed_env_keys.clone())
|
|
})?;
|
|
}
|
|
std::fs::write(write.path, write.contents)
|
|
.with_context(|| format!("writing settings file {}", write.path.display()))
|
|
.map_err(|err| {
|
|
PersistInstallOutputsError::new(err, true, removed_env_keys.clone())
|
|
})?;
|
|
}
|
|
|
|
if let Some(write) = self.dev_token_write.as_ref() {
|
|
if let Err(err) = write_pending_dev_token(write) {
|
|
let rollback_failures =
|
|
rollback_direct_persistence(self.settings_write.as_ref(), Some(write));
|
|
let error = direct_persistence_error(err, &rollback_failures);
|
|
return Err(PersistInstallOutputsError::new(
|
|
error,
|
|
true,
|
|
removed_env_keys,
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(removed_env_keys)
|
|
}
|
|
|
|
fn secret_persistence_error(
|
|
&self,
|
|
err: anyhow::Error,
|
|
removed_env_keys: Vec<String>,
|
|
) -> PersistInstallOutputsError {
|
|
let rollback_failures = rollback_direct_persistence(
|
|
self.settings_write.as_ref(),
|
|
self.dev_token_write.as_ref(),
|
|
);
|
|
let error = direct_persistence_error(err, &rollback_failures);
|
|
PersistInstallOutputsError::new(error, true, removed_env_keys)
|
|
}
|
|
|
|
pub async fn persist_direct(&self) -> std::result::Result<(), PersistInstallOutputsError> {
|
|
let removed_env_keys = self.persist_files()?;
|
|
|
|
if let Err(err) =
|
|
persist_vault_secrets_direct(self.storage_dir, &self.vault_writes, &self.vault_removals)
|
|
.await
|
|
{
|
|
return Err(self.secret_persistence_error(err, removed_env_keys));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub async fn persist_install_outputs_direct(
|
|
storage_dir: &Path,
|
|
server_env_writes: &[envfile::EnvFileUpdate],
|
|
server_env_removals: &[envfile::EnvFileRemoval],
|
|
vault_secrets: &[SecretStoreWrite],
|
|
settings_write: Option<&PendingSettingsWrite<'_>>,
|
|
) -> std::result::Result<(), PersistInstallOutputsError> {
|
|
InstallPersistencePlan {
|
|
storage_dir,
|
|
settings_write: settings_write.copied(),
|
|
server_env_writes: server_env_writes.to_vec(),
|
|
server_env_removals: server_env_removals.to_vec(),
|
|
dev_token_write: None,
|
|
vault_writes: vault_secrets.to_vec(),
|
|
vault_removals: Vec::new(),
|
|
}
|
|
.persist_direct()
|
|
.await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
|
|
use fabro_config::{ServerSettingsBuilder, Storage, UserSettingsBuilder, envfile};
|
|
use fabro_types::settings::cli::CliTargetSettings;
|
|
use fabro_util::dev_token::{
|
|
generate_dev_token, read_dev_token_file, validate_dev_token_format, write_dev_token,
|
|
};
|
|
use fabro_vault::{SecretType as VaultSecretType, Vault};
|
|
|
|
async fn load_secret_snapshot(storage: &Storage) -> Vault {
|
|
SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path())
|
|
.await
|
|
.unwrap()
|
|
.into_vault()
|
|
}
|
|
|
|
use super::{
|
|
InstallListenConfig, InstallObjectStoreCredentialMode, InstallObjectStoreSelection,
|
|
InstallPersistencePlan, InstallSandboxSelection, OBJECT_STORE_ACCESS_KEY_ID_ENV,
|
|
OBJECT_STORE_MANAGED_COMMENT, OBJECT_STORE_SECRET_ACCESS_KEY_ENV, PendingSettingsWrite,
|
|
SecretStore, SecretStoreWrite, default_web_url, merge_server_settings,
|
|
persist_install_outputs_direct, prepare_dev_token_write_for_install, set_cli_target_http,
|
|
set_server_listen, write_github_app_settings, write_object_store_settings,
|
|
write_sandbox_settings,
|
|
};
|
|
|
|
fn format_config_toml() -> String {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
merge_server_settings(
|
|
&mut doc,
|
|
&default_web_url(),
|
|
&InstallListenConfig::Tcp("127.0.0.1:32276".to_string()),
|
|
)
|
|
.expect("default server config should be valid");
|
|
toml::to_string_pretty(&doc).expect("default server config should serialize")
|
|
}
|
|
|
|
#[test]
|
|
fn config_toml_has_auth_strategies() {
|
|
use fabro_types::settings::ServerAuthMethod;
|
|
|
|
let toml_str = format_config_toml();
|
|
let cfg =
|
|
ServerSettingsBuilder::from_toml(&toml_str).expect("generated config should resolve");
|
|
assert_eq!(cfg.server.auth.methods, vec![ServerAuthMethod::DevToken]);
|
|
}
|
|
|
|
#[test]
|
|
fn config_toml_omits_server_logging_destination() {
|
|
let toml_str = format_config_toml();
|
|
let cfg: toml::Value = toml::from_str(&toml_str).expect("generated config should parse");
|
|
let destination = cfg
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("logging"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|logging| logging.get("destination"));
|
|
|
|
assert_eq!(destination, None);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_server_settings_preserves_existing_top_level_sections() {
|
|
let mut doc: toml::Value = toml::from_str(
|
|
r#"
|
|
_version = 1
|
|
|
|
[project]
|
|
name = "custom"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
merge_server_settings(
|
|
&mut doc,
|
|
&default_web_url(),
|
|
&InstallListenConfig::Tcp("127.0.0.1:32276".to_string()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
doc.get("project")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|project| project.get("name"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("custom")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_server_settings_replaces_stale_unix_cli_target_fields() {
|
|
let mut doc: toml::Value = toml::from_str(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "unix"
|
|
path = "/tmp/fabro.sock"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
merge_server_settings(
|
|
&mut doc,
|
|
&default_web_url(),
|
|
&InstallListenConfig::Tcp("127.0.0.1:32276".to_string()),
|
|
)
|
|
.unwrap();
|
|
|
|
let target = doc
|
|
.get("cli")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|cli| cli.get("target"))
|
|
.and_then(toml::Value::as_table)
|
|
.expect("cli.target should be a table");
|
|
assert_eq!(
|
|
target.get("type").and_then(toml::Value::as_str),
|
|
Some("http")
|
|
);
|
|
assert_eq!(
|
|
target.get("url").and_then(toml::Value::as_str),
|
|
Some(default_web_url().as_str())
|
|
);
|
|
assert!(!target.contains_key("path"));
|
|
|
|
let toml_str = toml::to_string_pretty(&doc).expect("settings should serialize");
|
|
let settings = UserSettingsBuilder::from_toml(&toml_str).expect("settings should resolve");
|
|
assert!(matches!(
|
|
settings.cli.target,
|
|
Some(CliTargetSettings::Http { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn set_cli_target_http_replaces_the_full_tagged_enum_table() {
|
|
let mut doc: toml::Value = toml::from_str(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "unix"
|
|
path = "/tmp/fabro.sock"
|
|
stale = "remove-me"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
set_cli_target_http(&mut doc, &default_web_url()).unwrap();
|
|
|
|
let target = doc
|
|
.get("cli")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|cli| cli.get("target"))
|
|
.and_then(toml::Value::as_table)
|
|
.expect("cli.target should be a table");
|
|
assert_eq!(target.len(), 2);
|
|
assert_eq!(
|
|
target.get("type").and_then(toml::Value::as_str),
|
|
Some("http")
|
|
);
|
|
assert_eq!(
|
|
target.get("url").and_then(toml::Value::as_str),
|
|
Some(default_web_url().as_str())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_server_listen_replaces_stale_variant_fields_in_both_directions() {
|
|
let mut doc: toml::Value = toml::from_str(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.listen]
|
|
type = "tcp"
|
|
address = "127.0.0.1:32276"
|
|
path = "/tmp/stale.sock"
|
|
stale = "remove-me"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
set_server_listen(
|
|
&mut doc,
|
|
&InstallListenConfig::Unix(PathBuf::from("/tmp/fabro.sock")),
|
|
)
|
|
.unwrap();
|
|
let listen = doc
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("listen"))
|
|
.and_then(toml::Value::as_table)
|
|
.expect("server.listen should be a table");
|
|
assert_eq!(listen.len(), 2);
|
|
assert_eq!(
|
|
listen.get("type").and_then(toml::Value::as_str),
|
|
Some("unix")
|
|
);
|
|
assert_eq!(
|
|
listen.get("path").and_then(toml::Value::as_str),
|
|
Some("/tmp/fabro.sock")
|
|
);
|
|
|
|
set_server_listen(
|
|
&mut doc,
|
|
&InstallListenConfig::Tcp("0.0.0.0:32276".to_string()),
|
|
)
|
|
.unwrap();
|
|
let listen = doc
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("listen"))
|
|
.and_then(toml::Value::as_table)
|
|
.expect("server.listen should be a table");
|
|
assert_eq!(listen.len(), 2);
|
|
assert_eq!(
|
|
listen.get("type").and_then(toml::Value::as_str),
|
|
Some("tcp")
|
|
);
|
|
assert_eq!(
|
|
listen.get("address").and_then(toml::Value::as_str),
|
|
Some("0.0.0.0:32276")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn write_github_app_settings_uses_server_integrations_github() {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
merge_server_settings(
|
|
&mut doc,
|
|
&default_web_url(),
|
|
&InstallListenConfig::Tcp("127.0.0.1:32276".to_string()),
|
|
)
|
|
.unwrap();
|
|
|
|
write_github_app_settings(&mut doc, "123", "fabro-app", "client-id", &[
|
|
"brynary".to_string()
|
|
])
|
|
.unwrap();
|
|
|
|
let github = doc
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("integrations"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|integrations| integrations.get("github"))
|
|
.and_then(toml::Value::as_table)
|
|
.expect("server.integrations.github should exist");
|
|
|
|
assert_eq!(
|
|
github.get("strategy").and_then(toml::Value::as_str),
|
|
Some("app")
|
|
);
|
|
assert_eq!(
|
|
github.get("app_id").and_then(toml::Value::as_str),
|
|
Some("123")
|
|
);
|
|
assert_eq!(
|
|
github.get("slug").and_then(toml::Value::as_str),
|
|
Some("fabro-app")
|
|
);
|
|
assert_eq!(
|
|
github.get("client_id").and_then(toml::Value::as_str),
|
|
Some("client-id")
|
|
);
|
|
|
|
let methods = doc
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("auth"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|auth| auth.get("methods"))
|
|
.and_then(toml::Value::as_array)
|
|
.expect("server.auth.methods should exist");
|
|
assert_eq!(
|
|
methods
|
|
.iter()
|
|
.map(|value| value.as_str().expect("auth method should be a string"))
|
|
.collect::<Vec<_>>(),
|
|
vec!["github"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_install_outputs_direct_restores_settings_and_vault_on_secret_failure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = Storage::new(dir.path());
|
|
let settings_path = dir.path().join("settings.toml");
|
|
std::fs::write(&settings_path, "_version = 1\n[server]\n").unwrap();
|
|
let vault_path = storage.secrets_path();
|
|
let mut vault = Vault::load(vault_path.clone()).unwrap();
|
|
vault
|
|
.set("EXISTING_SECRET", "keep", VaultSecretType::Token, None)
|
|
.unwrap();
|
|
|
|
let result = persist_install_outputs_direct(
|
|
dir.path(),
|
|
&[envfile::EnvFileUpdate {
|
|
key: "SESSION_SECRET".to_string(),
|
|
value: "session".to_string(),
|
|
comment: None,
|
|
}],
|
|
&[],
|
|
&[SecretStoreWrite {
|
|
name: "bad-secret-name".to_string(),
|
|
value: "boom".to_string(),
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
}],
|
|
Some(&PendingSettingsWrite {
|
|
path: &settings_path,
|
|
contents: "_version = 1\n[server]\nfoo = \"bar\"\n",
|
|
previous_contents: Some("_version = 1\n[server]\n"),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert_eq!(
|
|
std::fs::read_to_string(&settings_path).unwrap(),
|
|
"_version = 1\n[server]\n"
|
|
);
|
|
|
|
let restored = load_secret_snapshot(&storage).await;
|
|
assert_eq!(restored.get("EXISTING_SECRET"), Some("keep"));
|
|
assert_eq!(restored.get("bad-secret-name"), None);
|
|
|
|
let server_env = envfile::read_env_file(&storage.runtime_directory().env_path()).unwrap();
|
|
assert_eq!(
|
|
server_env.get("SESSION_SECRET").map(String::as_str),
|
|
Some("session")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_persistence_plan_direct_writes_and_removes_vault_secrets() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = Storage::new(dir.path());
|
|
let mut vault = Vault::load(storage.secrets_path()).unwrap();
|
|
vault
|
|
.set("REMOVE_ME", "old", VaultSecretType::Token, None)
|
|
.unwrap();
|
|
vault
|
|
.set("KEEP_ME", "keep", VaultSecretType::Token, None)
|
|
.unwrap();
|
|
|
|
InstallPersistencePlan {
|
|
storage_dir: dir.path(),
|
|
settings_write: None,
|
|
server_env_writes: Vec::new(),
|
|
server_env_removals: Vec::new(),
|
|
dev_token_write: None,
|
|
vault_writes: vec![SecretStoreWrite {
|
|
name: "NEW_SECRET".to_string(),
|
|
value: "new".to_string(),
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
}],
|
|
vault_removals: vec!["REMOVE_ME".to_string()],
|
|
}
|
|
.persist_direct()
|
|
.await
|
|
.unwrap();
|
|
|
|
let vault = load_secret_snapshot(&storage).await;
|
|
assert_eq!(vault.get("REMOVE_ME"), None);
|
|
assert_eq!(vault.get("KEEP_ME"), Some("keep"));
|
|
assert_eq!(vault.get("NEW_SECRET"), Some("new"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_persistence_plan_direct_restores_settings_and_vault_on_secret_failure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = Storage::new(dir.path());
|
|
let settings_path = dir.path().join("settings.toml");
|
|
std::fs::write(&settings_path, "_version = 1\n[server]\n").unwrap();
|
|
let vault_path = storage.secrets_path();
|
|
let mut vault = Vault::load(vault_path.clone()).unwrap();
|
|
vault
|
|
.set("REMOVE_ME", "old", VaultSecretType::Token, None)
|
|
.unwrap();
|
|
|
|
let result = InstallPersistencePlan {
|
|
storage_dir: dir.path(),
|
|
settings_write: Some(PendingSettingsWrite {
|
|
path: &settings_path,
|
|
contents: "_version = 1\n[server]\nfoo = \"bar\"\n",
|
|
previous_contents: Some("_version = 1\n[server]\n"),
|
|
}),
|
|
server_env_writes: vec![envfile::EnvFileUpdate {
|
|
key: "SESSION_SECRET".to_string(),
|
|
value: "session".to_string(),
|
|
comment: None,
|
|
}],
|
|
server_env_removals: Vec::new(),
|
|
dev_token_write: None,
|
|
vault_writes: vec![SecretStoreWrite {
|
|
name: "bad-secret-name".to_string(),
|
|
value: "boom".to_string(),
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
}],
|
|
vault_removals: vec!["REMOVE_ME".to_string()],
|
|
}
|
|
.persist_direct()
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert_eq!(
|
|
std::fs::read_to_string(&settings_path).unwrap(),
|
|
"_version = 1\n[server]\n"
|
|
);
|
|
let vault = load_secret_snapshot(&storage).await;
|
|
assert_eq!(vault.get("REMOVE_ME"), Some("old"));
|
|
assert_eq!(vault.get("bad-secret-name"), None);
|
|
let server_env = envfile::read_env_file(&storage.runtime_directory().env_path()).unwrap();
|
|
assert_eq!(
|
|
server_env.get("SESSION_SECRET").map(String::as_str),
|
|
Some("session")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_dev_token_write_for_install_missing_file_stages_without_writing() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = Storage::new(dir.path())
|
|
.runtime_directory()
|
|
.dev_token_path();
|
|
|
|
let prepared = prepare_dev_token_write_for_install(&path).unwrap();
|
|
|
|
assert!(validate_dev_token_format(&prepared.token));
|
|
assert!(
|
|
prepared.write.is_some(),
|
|
"missing token file should stage a write"
|
|
);
|
|
assert!(
|
|
!path.exists(),
|
|
"preparing a token must not create the token file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_dev_token_write_for_install_reuses_existing_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = Storage::new(dir.path())
|
|
.runtime_directory()
|
|
.dev_token_path();
|
|
let token = generate_dev_token();
|
|
write_dev_token(&path, &token).unwrap();
|
|
|
|
let prepared = prepare_dev_token_write_for_install(&path).unwrap();
|
|
|
|
assert_eq!(prepared.token, token);
|
|
assert!(
|
|
prepared.write.is_none(),
|
|
"existing valid token should not be rewritten"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prepare_dev_token_write_for_install_rejects_invalid_existing_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = Storage::new(dir.path())
|
|
.runtime_directory()
|
|
.dev_token_path();
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(&path, "not-a-valid-token").unwrap();
|
|
|
|
let err = prepare_dev_token_write_for_install(&path).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("invalid dev token format"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_persistence_plan_direct_writes_staged_dev_token_on_success() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = Storage::new(dir.path());
|
|
let path = storage.runtime_directory().dev_token_path();
|
|
let prepared = prepare_dev_token_write_for_install(&path).unwrap();
|
|
let token = prepared.token.clone();
|
|
|
|
InstallPersistencePlan {
|
|
storage_dir: dir.path(),
|
|
settings_write: None,
|
|
server_env_writes: Vec::new(),
|
|
server_env_removals: Vec::new(),
|
|
dev_token_write: prepared.write,
|
|
vault_writes: Vec::new(),
|
|
vault_removals: Vec::new(),
|
|
}
|
|
.persist_direct()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(read_dev_token_file(&path).as_deref(), Some(token.as_str()));
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
|
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_persistence_plan_direct_does_not_leave_staged_dev_token_on_vault_failure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = Storage::new(dir.path());
|
|
let path = storage.runtime_directory().dev_token_path();
|
|
let prepared = prepare_dev_token_write_for_install(&path).unwrap();
|
|
|
|
let result = InstallPersistencePlan {
|
|
storage_dir: dir.path(),
|
|
settings_write: None,
|
|
server_env_writes: Vec::new(),
|
|
server_env_removals: Vec::new(),
|
|
dev_token_write: prepared.write,
|
|
vault_writes: vec![SecretStoreWrite {
|
|
name: "bad-secret-name".to_string(),
|
|
value: "boom".to_string(),
|
|
secret_type: VaultSecretType::Token,
|
|
description: None,
|
|
}],
|
|
vault_removals: Vec::new(),
|
|
}
|
|
.persist_direct()
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
assert!(
|
|
!path.exists(),
|
|
"failed persistence should not leave a newly staged dev token file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_server_settings_keeps_tcp_bind_separate_from_public_web_url() {
|
|
use fabro_types::settings::server::ServerListenSettings;
|
|
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
merge_server_settings(
|
|
&mut doc,
|
|
"https://fabro.example.com",
|
|
&InstallListenConfig::Tcp("0.0.0.0:32276".to_string()),
|
|
)
|
|
.unwrap();
|
|
|
|
let resolved = ServerSettingsBuilder::from_toml(
|
|
&toml::to_string_pretty(&doc).expect("settings should serialize"),
|
|
)
|
|
.expect("settings should resolve")
|
|
.server;
|
|
match resolved.listen {
|
|
ServerListenSettings::Tcp { address, .. } => {
|
|
assert_eq!(address.to_string(), "0.0.0.0:32276");
|
|
}
|
|
ServerListenSettings::Unix { .. } => {
|
|
panic!("expected tcp listen settings");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn write_object_store_settings_keeps_local_defaults_and_removes_managed_keys() {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::Local {
|
|
root: String::new(),
|
|
})
|
|
.expect("local object store selection should succeed");
|
|
|
|
assert!(
|
|
doc.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("artifacts"))
|
|
.is_none()
|
|
);
|
|
assert!(plan.writes.is_empty());
|
|
assert_eq!(plan.removals.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn write_object_store_settings_configures_local_root() {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::Local {
|
|
root: "/srv/fabro/objects".to_string(),
|
|
})
|
|
.expect("local object store selection should succeed");
|
|
|
|
let server = doc
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.expect("server table should exist");
|
|
assert_eq!(
|
|
server
|
|
.get("artifacts")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|artifacts| artifacts.get("provider"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("local")
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.get("artifacts")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|artifacts| artifacts.get("prefix"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("artifacts")
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.get("artifacts")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|artifacts| artifacts.get("local"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|local| local.get("root"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("/srv/fabro/objects")
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.get("slatedb")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|slatedb| slatedb.get("provider"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("local")
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.get("slatedb")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|slatedb| slatedb.get("prefix"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("slatedb")
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.get("slatedb")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|slatedb| slatedb.get("local"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|local| local.get("root"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("/srv/fabro/objects")
|
|
);
|
|
assert!(plan.writes.is_empty());
|
|
assert_eq!(plan.removals.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn write_object_store_settings_configures_s3_runtime_credentials() {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::S3 {
|
|
bucket: "fabro-data".to_string(),
|
|
region: "us-east-1".to_string(),
|
|
credential_mode: InstallObjectStoreCredentialMode::Runtime,
|
|
access_key_id: None,
|
|
secret_access_key: None,
|
|
})
|
|
.expect("runtime-credential object store selection should succeed");
|
|
|
|
let server = doc
|
|
.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.expect("server table should exist");
|
|
assert_eq!(
|
|
server
|
|
.get("artifacts")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|artifacts| artifacts.get("prefix"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("artifacts")
|
|
);
|
|
assert_eq!(
|
|
server
|
|
.get("slatedb")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|slatedb| slatedb.get("prefix"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("slatedb")
|
|
);
|
|
assert!(plan.writes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn write_object_store_settings_configures_s3_manual_credentials() {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
let plan = write_object_store_settings(&mut doc, &InstallObjectStoreSelection::S3 {
|
|
bucket: "fabro-data".to_string(),
|
|
region: "us-east-1".to_string(),
|
|
credential_mode: InstallObjectStoreCredentialMode::AccessKey,
|
|
access_key_id: Some("AKIA_TEST".to_string()),
|
|
secret_access_key: Some("secret-test".to_string()),
|
|
})
|
|
.expect("manual-credential object store selection should succeed");
|
|
|
|
assert_eq!(plan.writes.len(), 2);
|
|
assert!(
|
|
plan.writes
|
|
.iter()
|
|
.all(|write| write.comment.as_deref() == Some(OBJECT_STORE_MANAGED_COMMENT))
|
|
);
|
|
assert_eq!(
|
|
plan.writes
|
|
.iter()
|
|
.find(|write| write.key == OBJECT_STORE_ACCESS_KEY_ID_ENV)
|
|
.map(|write| write.value.as_str()),
|
|
Some("AKIA_TEST")
|
|
);
|
|
assert_eq!(
|
|
plan.writes
|
|
.iter()
|
|
.find(|write| write.key == OBJECT_STORE_SECRET_ACCESS_KEY_ENV)
|
|
.map(|write| write.value.as_str()),
|
|
Some("secret-test")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn write_sandbox_settings_records_run_default_without_environment_catalog() {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
write_sandbox_settings(&mut doc, InstallSandboxSelection::Docker, true)
|
|
.expect("docker sandbox selection should succeed");
|
|
|
|
assert_eq!(
|
|
doc.get("run")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|run| run.get("environment"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|env| env.get("id"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("default")
|
|
);
|
|
assert!(doc.get("environments").is_none());
|
|
assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
|
|
assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(true));
|
|
assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn write_sandbox_settings_records_daytona_policy_without_environment_catalog() {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
write_sandbox_settings(&mut doc, InstallSandboxSelection::Daytona, true)
|
|
.expect("daytona sandbox selection should succeed");
|
|
|
|
assert_eq!(
|
|
doc.get("run")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|run| run.get("environment"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|env| env.get("id"))
|
|
.and_then(toml::Value::as_str),
|
|
Some("default")
|
|
);
|
|
assert!(doc.get("environments").is_none());
|
|
assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
|
|
assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(false));
|
|
assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn write_sandbox_settings_disables_local_when_not_allowed() {
|
|
let mut doc = toml::Value::Table(toml::Table::default());
|
|
write_sandbox_settings(&mut doc, InstallSandboxSelection::Docker, false)
|
|
.expect("docker sandbox selection should succeed");
|
|
|
|
assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(false));
|
|
assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(true));
|
|
assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(false));
|
|
}
|
|
|
|
fn sandbox_provider_enabled(doc: &toml::Value, provider: &str) -> Option<bool> {
|
|
doc.get("server")
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|server| server.get("sandbox"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|sandbox| sandbox.get("providers"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|providers| providers.get(provider))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|provider| provider.get("enabled"))
|
|
.and_then(toml::Value::as_bool)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_install_outputs_direct_only_removes_marked_object_store_keys() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = Storage::new(dir.path());
|
|
let env_path = storage.runtime_directory().env_path();
|
|
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
|
|
std::fs::write(
|
|
&env_path,
|
|
format!(
|
|
"{OBJECT_STORE_ACCESS_KEY_ID_ENV}=operator-access\n# {OBJECT_STORE_MANAGED_COMMENT}\n{OBJECT_STORE_ACCESS_KEY_ID_ENV}=managed-access\n{OBJECT_STORE_SECRET_ACCESS_KEY_ENV}=operator-secret\nKEEP_ME=1\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
persist_install_outputs_direct(
|
|
dir.path(),
|
|
&[],
|
|
&[envfile::EnvFileRemoval {
|
|
key: OBJECT_STORE_ACCESS_KEY_ID_ENV.to_string(),
|
|
comment: Some(OBJECT_STORE_MANAGED_COMMENT.to_string()),
|
|
}],
|
|
&[],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("env-only persistence should succeed");
|
|
|
|
let server_env = envfile::read_env_file(&env_path).unwrap();
|
|
assert_eq!(
|
|
server_env
|
|
.get(OBJECT_STORE_ACCESS_KEY_ID_ENV)
|
|
.map(String::as_str),
|
|
Some("operator-access")
|
|
);
|
|
assert_eq!(
|
|
server_env
|
|
.get(OBJECT_STORE_SECRET_ACCESS_KEY_ENV)
|
|
.map(String::as_str),
|
|
Some("operator-secret")
|
|
);
|
|
assert_eq!(server_env.get("KEEP_ME").map(String::as_str), Some("1"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn persist_install_outputs_direct_writes_private_server_env_permissions() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = Storage::new(dir.path());
|
|
let env_path = storage.runtime_directory().env_path();
|
|
|
|
persist_install_outputs_direct(
|
|
dir.path(),
|
|
&[envfile::EnvFileUpdate {
|
|
key: "SESSION_SECRET".to_string(),
|
|
value: "first".to_string(),
|
|
comment: None,
|
|
}],
|
|
&[],
|
|
&[],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("initial env write should succeed");
|
|
let create_mode = std::fs::metadata(&env_path).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(create_mode, 0o600);
|
|
|
|
persist_install_outputs_direct(
|
|
dir.path(),
|
|
&[envfile::EnvFileUpdate {
|
|
key: "SESSION_SECRET".to_string(),
|
|
value: "second".to_string(),
|
|
comment: None,
|
|
}],
|
|
&[],
|
|
&[],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("rewrite env write should succeed");
|
|
let update_mode = std::fs::metadata(&env_path).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(update_mode, 0o600);
|
|
}
|
|
}
|